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/651] [it8951] Hold display enable pins off during deep-sleep (#20471) --- esphome/components/it8951/display.py | 4 ++-- esphome/components/it8951/it8951.cpp | 6 +++++ esphome/components/it8951/it8951.h | 1 + tests/component_tests/it8951/__init__.py | 0 tests/component_tests/it8951/test_init.py | 28 +++++++++++++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/it8951/__init__.py create mode 100644 tests/component_tests/it8951/test_init.py diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index b5f3019a126..76fbadd1968 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -145,8 +145,8 @@ IT8951Model( reset_pin=12, cs_pin=10, # Board power-enable rails: 1.8V logic supply (GPIO21) and the EPD supply - # (GPIO11). Driven high during setup so no separate power_supply is needed. - enable_pin=[21, 11], + # (GPIO11). Driven high during setup so no separate power_supply is needed, and held low during deep_sleep. + enable_pin=[{"number": 21, "hold_state": True}, {"number": 11, "hold_state": True}], vcom=1400, # reTerminal E1003 panel firmware only accepts the 0x0002 VCOM SET # selector; using the default 0x0001 leaves VCOM unchanged and breaks diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 2cfcf111ea3..fe99017774d 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -351,6 +351,12 @@ void IT8951Display::on_safe_shutdown() { this->spi_cmd_(TCON_SLEEP); } +void IT8951Display::on_powerdown() { + for (auto *pin : this->enable_pins_) { + pin->digital_write(false); + } +} + // --- Init op enqueuers ------------------------------------------------------- void IT8951Display::enqueue_init_reset_() { diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index fcdc6d1d196..e1273587c45 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -150,6 +150,7 @@ class IT8951Display : public Display, void loop() override; void dump_config() override; void on_safe_shutdown() override; + void on_powerdown() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } // --- Config setters (called from generated code) --- diff --git a/tests/component_tests/it8951/__init__.py b/tests/component_tests/it8951/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/it8951/test_init.py b/tests/component_tests/it8951/test_init.py new file mode 100644 index 00000000000..46c203e5eb5 --- /dev/null +++ b/tests/component_tests/it8951/test_init.py @@ -0,0 +1,28 @@ +"""Tests for it8951 configuration validation.""" + +from collections.abc import Callable +from typing import Any + +from esphome.components.const import CONF_HOLD_STATE +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3 +from esphome.components.it8951.display import CONFIG_SCHEMA +from esphome.const import CONF_ENABLE_PIN, CONF_NUMBER, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_reterminal_e1003_enable_pins_hold_state( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The reTerminal E1003 enable pins must hold their state during deep sleep.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 7, "mosi_pin": 9}) + + config = CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-e1003"}) + + enable_pins = config[CONF_ENABLE_PIN] + assert [pin[CONF_NUMBER] for pin in enable_pins] == [21, 11] + assert all(pin[CONF_HOLD_STATE] for pin in enable_pins) From 721228c1d2f5604efc81ac4ffd53d236cdb1f6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:31:06 +0200 Subject: [PATCH 002/651] [teleinfo] Add predefined properties for main tags of TIC standard and historical (#9167) Co-authored-by: Claude Opus 4.6 Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/teleinfo/__init__.py | 2 +- .../components/teleinfo/sensor/__init__.py | 144 ++++++++++++++++- tests/component_tests/teleinfo/__init__.py | 0 tests/component_tests/teleinfo/test_sensor.py | 149 ++++++++++++++++++ tests/components/teleinfo/common.yaml | 86 +++++++++- 6 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 tests/component_tests/teleinfo/__init__.py create mode 100644 tests/component_tests/teleinfo/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index a851e62a3e5..4264479349c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -570,7 +570,7 @@ esphome/components/tca9555/* @mobrembski esphome/components/tcl112/* @glmnet esphome/components/tcp_uart/* @Bascht74 esphome/components/tee501/* @Stock-M -esphome/components/teleinfo/* @0hax +esphome/components/teleinfo/* @0hax @FredM67 esphome/components/tem3200/* @bakerkj esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/template/datetime/* @rfdarter diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 8caf0202129..5a6a01f6695 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -CODEOWNERS = ["@0hax"] +CODEOWNERS = ["@0hax", "@FredM67"] DOMAIN = "teleinfo" MULTI_CONF = True diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index b51d4cb7953..6a23836c8ab 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -1,10 +1,32 @@ import esphome.codegen as cg from esphome.components import sensor +import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, CONF_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, ICON_FLASH, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_MILLIVOLT, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -13,19 +35,125 @@ from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, telein TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = sensor.sensor_schema( - TeleInfoSensor, - unit_of_measurement=UNIT_WATT_HOURS, - icon=ICON_FLASH, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, -).extend(TELEINFO_LISTENER_SCHEMA) + +def _preset(unit: str, device_class: str, state_class: str) -> dict[str, str]: + return { + CONF_UNIT_OF_MEASUREMENT: unit, + CONF_DEVICE_CLASS: device_class, + CONF_STATE_CLASS: state_class, + } + + +_ENERGY = _preset(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING) +_REACTIVE_ENERGY = _preset( + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, +) +_CURRENT = _preset(UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT) +_VOLTAGE = _preset(UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT) +_APPARENT_POWER_VA = _preset( + UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_APPARENT_POWER_KVA = _preset( + UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT) + +# Legacy defaults, kept for tags without a known preset +_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH} + +# Units that are valid for each device class, used to decide whether a preset's +# device class can be kept when the user overrides the unit (and vice versa) +_COMPATIBLE_UNITS = { + DEVICE_CLASS_ENERGY: {UNIT_WATT_HOURS, UNIT_KILOWATT_HOURS}, + DEVICE_CLASS_REACTIVE_ENERGY: { + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + }, + DEVICE_CLASS_CURRENT: {UNIT_AMPERE, UNIT_MILLIAMP}, + DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT}, + DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS}, + DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT}, +} + +# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant. +TIC_TAG_CONFIGS = { + # Standard mode + "EA": _ENERGY, + "ER": _REACTIVE_ENERGY, + "IRMS": _CURRENT, + "URMS": _VOLTAGE, + "UMOY": _VOLTAGE, + "SINST": _APPARENT_POWER_VA, + "SMAX": _APPARENT_POWER_VA, + "CC": _POWER, + "PREF": _APPARENT_POWER_KVA, + "PCOUP": _APPARENT_POWER_KVA, + # Historical mode + "BASE": _ENERGY, + "HCH": _ENERGY, + "EJP": _ENERGY, + "BBRH": _ENERGY, + "IINST": _CURRENT, + "IMAX": _CURRENT, + "ISOUSC": _CURRENT, + "ADPS": _CURRENT, + "ADIR": _CURRENT, + "PAPP": _APPARENT_POWER_VA, + "PMAX": _POWER, +} + + +def apply_tag_config(config: ConfigType) -> ConfigType: + """Apply preset configurations based on the tag name. + + Only keys not set by the user are filled in. For known tags, a preset unit or + device class is dropped if it is incompatible with the one set by the user. + Unknown tags get the legacy defaults, merged key by key as before. + """ + if CONF_TAG_NAME not in config: + return config + + # Runs before cv.string, so the raw value may not be a string yet + tag_name = str(config[CONF_TAG_NAME]) + preset = next( + (p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)), + None, + ) + + config = dict(config) + if preset is None: + for key, value in _LEGACY_DEFAULT.items(): + config.setdefault(key, value) + return config + + unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT]) + device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS]) + compatible = unit in _COMPATIBLE_UNITS.get(device_class, ()) + + for key, value in preset.items(): + if key in config: + continue + if key in (CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS) and not compatible: + continue + config[key] = value + return config + + +CONFIG_SCHEMA = cv.All( + apply_tag_config, + sensor.sensor_schema( + TeleInfoSensor, + accuracy_decimals=0, + ).extend(TELEINFO_LISTENER_SCHEMA), +) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await sensor.register_sensor(var, config) + teleinfo = await cg.get_variable(config[CONF_TELEINFO_ID]) cg.add(teleinfo.register_teleinfo_listener(var)) diff --git a/tests/component_tests/teleinfo/__init__.py b/tests/component_tests/teleinfo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/teleinfo/test_sensor.py b/tests/component_tests/teleinfo/test_sensor.py new file mode 100644 index 00000000000..a8f9420deac --- /dev/null +++ b/tests/component_tests/teleinfo/test_sensor.py @@ -0,0 +1,149 @@ +"""Tests for teleinfo sensor tag presets.""" + +import pytest + +from esphome.components.teleinfo import CONF_TAG_NAME +from esphome.components.teleinfo.sensor import apply_tag_config +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, + ICON_FLASH, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, + UNIT_WATT_HOURS, +) + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class", "state_class"), + [ + ("EAST", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ( + "ERQ1", + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, + ), + ("IRMS1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("URMS1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT), + ( + "SINSTS", + UNIT_VOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ( + "PREF", + UNIT_KILOVOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ("CCASN", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ("HCHC", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("BBRHCJB", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT), + ("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ], +) +def test_known_tag_preset( + tag: str, unit: str, device_class: str, state_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + assert config[CONF_STATE_CLASS] == state_class + assert CONF_ICON not in config + + +def test_unknown_tag_keeps_legacy_defaults() -> None: + config = apply_tag_config({CONF_TAG_NAME: "MYTAG"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_user_unit_keeps_legacy_device_class() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "MYTAG", CONF_UNIT_OF_MEASUREMENT: UNIT_KILOWATT_HOURS} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KILOWATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_starting_with_u_is_not_voltage() -> None: + config = apply_tag_config({CONF_TAG_NAME: "UNKNOWN"}) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +def test_numeric_tag_name_does_not_crash() -> None: + config = apply_tag_config({CONF_TAG_NAME: 1234}) + assert config[CONF_TAG_NAME] == 1234 + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class"), + [ + ("HCHP", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("EAST", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("PREF", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER), + ("IINST1", UNIT_MILLIAMP, DEVICE_CLASS_CURRENT), + ], +) +def test_user_compatible_unit_keeps_preset_device_class( + tag: str, unit: str, device_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag, CONF_UNIT_OF_MEASUREMENT: unit}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + + +def test_user_incompatible_unit_skips_preset_device_class() -> None: + config = apply_tag_config({CONF_TAG_NAME: "PAPP", CONF_UNIT_OF_MEASUREMENT: "W"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == "W" + assert CONF_DEVICE_CLASS not in config + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + + +def test_user_incompatible_device_class_skips_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_POWER} + ) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert CONF_UNIT_OF_MEASUREMENT not in config + + +def test_user_same_device_class_keeps_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_VOLT_AMPS + + +def test_user_state_class_is_kept() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "HCHP", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT} + ) + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS diff --git a/tests/components/teleinfo/common.yaml b/tests/components/teleinfo/common.yaml index dcb2cb14bdf..7f73bca62c2 100644 --- a/tests/components/teleinfo/common.yaml +++ b/tests/components/teleinfo/common.yaml @@ -22,11 +22,95 @@ teleinfo: update_interval: 60s sensor: + # Historical mode tags - auto-configured via apply_tag_config - platform: teleinfo name: hchc tag_name: HCHC teleinfo_id: test_teleinfo - unit_of_measurement: Wh + - platform: teleinfo + name: base + tag_name: BASE + teleinfo_id: test_teleinfo + - platform: teleinfo + name: iinst1 + tag_name: IINST1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: papp + tag_name: PAPP + teleinfo_id: test_teleinfo + - platform: teleinfo + name: bbrhcjb + tag_name: BBRHCJB + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ejphn + tag_name: EJPHN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pmax + tag_name: PMAX + teleinfo_id: test_teleinfo + - platform: teleinfo + name: imax1 + tag_name: IMAX1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: isousc + tag_name: ISOUSC + teleinfo_id: test_teleinfo + - platform: teleinfo + name: adps + tag_name: ADPS + teleinfo_id: test_teleinfo + # Standard mode tags - auto-configured via apply_tag_config + - platform: teleinfo + name: east + tag_name: EAST + teleinfo_id: test_teleinfo + - platform: teleinfo + name: erqt + tag_name: ERQT + teleinfo_id: test_teleinfo + - platform: teleinfo + name: irms1 + tag_name: IRMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: urms1 + tag_name: URMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: sinsts + tag_name: SINSTS + teleinfo_id: test_teleinfo + - platform: teleinfo + name: smaxsn + tag_name: SMAXSN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ccasn + tag_name: CCASN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pref + tag_name: PREF + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pcoup + tag_name: PCOUP + teleinfo_id: test_teleinfo + # Explicit override - user-specified unit should not be overwritten + - platform: teleinfo + name: hchp_override + tag_name: HCHP + teleinfo_id: test_teleinfo + unit_of_measurement: kWh + # Unknown tag - no auto-config match, should still compile + - platform: teleinfo + name: custom_tag + tag_name: MYTAG + teleinfo_id: test_teleinfo text_sensor: - platform: teleinfo From f4fbc3215d226c4f9bb47ab3650e14935e258acc Mon Sep 17 00:00:00 2001 From: jabla Date: Sat, 10 Oct 2026 19:28:40 +0200 Subject: [PATCH 003/651] [espidf] Clean the build when the IDF target changes (#20473) --- esphome/espidf/toolchain.py | 50 ++++++ tests/unit_tests/test_espidf_toolchain.py | 199 ++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index af3fda63495..871e74d36d3 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -1,5 +1,6 @@ """ESP-IDF direct build API for ESPHome.""" +import contextlib from dataclasses import dataclass, field import fnmatch import json @@ -124,6 +125,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] = os.environ.copy() # Do not leak PYTHONPATH into child env env_cache[version].pop("PYTHONPATH", None) + # Do not let an inherited IDF_TARGET override the variant + env_cache[version].pop("IDF_TARGET", None) # Use provided IDF framework if available if _esphome_manages_idf(): @@ -227,6 +230,8 @@ FILTER_IDF_LINES: list[str] = [ # click's boolean spellings, which idf.py applies to IDF_CCACHE_ENABLE. _CLICK_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"}) _CMAKECACHE_LINE = re.compile(r"^([^#/:=]+):([^:=]+)=(.*)$") +_SDKCONFIG_TARGET_LINE = re.compile(r'^CONFIG_IDF_TARGET="([^"]*)"$', re.MULTILINE) +_TOOLCHAIN_FILE_TARGET = re.compile(r"toolchain-(?:clang-)?(\w+)\.cmake$") @dataclass(frozen=True, kw_only=True) @@ -310,6 +315,50 @@ def tree_skips_bootloader(build_dir: Path) -> bool: return cache.get(SKIP_BOOTLOADER_DEFINE) == "1" +def _foreign_idf_target(target: str) -> str | None: + """Return another IDF target recorded in the CMake caches or sdkconfig.""" + build_dir = _build_dir() + sdkconfig = CORE.relative_build_path(f"sdkconfig.{CORE.name}") + found = [] + for cache_path in ( + build_dir / "CMakeCache.txt", + build_dir / "bootloader" / "CMakeCache.txt", + ): + with contextlib.suppress(OSError, ValueError): + cache = _parse_cmakecache(cache_path) + found.append(cache.get("IDF_TARGET")) + if m := _TOOLCHAIN_FILE_TARGET.search( + cache.get("CMAKE_TOOLCHAIN_FILE", "") + ): + found.append(m.group(1)) + with contextlib.suppress(OSError, ValueError): + if m := _SDKCONFIG_TARGET_LINE.search(sdkconfig.read_text(encoding="utf-8")): + found.append(m.group(1)) + return next((t for t in found if t and t != target), None) + + +def _clean_foreign_target_tree() -> None: + """Clean a build configured for another IDF target, like idf.py set-target.""" + variant = CORE.data.get(KEY_ESP32, {}).get(KEY_VARIANT) + if variant is None: + return + target = variant_to_idf_target(variant) + if (old := _foreign_idf_target(target)) is None: + return + from esphome.writer import clean_build + + _LOGGER.info( + "IDF target changed from %s to %s, cleaning build files...", old, target + ) + clean_build(clear_pio_cache=False) + if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): + return + # Restart sdkconfig from ESPHome's options + internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal") + if internal.is_file(): + shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}")) + + def _skip_bootloader() -> bool: """Whether this tree should not build a bootloader at all; per-run memo.""" cache = _cache() @@ -804,6 +853,7 @@ def run_compile(config, verbose: bool) -> int: 3. Run full build """ jobs = _build_jobs(config) + _clean_foreign_target_tree() if need_reconfigure(): if (rc := _configure_project(verbose)) != 0: return rc diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 2a799a4d84a..c73858ed529 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -7,6 +7,7 @@ from contextlib import contextmanager import json import os from pathlib import Path +import shutil import subprocess from unittest.mock import MagicMock, call, patch @@ -262,6 +263,14 @@ def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: assert "PYTHONPATH" not in env +def test_get_idf_env_pops_inherited_idf_target(setup_core: Path) -> None: + """An inherited IDF_TARGET does not reach cmake.""" + toolchain._cache().env.clear() + with patch.dict(os.environ, {"IDF_PATH": str(setup_core), "IDF_TARGET": "esp32"}): + env = toolchain._get_idf_env(version="5.5.4") + assert "IDF_TARGET" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. @@ -1575,3 +1584,193 @@ def test_missing_image_hint_names_the_flag(setup_core: Path) -> None: f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n" ) assert "--skip-bootloader" in toolchain.missing_image_hint() + + +def _write_target_tree( + main: str | None = None, + bootloader: str | None = None, + bootloader_toolchain: str | None = None, + sdkconfig: str | None = None, +) -> None: + """Write the target into the CMake caches and sdkconfig.""" + tools = "/idf/tools/cmake" + for rel, target, toolchain_target in ( + ("build", main, main), + ("build/bootloader", bootloader, bootloader_toolchain or bootloader), + ): + if target is None: + continue + cache = CORE.relative_build_path(rel, "CMakeCache.txt") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text( + f"CMAKE_TOOLCHAIN_FILE:FILEPATH={tools}/toolchain-{toolchain_target}.cmake\n" + f"IDF_TARGET:STRING={target}\n" + ) + if sdkconfig is not None: + path = CORE.relative_build_path("sdkconfig.test") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'CONFIG_FOO=y\nCONFIG_IDF_TARGET="{sdkconfig}"\n') + + +@pytest.mark.parametrize( + ("tree", "expected"), + [ + ({}, None), + ({"main": "esp32c6", "bootloader": "esp32c6", "sdkconfig": "esp32c6"}, None), + ({"main": "esp32h2"}, "esp32h2"), + ({"bootloader": "esp32h2"}, "esp32h2"), + # Failed bootloader configure: new IDF_TARGET, old toolchain file + ({"bootloader": "esp32c6", "bootloader_toolchain": "esp32h2"}, "esp32h2"), + ({"main": "esp32c6", "sdkconfig": "esp32h2"}, "esp32h2"), + ( + { + "main": "esp32c6", + "bootloader": "esp32c6", + "bootloader_toolchain": "clang-esp32c6", + }, + None, + ), + ( + { + "main": "esp32c6", + "bootloader": "esp32c6", + "bootloader_toolchain": "clang-esp32h2", + }, + "esp32h2", + ), + ], +) +def test_foreign_idf_target( + setup_core: Path, tree: dict[str, str], expected: str | None +) -> None: + """Another target in any cache or sdkconfig counts.""" + _setup_build(setup_core) + _write_target_tree(**tree) + assert toolchain._foreign_idf_target("esp32c6") == expected + + +@pytest.mark.parametrize( + ("cache", "sdkconfig", "expected"), + [ + ("IDF_TARGET:STRING=esp32h2\n", None, "esp32h2"), + (None, "CONFIG_FOO=y\n", None), + ], +) +def test_foreign_idf_target_partial_entries( + setup_core: Path, cache: str | None, sdkconfig: str | None, expected: str | None +) -> None: + """Missing entries are skipped.""" + _setup_build(setup_core) + for rel, content in ( + ("build/CMakeCache.txt", cache), + ("sdkconfig.test", sdkconfig), + ): + if content is not None: + path = CORE.relative_build_path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + assert toolchain._foreign_idf_target("esp32c6") == expected + + +def test_foreign_idf_target_ignores_unreadable_files(setup_core: Path) -> None: + """Unreadable files are ignored.""" + _setup_build(setup_core) + for rel in ("build/CMakeCache.txt", "sdkconfig.test"): + path = CORE.relative_build_path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\xff\xfe IDF_TARGET:STRING=esp32h2\n") + assert toolchain._foreign_idf_target("esp32c6") is None + + +@pytest.mark.parametrize("snapshot", [True, False]) +def test_clean_foreign_target_tree_cleans_and_resets_sdkconfig( + setup_core: Path, snapshot: bool +) -> None: + """A foreign tree is cleaned and sdkconfig reset from the snapshot.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(bootloader="esp32c6", bootloader_toolchain="esp32h2") + _write_target_tree(sdkconfig="esp32h2") + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + if snapshot: + internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n") + + with patch( + "esphome.writer.clean_build", + side_effect=lambda **_: shutil.rmtree(CORE.relative_build_path("build")), + ) as mock_clean: + toolchain._clean_foreign_target_tree() + + mock_clean.assert_called_once_with(clear_pio_cache=False) + sdkconfig = CORE.relative_build_path("sdkconfig.test").read_text() + if snapshot: + assert sdkconfig == "CONFIG_IDF_TARGET_ESP32C6=y\n" + else: + assert 'CONFIG_IDF_TARGET="esp32h2"' in sdkconfig + + +@pytest.mark.parametrize("variant", ["ESP32C6", None]) +def test_clean_foreign_target_tree_keeps_matching_tree( + setup_core: Path, variant: str | None +) -> None: + """A matching tree or unknown variant is left alone.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + _write_target_tree(main="esp32c6", bootloader="esp32c6", sdkconfig="esp32c6") + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n") + sdkconfig = CORE.relative_build_path("sdkconfig.test") + before = sdkconfig.read_text() + + with patch("esphome.writer.clean_build") as mock_clean: + toolchain._clean_foreign_target_tree() + + mock_clean.assert_not_called() + assert sdkconfig.read_text() == before + + +@pytest.mark.parametrize("main", ["esp32h2", None]) +def test_clean_foreign_target_tree_skipped_clean_keeps_sdkconfig( + setup_core: Path, monkeypatch: pytest.MonkeyPatch, main: str | None +) -> None: + """ESPHOME_SKIP_CLEAN_BUILD keeps the tree and sdkconfig.""" + _setup_build(setup_core) + monkeypatch.setenv("ESPHOME_SKIP_CLEAN_BUILD", "1") + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(main=main, sdkconfig="esp32h2") + CORE.relative_build_path("sdkconfig.test.esphomeinternal").write_text( + "CONFIG_IDF_TARGET_ESP32C6=y\n" + ) + + toolchain._clean_foreign_target_tree() + + assert CORE.relative_build_path("build/CMakeCache.txt").is_file() == bool(main) + assert 'CONFIG_IDF_TARGET="esp32h2"' in ( + CORE.relative_build_path("sdkconfig.test").read_text() + ) + + +def test_run_compile_cleans_foreign_target_tree_first(setup_core: Path) -> None: + """run_compile cleans a foreign tree before the reconfigure check.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(main="esp32h2") + calls: list[str] = [] + + with ( + patch( + "esphome.writer.clean_build", + side_effect=lambda **_: calls.append("clean"), + ), + patch.object( + toolchain, + "need_reconfigure", + side_effect=lambda: calls.append("check") or False, + ), + patch.object(toolchain, "_cache_entries_changed", return_value=False), + patch.object(toolchain, "_run_ninja", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + + assert calls == ["clean", "check"] From 4ba0e1bfa2796116ec831d5a46e8eb54954f18f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 07:39:49 -1000 Subject: [PATCH 004/651] [core] Fix the native ESP8266 and host builds on Windows without ccache (#20468) --- esphome/build_gen/arduino8266.py | 2 +- esphome/build_gen/host.py | 2 +- esphome/build_helpers/ninja_gen.py | 21 ++++++++++++------- .../unit_tests/build_gen/test_arduino8266.py | 3 ++- tests/unit_tests/build_gen/test_host.py | 5 ++++- .../build_helpers/test_ninja_gen.py | 18 ++++++++++++++++ 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 44369136a01..b0ba7663927 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1057,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: cxx = (toolchain_tool(paths.toolchain, "g++"),) lines = [ *tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), *pch_rule_lines(), "rule link", diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py index f1e53e1ccb2..3a8587891b2 100644 --- a/esphome/build_gen/host.py +++ b/esphome/build_gen/host.py @@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: lines = [ *tool_lines(compilers.cc, compilers.cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index a2ee244a29f..c74262fdeea 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -74,33 +74,40 @@ def tool_lines( # path gets f"python = {_q(strip_win_long_path_prefix(sys.executable))}", f"buildtool = {_q(_BUILD_TOOL)}", - f"ccache = {_q(ccache) if ccache else ''}", + *([f"ccache = {_q(ccache)}"] if ccache else []), "", ] -def compile_rule_lines() -> list[str]: - """The compile rules; ``$own_includes`` is empty unless an edge sets it.""" +def compile_rule_lines(ccache: str | None) -> list[str]: + """The compile rules; ``$own_includes`` is empty unless an edge sets it. + + ``ccache`` is the launcher ``tool_lines`` was given; the same test here + keeps the variable and the rules in step. It is spliced in rather than + left as an empty variable: a leading space on the command line makes + CreateProcess fail on Windows. + """ + launcher = "$ccache " if ccache else "" return [ "rule c", - " command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CC $out", "rule cxx", - " command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", + f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CXX $out", "rule aspp", - " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = AS $out", # Plain assembler, as SCons's ASCOM: no preprocessor, so no # depfile and no $flags (defines/includes) either "rule asm", - " command = $ccache $cc -x assembler $asflags -c $in -o $out", + f" command = {launcher}$cc -x assembler $asflags -c $in -o $out", " description = AS $out", ] diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 2ea4ffabe6e..4c60b4e4764 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -888,8 +888,9 @@ def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None _set_flags() content = _write_ninja(paths) assert "lowlevel.s.o: asm " in content + assert "rule asm\n command = $cc -x assembler $asflags -c $in -o $out" in content assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in ( - content + _write_ninja(paths, ccache="/cc/ccache") ) diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py index 9e40698c91c..c3936ee65a8 100644 --- a/tests/unit_tests/build_gen/test_host.py +++ b/tests/unit_tests/build_gen/test_host.py @@ -308,7 +308,10 @@ def test_write_project_pch_skipped_for_a_force_include_in_the_compiler( def test_write_project_without_ccache(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") _changed, ninja = _render() - assert "ccache = \n" in ninja + # No launcher variable, and no leading space on the commands either: + # CreateProcess on Windows rejects a command line that starts with one + assert "$ccache" not in ninja + assert " command = $cxx -MMD -MF $out.d" in ninja def test_write_project_routes_user_link_flags(tmp_path: Path) -> None: diff --git a/tests/unit_tests/build_helpers/test_ninja_gen.py b/tests/unit_tests/build_helpers/test_ninja_gen.py index e7f9b96af2e..d51110efad8 100644 --- a/tests/unit_tests/build_helpers/test_ninja_gen.py +++ b/tests/unit_tests/build_helpers/test_ninja_gen.py @@ -55,3 +55,21 @@ def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None: tmp_path / "b.cpp", tmp_path / "sub" / "c.S", ] + + +@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None]) +def test_compile_rule_lines_splice_the_launcher(ccache: str | None) -> None: + """Without ccache no command keeps a leading space, which CreateProcess + on Windows rejects; with it every compile rule starts with the launcher.""" + commands = [ + line.removeprefix(" command = ") + for line in ninja_gen.compile_rule_lines(ccache) + if line.startswith(" command = ") + ] + assert len(commands) == 4 + for command in commands: + if ccache: + assert command.startswith("$ccache $c") + else: + assert command.startswith("$c") + assert "$ccache" not in command From 9f4a7cf5cc715e8795ddc4c270c061669dfee847 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 10 Oct 2026 19:49:35 +0200 Subject: [PATCH 005/651] [nrf52] fix build error on 3.4.0 with pch ON (#20474) --- esphome/components/nrf52/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7cd0c6cdc1b..de9dc6401e4 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -835,7 +835,7 @@ _PCH_CMAKE_LINES = [ "set(esphome_kept_options)", "set(esphome_pch_headers)", "foreach(option IN LISTS esphome_options)", - ' if(option MATCHES "imacros> (.+)$")', + ' if(option MATCHES "imacros> ([^>]+)")', ' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")', " list(APPEND esphome_kept_options", ' "$<$,$,app>>>:${option}>")', From 3447b681363f6aae7aeed9f7a1570b7028b5dc7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 08:00:40 -1000 Subject: [PATCH 006/651] [ci] Compile an ESP8266 config with the native toolchain on Windows (#20467) --- .github/workflows/ci.yml | 86 +++++++++++++++++++++++++++++++++ script/test_build_components.py | 5 +- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 586263c6514..285d6869e12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1335,6 +1335,91 @@ jobs: # config first, so a separate config pass is redundant. python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests + test-esp8266-native-windows: + name: Compile esp8266 with the native toolchain on Windows (ccache ${{ matrix.ccache }}) + runs-on: windows-latest + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true' + # A hung compiler must fail the job, not hold the runner for six hours. + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + # Both ways the build runs on Windows: ESPHome Desktop bundles + # ccache and puts it on PATH, a pip install has none. + ccache: ["1", "0"] + env: + ESPHOME_CCACHE_ENABLE: ${{ matrix.ccache }} + # The test script prints characters the cp1252 console cannot encode + PYTHONUTF8: "1" + # The exact static build ESPHome Desktop ships + CCACHE_URL: https://github.com/ccache/ccache/releases/download/v4.14/ccache-4.14-windows-x86_64.zip + CCACHE_SHA256: 2568347a697e103ca1b073981c704ad76fb2507d066c38dba038dd73399d968f + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Install ccache + if: matrix.ccache == '1' + shell: pwsh + run: | + $dir = Join-Path $env:RUNNER_TEMP "ccache" + New-Item -ItemType Directory -Force $dir | Out-Null + $zip = Join-Path $dir "ccache.zip" + Invoke-WebRequest -Uri $env:CCACHE_URL -OutFile $zip + $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower() + if ($hash -ne $env:CCACHE_SHA256) { + Write-Host "::error::ccache archive sha256 $hash does not match $env:CCACHE_SHA256" + exit 1 + } + Expand-Archive -Path $zip -DestinationPath $dir + $bin = Get-ChildItem -Path $dir -Recurse -Filter ccache.exe | Select-Object -First 1 + Add-Content -Path $env:GITHUB_PATH -Value $bin.DirectoryName + & $bin.FullName --version + + - name: Compile + shell: pwsh + # One component test: web_server brings wifi, api and the async web + # server libraries, the shape of a typical ESP8266 device + run: | + . .\venv\Scripts\Activate.ps1 + $arguments = "script/test_build_components.py", "-e", "compile", "-t", "esp8266-ard", "-c", "web_server", "--base-only", "--toolchain", "arduino", "--fail-on-no-tests" + $p = Start-Process -FilePath python -ArgumentList $arguments -NoNewWindow -PassThru + # Reading the handle keeps ExitCode available after the process exits + $null = $p.Handle + if (-not $p.WaitForExit(25 * 60 * 1000)) { + Write-Host "::error::The compile did not finish in 25 minutes; processes still running:" + Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -match 'xtensa|ccache|ninja|esphome' } | + Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine | + Format-List | Out-String -Width 4096 | Write-Host + Get-Process | + Where-Object { $_.Name -match 'cc1|ccache|ninja|xtensa|python' } | + Select-Object Id, Name, CPU, WorkingSet64 | + Format-Table | Out-String | Write-Host + taskkill /PID $p.Id /T /F + exit 1 + } + exit $p.ExitCode + + - name: Show ccache statistics + # Also after a failed or killed compile: the counters say whether + # ccache was ever reached + if: always() && matrix.ccache == '1' + shell: pwsh + # The build keeps its store under the esphome tools cache + run: | + $env:CCACHE_DIR = Join-Path $env:LOCALAPPDATA "esphome\Cache\arduino8266\ccache" + ccache -s + device-builder: name: Test downstream esphome/device-builder runs-on: ubuntu-24.04 @@ -1705,6 +1790,7 @@ jobs: - test-build-components-split - test-esp32-platformio - test-esp8266-native + - test-esp8266-native-windows - device-builder - memory-impact-target-branch - memory-impact-pr-branch diff --git a/script/test_build_components.py b/script/test_build_components.py index c955391a619..85e4df38f17 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -21,6 +21,7 @@ from dataclasses import dataclass import hashlib import os from pathlib import Path +import shutil import subprocess import sys import time @@ -83,7 +84,9 @@ def show_disk_space_if_ci(esphome_command: str) -> None: print("=" * 80) # Use sys.stdout.flush() to ensure output appears immediately sys.stdout.flush() - subprocess.run(["df", "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr) + # Windows has no df + if df := shutil.which("df"): + subprocess.run([df, "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr) print("=" * 80 + "\n") sys.stdout.flush() From eb1afa4245e32f6bf806f8a84817d136d9ca7fa6 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:15:30 +0200 Subject: [PATCH 007/651] [tests] Close leftover server connections in the tcp_uart bridge and TCP client link tests (#20215) Co-authored-by: J. Nick Koston --- tests/integration/test_socket_tcp_client_link.py | 6 ++++++ tests/integration/test_tcp_uart_bridge.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/integration/test_socket_tcp_client_link.py b/tests/integration/test_socket_tcp_client_link.py index 16b5f544f79..89a6ff42347 100644 --- a/tests/integration/test_socket_tcp_client_link.py +++ b/tests/integration/test_socket_tcp_client_link.py @@ -35,6 +35,7 @@ async def test_socket_tcp_client_link( link_down = asyncio.Event() second_link_up = asyncio.Event() link_up_count = 0 + writers: list[asyncio.StreamWriter] = [] def on_log_line(line: str) -> None: nonlocal link_up_count @@ -48,6 +49,7 @@ async def test_socket_tcp_client_link( async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: + writers.append(writer) if not echo_done.is_set(): writer.write(PAYLOAD) await writer.drain() @@ -98,5 +100,9 @@ async def test_socket_tcp_client_link( "Second session echo wrong; stale bytes from the first session?" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() diff --git a/tests/integration/test_tcp_uart_bridge.py b/tests/integration/test_tcp_uart_bridge.py index fb22a26b558..70befc90498 100644 --- a/tests/integration/test_tcp_uart_bridge.py +++ b/tests/integration/test_tcp_uart_bridge.py @@ -41,12 +41,14 @@ async def test_tcp_uart_bridge( service_byte_ok = asyncio.Event() reconnect_echo_ok = asyncio.Event() sessions = 0 + writers: list[asyncio.StreamWriter] = [] async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: nonlocal sessions sessions += 1 + writers.append(writer) if sessions == 1: writer.write(GREETING) await writer.drain() @@ -98,5 +100,9 @@ async def test_tcp_uart_bridge( reconnect_echo_ok, 15.0, "Bridge did not reconnect and echo again" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() From ea7c277c08af45828fdd8309d8c8d80d6f09c06e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:34:29 -0400 Subject: [PATCH 008/651] [ci] Mark PRs with merge conflicts stale after 30 days (#20490) --- .github/workflows/stale.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 43b7e6663d6..7507eb4a424 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,16 +16,18 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main + uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: # Live only on dev: a workflow_dispatch from any other branch is a dry run dry-run: ${{ github.ref != 'refs/heads/dev' }} days-before-stale: 90 + days-before-stale-conflicting: 30 days-before-close: 7 stale-label: stale exempt-label: not-stale + conflict-label: needs-rebase ignored-users: esphbot,codecov-commenter stale-pr-message: > There hasn't been any activity on this pull request recently. This @@ -36,6 +38,18 @@ jobs: to keep it open. Also, please merge the latest dev branch into your branch to ensure that it's up to date with the latest changes. + Thank you for your contribution! + conflict-pr-message: > + This pull request has merge conflicts. Please merge the latest dev + branch into your branch to resolve them. + stale-conflicting-pr-message: > + The merge conflicts on this pull request have not been resolved for + 30 days, so it has been marked as stale and will be closed if they + are not resolved within 7 days. + + Please merge the latest dev branch into your branch. Once the pull + request is mergeable again the stale label is removed. + Thank you for your contribution! stale-issue-message: > There hasn't been any activity on this issue recently. Due to the From a4ef8a6c905197680341c631f851f4e16241d56a Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 10 Oct 2026 11:43:33 -0700 Subject: [PATCH 009/651] [addressable_light] Clear white channel when setting HSV color (#20265) --- esphome/components/light/esp_color_view.h | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/light/esp_color_view.h b/esphome/components/light/esp_color_view.h index 440a23e9c93..018e30a9a81 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -19,10 +19,7 @@ class ESPColorSettable { virtual void lighten(uint8_t delta) = 0; virtual void darken(uint8_t delta) = 0; void set(const ESPHSVColor &color) { this->set_hsv(color); } - void set_hsv(const ESPHSVColor &color) { - Color rgb = color.to_rgb(); - this->set_rgb(rgb.r, rgb.g, rgb.b); - } + void set_hsv(const ESPHSVColor &color) { this->set(color.to_rgb()); } void set_rgb(uint8_t red, uint8_t green, uint8_t blue) { this->set_red(red); this->set_green(green); From 667dbd1ec3fb752928b95e4b4c44ad3e65ac0d8d Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 10 Oct 2026 11:56:16 -0700 Subject: [PATCH 010/651] [adalight] Add a component build test (#19103) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- tests/components/adalight/common.yaml | 13 +++++++++++++ tests/components/adalight/test.esp8266-ard.yaml | 3 +++ 2 files changed, 16 insertions(+) create mode 100644 tests/components/adalight/common.yaml create mode 100644 tests/components/adalight/test.esp8266-ard.yaml diff --git a/tests/components/adalight/common.yaml b/tests/components/adalight/common.yaml new file mode 100644 index 00000000000..4203c76a500 --- /dev/null +++ b/tests/components/adalight/common.yaml @@ -0,0 +1,13 @@ +adalight: + +light: + - platform: neopixelbus + id: adalight_light + name: Neopixelbus Light + method: esp8266_uart + num_leds: 5 + pin: 2 + type: GRBW + variant: SK6812 + effects: + - adalight: diff --git a/tests/components/adalight/test.esp8266-ard.yaml b/tests/components/adalight/test.esp8266-ard.yaml new file mode 100644 index 00000000000..08ddd3f007a --- /dev/null +++ b/tests/components/adalight/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + adalight: !include common.yaml From 2ac48d408f33c6430acbecf9036096f0384c9f7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 08:56:49 -1000 Subject: [PATCH 011/651] [const] Move automatic_baseline_calibration to the shared constants (#20492) --- esphome/components/const/__init__.py | 1 + esphome/components/mhz19/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index b54d797f096..6b1ffc5473d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -9,6 +9,7 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_ALLOWED_IPS = "allowed_ips" +CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 053f17765e1..4daeef5059c 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -2,6 +2,7 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION import esphome.config_validation as cv from esphome.const import ( CONF_CO2, @@ -19,7 +20,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] -CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_DETECTION_RANGE = "detection_range" mhz19_ns = cg.esphome_ns.namespace("mhz19") From f70b6a6b03afe9256b72a77159fffaa9ae49e5c9 Mon Sep 17 00:00:00 2001 From: Jeff Zigler <123041141+zigboi@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:04:06 -0700 Subject: [PATCH 012/651] [mitsubishi] Add fahrenheit_compatibility mode (#18880) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mitsubishi/climate.py | 3 ++ esphome/components/mitsubishi/mitsubishi.cpp | 32 +++++++++++++++++++- esphome/components/mitsubishi/mitsubishi.h | 10 ++++++ tests/components/mitsubishi/common.yaml | 4 +++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 2d38351898a..37431648969 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -20,6 +20,7 @@ SETFANMODE = { CONF_SUPPORTS_DRY = "supports_dry" CONF_SUPPORTS_FAN_ONLY = "supports_fan_only" +CONF_FAHRENHEIT_COMPATIBILITY = "fahrenheit_compatibility" CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = mitsubishi_ns.enum("HorizontalDirections") @@ -49,6 +50,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).ex cv.Optional(CONF_SET_FAN_MODE, default="3levels"): cv.enum(SETFANMODE), cv.Optional(CONF_SUPPORTS_DRY, default=False): cv.boolean, cv.Optional(CONF_SUPPORTS_FAN_ONLY, default=False): cv.boolean, + cv.Optional(CONF_FAHRENHEIT_COMPATIBILITY, default=False): cv.boolean, cv.Optional(CONF_HORIZONTAL_DEFAULT, default="middle"): cv.enum( HORIZONTAL_DIRECTIONS ), @@ -65,5 +67,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fan_mode(config[CONF_SET_FAN_MODE])) cg.add(var.set_supports_dry(config[CONF_SUPPORTS_DRY])) cg.add(var.set_supports_fan_only(config[CONF_SUPPORTS_FAN_ONLY])) + cg.add(var.set_fahrenheit_compatibility(config[CONF_FAHRENHEIT_COMPATIBILITY])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index dabd62b8f1d..8ee4bda042e 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -1,4 +1,5 @@ #include "mitsubishi.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -53,6 +54,33 @@ const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; const uint8_t MITSUBISHI_BYTE16 = 0x00; +// Byte 7 codes for 61°F through 88°F. Bit 4 is the half degree bit Mitsubishi uses so every Fahrenheit degree +// maps to its own code. +static const uint8_t FAHRENHEIT_CODES[] PROGMEM = {0x00, 0x10, 0x01, 0x11, 0x02, 0x12, 0x03, 0x04, 0x05, 0x15, + 0x06, 0x16, 0x07, 0x17, 0x08, 0x18, 0x09, 0x19, 0x0a, 0x1a, + 0x0b, 0x1b, 0x0c, 0x1c, 0x0d, 0x1d, 0x0e, 0x0f}; +static constexpr uint8_t FAHRENHEIT_MIN = 61; +static constexpr uint8_t MITSUBISHI_HALF_DEGREE = 0x10; + +static uint8_t fahrenheit_code(float celsius) { + int fahrenheit = (int) roundf(celsius_to_fahrenheit(celsius)); + size_t index = clamp(fahrenheit - FAHRENHEIT_MIN, 0, sizeof(FAHRENHEIT_CODES) - 1); + return progmem_read_byte(&FAHRENHEIT_CODES[index]); +} + +// In Fahrenheit mode a code from the table decodes to the exact Fahrenheit value. Any other code, and every code in +// Celsius mode, is a whole degree plus the half degree bit, which a remote set to Fahrenheit also sets. +static float decode_temperature(uint8_t code, bool fahrenheit) { + if (fahrenheit) { + for (size_t i = 0; i < sizeof(FAHRENHEIT_CODES); i++) { + if (code == progmem_read_byte(&FAHRENHEIT_CODES[i])) { + return fahrenheit_to_celsius(FAHRENHEIT_MIN + i); + } + } + } + return MITSUBISHI_TEMP_MIN + (code & 0x0F) + ((code & MITSUBISHI_HALF_DEGREE) ? 0.5f : 0.0f); +} + void MitsubishiClimate::transmit_state() { // Byte 0-4: Constant: 0x23, 0xCB, 0x26, 0x01, 0x00 // Byte 5: On=0x20, Off: 0x00 @@ -113,6 +141,8 @@ void MitsubishiClimate::transmit_state() { // Temperature if (this->mode == climate::CLIMATE_MODE_DRY) { remote_state[7] = 24 - MITSUBISHI_TEMP_MIN; // Remote sends always 24°C if "Dry" mode is selected + } else if (this->fahrenheit_compatibility_) { + remote_state[7] = fahrenheit_code(this->target_temperature); } else { remote_state[7] = (uint8_t) roundf( clamp(this->target_temperature, MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX) - MITSUBISHI_TEMP_MIN); @@ -300,7 +330,7 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } // Temp - this->target_temperature = state_frame[7] + MITSUBISHI_TEMP_MIN; + this->target_temperature = decode_temperature(state_frame[7], this->fahrenheit_compatibility_); // Fan uint8_t fan = state_frame[9] & 0x07; //(Bit 0,1,2 = Speed) diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 269d39b06b4..cf943389e11 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/core/helpers.h" #include @@ -9,6 +10,8 @@ namespace esphome::mitsubishi { // Temperature const uint8_t MITSUBISHI_TEMP_MIN = 16; // Celsius const uint8_t MITSUBISHI_TEMP_MAX = 31; // Celsius +// Remotes set to Fahrenheit go up to 88°F, one step past the Celsius range +static constexpr float MITSUBISHI_TEMP_MAX_FAHRENHEIT = fahrenheit_to_celsius(88.0f); // Fan mode enum SetFanMode : uint8_t { @@ -55,6 +58,12 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_supports_fan_only(bool supports_fan_only) { this->set_mode_supported_(climate::CLIMATE_MODE_FAN_ONLY, supports_fan_only); } + void set_fahrenheit_compatibility(bool fahrenheit_compatibility) { + this->fahrenheit_compatibility_ = fahrenheit_compatibility; + if (fahrenheit_compatibility) { + this->maximum_temperature_ = MITSUBISHI_TEMP_MAX_FAHRENHEIT; + } + } void set_fan_mode(SetFanMode fan_mode) { this->fan_mode_ = fan_mode; @@ -80,6 +89,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { bool parse_state_frame_(const uint8_t frame[]); SetFanMode fan_mode_; + bool fahrenheit_compatibility_{false}; HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; diff --git a/tests/components/mitsubishi/common.yaml b/tests/components/mitsubishi/common.yaml index 4a2deda1630..8335ea35435 100644 --- a/tests/components/mitsubishi/common.yaml +++ b/tests/components/mitsubishi/common.yaml @@ -2,3 +2,7 @@ climate: - platform: mitsubishi name: Mitsubishi transmitter_id: xmitr + fahrenheit_compatibility: true + - platform: mitsubishi + name: Mitsubishi Celsius + transmitter_id: xmitr From 17cbb751613e6559b31651f37c1e9068c6cbd6eb Mon Sep 17 00:00:00 2001 From: Jeff Zigler <123041141+zigboi@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:11:14 -0700 Subject: [PATCH 013/651] [mitsubishi] Control both vanes on multi-vane Mitsubishi minisplit heads (#12609) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mitsubishi/climate.py | 4 ++++ esphome/components/mitsubishi/mitsubishi.cpp | 19 +++++++++++++------ esphome/components/mitsubishi/mitsubishi.h | 2 ++ tests/components/mitsubishi/common.yaml | 1 + 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 37431648969..1fbe1d67cf3 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -44,6 +44,8 @@ VERTICAL_DIRECTIONS = { "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, } +CONF_VERTICAL_VANES = "vertical_vanes" + CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).extend( { @@ -57,6 +59,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).ex cv.Optional(CONF_VERTICAL_DEFAULT, default="middle"): cv.enum( VERTICAL_DIRECTIONS ), + cv.Optional(CONF_VERTICAL_VANES, default=1): cv.int_range(1, 2), } ) @@ -70,3 +73,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fahrenheit_compatibility(config[CONF_FAHRENHEIT_COMPATIBILITY])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) + cg.add(var.set_vertical_vanes(config[CONF_VERTICAL_VANES])) diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 8ee4bda042e..515cd81fdfe 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -27,6 +27,7 @@ const uint8_t MITSUBISHI_WIDE_VANE_SWING = 0xC0; const uint8_t MITSUBISHI_FAN_AUTO = 0x00; +const uint8_t MITSUBISHI_VERTICAL_VANE_MASK = 0x38; // Bits 3,4,5 const uint8_t MITSUBISHI_VERTICAL_VANE_SWING = 0x38; // const uint8_t MITSUBISHI_AUTO = 0x80; @@ -52,7 +53,6 @@ const uint8_t MITSUBISHI_BYTE02 = 0x26; const uint8_t MITSUBISHI_BYTE03 = 0x01; const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; -const uint8_t MITSUBISHI_BYTE16 = 0x00; // Byte 7 codes for 61°F through 88°F. Bit 4 is the half degree bit Mitsubishi uses so every Fahrenheit degree // maps to its own code. @@ -100,7 +100,7 @@ void MitsubishiClimate::transmit_state() { // Byte 13: Constant 0x00 // Byte 14: HVAC specfic, i.e. ECONO COOL, CLEAN MODE, always 0x00 // Byte 15: HVAC specfic, i.e. POWERFUL, SMART SET, PLASMA, always 0x00 - // Byte 16: Constant 0x00 + // Byte 16: Left vane control (for specific models). Constants match right vane controls. // Byte 17: Checksum: SUM[Byte0...Byte16] uint8_t remote_state[18] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; @@ -214,7 +214,7 @@ void MitsubishiClimate::transmit_state() { break; } - ESP_LOGD(TAG, "default_vertical_direction_: %02X", this->default_vertical_direction_); + ESP_LOGD(TAG, "Vertical default: 0x%02X, vanes: %u", this->default_vertical_direction_, this->vertical_vanes_); // Special modes switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { @@ -236,6 +236,11 @@ void MitsubishiClimate::transmit_state() { break; } + if (this->vertical_vanes_ > 1) { + // Heads with two vertical vanes carry the left vane in byte 16; it follows the right one, presets included + remote_state[16] = remote_state[9] & MITSUBISHI_VERTICAL_VANE_MASK; + } + // Checksum for (int i = 0; i < 17; i++) { remote_state[17] += remote_state[i]; @@ -296,11 +301,11 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } state_frame[pos] = byte; - // Check Header && Footer + // Check Header && Footer. Byte 16 only ever carries the left vane position (bits 3,4,5). if ((pos == 0 && byte != MITSUBISHI_BYTE00) || (pos == 1 && byte != MITSUBISHI_BYTE01) || (pos == 2 && byte != MITSUBISHI_BYTE02) || (pos == 3 && byte != MITSUBISHI_BYTE03) || (pos == 4 && byte != MITSUBISHI_BYTE04) || (pos == 13 && byte != MITSUBISHI_BYTE13) || - (pos == 16 && byte != MITSUBISHI_BYTE16)) { + (pos == 16 && (byte & ~MITSUBISHI_VERTICAL_VANE_MASK) != 0)) { ESP_LOGV(TAG, "Bytes 0,1,2,3,4,13 or 16 fail - invalid value"); return false; } @@ -362,7 +367,9 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } // Vertical Vane - uint8_t vertical_vane = state_frame[9] & 0x38; // Bits 3,4,5 + // On dual vane heads, the left vane is in [16] and right vane is in [9]. Left is ignored here because + // the swing_mode enum doesn't convey that level of detail. + uint8_t vertical_vane = state_frame[9] & MITSUBISHI_VERTICAL_VANE_MASK; switch (vertical_vane) { case MITSUBISHI_VERTICAL_VANE_SWING: if (this->swing_mode == climate::CLIMATE_SWING_HORIZONTAL) { diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index cf943389e11..493661f9a6d 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -80,6 +80,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_vertical_default(VerticalDirection vertical_direction) { this->default_vertical_direction_ = vertical_direction; } + void set_vertical_vanes(uint8_t vertical_vanes) { this->vertical_vanes_ = vertical_vanes; } protected: // Transmit via IR the state of this climate controller. @@ -93,6 +94,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; + uint8_t vertical_vanes_{1}; }; } // namespace esphome::mitsubishi diff --git a/tests/components/mitsubishi/common.yaml b/tests/components/mitsubishi/common.yaml index 8335ea35435..541ba4f5587 100644 --- a/tests/components/mitsubishi/common.yaml +++ b/tests/components/mitsubishi/common.yaml @@ -3,6 +3,7 @@ climate: name: Mitsubishi transmitter_id: xmitr fahrenheit_compatibility: true + vertical_vanes: 2 - platform: mitsubishi name: Mitsubishi Celsius transmitter_id: xmitr From 865863abf5fc848d3056a93a47a2e1dcfa436fdd Mon Sep 17 00:00:00 2001 From: CzBiX Date: Sun, 11 Oct 2026 03:12:40 +0800 Subject: [PATCH 014/651] [cm1106] Add ABC function (#8894) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/cm1106/cm1106.cpp | 81 ++++++++++++++++++++-------- esphome/components/cm1106/cm1106.h | 25 ++++++++- esphome/components/cm1106/sensor.py | 29 ++++++++++ tests/components/cm1106/common.yaml | 8 +++ 4 files changed, 120 insertions(+), 23 deletions(-) diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index a25a753f754..edfcd0f7309 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -6,10 +6,16 @@ namespace esphome::cm1106 { ESPHOME_LOG_TAG(TAG, "cm1106"); -static const uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; -static const uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; -static const uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; +static constexpr uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; +static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; +static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; +// The factory default ABC calibration cycle differs between CM1106 models, so +// every ABC write applies the configured cycle and baseline explicitly. +static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS[10] = {0x11, 0x07, 0x10, 0x64, 0x00, 0x0F, 0x01, 0x90, 0x64, 0x00}; +static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS_RESPONSE[4] = {0x16, 0x01, 0x10, 0xD9}; +static constexpr uint8_t CM1106_ABC_FLAG_ENABLE = 0x0; +static constexpr uint8_t CM1106_ABC_FLAG_DISABLE = 0x2; uint8_t cm1106_checksum(const uint8_t *response, size_t len) { uint8_t crc = 0; for (size_t i = 0; i < len - 1; i++) { @@ -25,34 +31,46 @@ void CM1106Component::setup() { this->mark_failed(); return; } + + if (this->abc_boot_logic_ != CM1106_ABC_NONE) { + this->abc_set_(this->abc_boot_logic_ == CM1106_ABC_ENABLED); + } } void CM1106Component::update() { uint8_t response[8] = {0}; if (!this->cm1106_write_command_(C_M1106_CMD_GET_CO2, sizeof(C_M1106_CMD_GET_CO2), response, sizeof(response))) { - ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + ESP_LOGW(TAG, "Reading data failed!"); this->status_set_warning(); return; } if (response[0] != 0x16 || response[1] != 0x05 || response[2] != 0x01) { - ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2], - response[3]); + ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); this->status_set_warning(); return; } uint8_t checksum = cm1106_checksum(response, sizeof(response)); if (response[7] != checksum) { - ESP_LOGW(TAG, "CM1106 Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum); + ESP_LOGW(TAG, "Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum); this->status_set_warning(); return; } + const uint16_t ppm = response[3] << 8 | response[4]; + const uint8_t status = response[5]; + if (status & 0x1) { + ESP_LOGW(TAG, "Preheating; CO₂=%uppm", ppm); + this->status_set_warning(); + return; + } + if (status) { + ESP_LOGW(TAG, "Status: %02X", status); + } this->status_clear_warning(); - uint16_t ppm = response[3] << 8 | response[4]; - ESP_LOGD(TAG, "CM1106 Received CO₂=%uppm DF3=%02X DF4=%02X", ppm, response[5], response[6]); + ESP_LOGD(TAG, "CO₂=%uppm DF4=%02X", ppm, response[6]); if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(ppm); } @@ -62,24 +80,38 @@ void CM1106Component::calibrate_zero(uint16_t ppm) { memcpy(cmd, C_M1106_CMD_SET_CO2_CALIB, sizeof(cmd)); cmd[3] = ppm >> 8; cmd[4] = ppm & 0xFF; + if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_CO2_CALIB_RESPONSE)) { + ESP_LOGD(TAG, "Successfully calibrated sensor to %uppm", ppm); + } +} + +void CM1106Component::abc_set_(bool enabled) { + ESP_LOGD(TAG, "%sabling automatic baseline calibration", enabled ? LOG_STR_LITERAL("En") : LOG_STR_LITERAL("Dis")); + uint8_t cmd[10]; + memcpy(cmd, C_M1106_CMD_SET_ABC_STATUS, sizeof(cmd)); + cmd[4] = enabled ? CM1106_ABC_FLAG_ENABLE : CM1106_ABC_FLAG_DISABLE; + cmd[5] = this->abc_cycle_; + cmd[6] = this->abc_baseline_ >> 8; + cmd[7] = this->abc_baseline_ & 0xFF; + if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_ABC_STATUS_RESPONSE)) { + ESP_LOGD(TAG, "Successfully set ABC status"); + } +} + +bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack) { uint8_t response[4] = {0}; - - if (!this->cm1106_write_command_(cmd, sizeof(cmd), response, sizeof(response))) { - ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + if (!this->cm1106_write_command_(command, command_len, response, sizeof(response))) { + ESP_LOGW(TAG, "Command %02X failed", command[2]); this->status_set_warning(); - return; + return false; } - - // check if correct response received - if (memcmp(response, C_M1106_CMD_SET_CO2_CALIB_RESPONSE, sizeof(response)) != 0) { - ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2], - response[3]); + if (memcmp(response, ack, sizeof(response)) != 0) { + ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); this->status_set_warning(); - return; + return false; } - this->status_clear_warning(); - ESP_LOGD(TAG, "CM1106 Successfully calibrated sensor to %uppm", ppm); + return true; } bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, @@ -100,6 +132,13 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma void CM1106Component::dump_config() { ESP_LOGCONFIG(TAG, "CM1106:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); + if (this->abc_boot_logic_ != CM1106_ABC_NONE) { + ESP_LOGCONFIG(TAG, + " Automatic baseline calibration on boot: %s\n" + " ABC calibration cycle: %u days\n" + " ABC baseline: %uppm", + ONOFF(this->abc_boot_logic_ == CM1106_ABC_ENABLED), this->abc_cycle_, this->abc_baseline_); + } if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index e381db7b5cd..1066a5c44dd 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -6,6 +6,14 @@ namespace esphome::cm1106 { +// ABC logic requested at boot; CM1106_ABC_NONE means nothing is sent and the +// sensor keeps its stored setting. +enum CM1106ABCLogic : uint8_t { + CM1106_ABC_NONE = 0, + CM1106_ABC_ENABLED, + CM1106_ABC_DISABLED, +}; + class CM1106Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; @@ -13,13 +21,26 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice { void dump_config() override; void calibrate_zero(uint16_t ppm); + void abc_enable() { this->abc_set_(true); } + void abc_disable() { this->abc_set_(false); } void set_co2_sensor(sensor::Sensor *co2_sensor) { this->co2_sensor_ = co2_sensor; } + void set_abc(bool enabled, uint8_t cycle_days, uint16_t baseline_ppm) { + this->abc_boot_logic_ = enabled ? CM1106_ABC_ENABLED : CM1106_ABC_DISABLED; + this->abc_cycle_ = cycle_days; + this->abc_baseline_ = baseline_ppm; + } protected: - sensor::Sensor *co2_sensor_{nullptr}; - bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); + /// Sends a command and checks the sensor's four byte acknowledgement, updating the warning status. + bool send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack); + void abc_set_(bool enabled); + + sensor::Sensor *co2_sensor_{nullptr}; + CM1106ABCLogic abc_boot_logic_{CM1106_ABC_NONE}; + uint8_t abc_cycle_{15}; + uint16_t abc_baseline_{400}; }; } // namespace esphome::cm1106 diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index c3939735a43..fa179f9667d 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -4,9 +4,12 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION import esphome.config_validation as cv from esphome.const import ( + CONF_BASELINE, CONF_CO2, + CONF_CYCLE, CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, ICON_MOLECULE_CO2, @@ -18,11 +21,22 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andrewjswan"] + +# true enables ABC with these defaults, false disables it, a mapping enables it with custom values +_ABC_SETTINGS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_CYCLE, default=15): cv.int_range(min=1, max=90), + cv.Optional(CONF_BASELINE, default=400): cv.int_range(min=400, max=10000), + } +) + + cm1106_ns = cg.esphome_ns.namespace("cm1106") CM1106Component = cm1106_ns.class_( "CM1106Component", cg.PollingComponent, uart.UARTDevice ) + CONFIG_SCHEMA = ( cv.Schema( { @@ -34,6 +48,9 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_CARBON_DIOXIDE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_AUTOMATIC_BASELINE_CALIBRATION): cv.Any( + cv.boolean, _ABC_SETTINGS_SCHEMA + ), }, ) .extend(cv.polling_component_schema("60s")) @@ -57,6 +74,11 @@ async def to_code(config: ConfigType) -> None: sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) + if (abc := config.get(CONF_AUTOMATIC_BASELINE_CALIBRATION)) is not None: + enabled = abc if isinstance(abc, bool) else True + settings = _ABC_SETTINGS_SCHEMA({}) if isinstance(abc, bool) else abc + cg.add(var.set_abc(enabled, settings[CONF_CYCLE], settings[CONF_BASELINE])) + CALIBRATION_ACTION_SCHEMA = maybe_simple_id( { @@ -70,3 +92,10 @@ automation.register_apply_action( CALIBRATION_ACTION_SCHEMA, automation.ApplyCall("calibrate_zero(400)"), ) +for _name, _call in ( + ("cm1106.abc_enable", "abc_enable()"), + ("cm1106.abc_disable", "abc_disable()"), +): + automation.register_apply_action( + _name, CALIBRATION_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/cm1106/common.yaml b/tests/components/cm1106/common.yaml index ed2fd670075..eb2fde3a492 100644 --- a/tests/components/cm1106/common.yaml +++ b/tests/components/cm1106/common.yaml @@ -1,5 +1,13 @@ sensor: - platform: cm1106 + id: cm1106_sensor co2: name: CM1106 CO2 Value + on_value: + then: + - cm1106.abc_enable: cm1106_sensor + - cm1106.abc_disable: cm1106_sensor update_interval: 15s + automatic_baseline_calibration: + cycle: 7 + baseline: 420 From ff96530e4d455b7e6d3c6c710690c978b48a4310 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:13:05 -0400 Subject: [PATCH 015/651] [esp32] Honor sdkconfig CPU frequency options (#20158) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 90 +++++++- tests/component_tests/esp32/test_esp32.py | 239 ++++++++++++++++++++++ 2 files changed, 326 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aa4f78de69d..9bceaf5676d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -551,9 +551,88 @@ assert all(variant in CPU_FREQUENCIES for variant in VARIANTS) FULL_CPU_FREQUENCIES = set(itertools.chain.from_iterable(CPU_FREQUENCIES.values())) +_SDKCONFIG_CPU_FREQUENCY_OPTION = "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ" +_SDKCONFIG_CPU_FREQUENCY_PREFIX = f"{_SDKCONFIG_CPU_FREQUENCY_OPTION}_" +# Older IDF versions named the CPU frequency options after the chip +_LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS = frozenset( + {VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3} +) + + +def _get_sdkconfig_cpu_frequencies( + sdkconfig_options: dict[str, str], variant: str +) -> tuple[set[int], set[int]]: + """Return the CPU frequencies in MHz that sdkconfig_options selects and disables.""" + # (integer option, choice option prefix) pairs that can carry a frequency + keys = [(_SDKCONFIG_CPU_FREQUENCY_OPTION, _SDKCONFIG_CPU_FREQUENCY_PREFIX)] + if variant in _LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS: + keys.append( + ( + f"CONFIG_{variant}_DEFAULT_CPU_FREQ_MHZ", + f"CONFIG_{variant}_DEFAULT_CPU_FREQ_", + ) + ) + selected: set[int] = set() + disabled: set[int] = set() + for integer_key, choice_prefix in keys: + if (value := sdkconfig_options.get(integer_key)) is not None: + try: + selected.add(int(value)) + except ValueError as err: + raise cv.Invalid( + f"{integer_key} must be an integer MHz value", + path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS], + ) from err + for name, value in sdkconfig_options.items(): + mhz = name.removeprefix(choice_prefix) + if mhz == name or not mhz.isdigit(): + continue + if value.lower() == "y": + selected.add(int(mhz)) + elif value.lower() == "n": + disabled.add(int(mhz)) + else: + raise cv.Invalid( + f"{name} must be y or n", + path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS], + ) + return selected, disabled + + def set_core_data(config): - cpu_frequency = config.get(CONF_CPU_FREQUENCY, None) + cpu_frequency = config.get(CONF_CPU_FREQUENCY) variant = config[CONF_VARIANT] + sdkconfig_path = [CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS] + selected, disabled = _get_sdkconfig_cpu_frequencies( + config[CONF_FRAMEWORK][CONF_SDKCONFIG_OPTIONS], variant + ) + if len(selected) > 1: + conflicting = ", ".join(f"{mhz}MHz" for mhz in sorted(selected)) + raise cv.Invalid( + f"sdkconfig_options contains conflicting CPU frequencies ({conflicting})", + path=sdkconfig_path, + ) + sdkconfig_cpu_frequency = f"{selected.pop()}MHZ" if selected else None + if cpu_frequency is None and sdkconfig_cpu_frequency is not None: + if sdkconfig_cpu_frequency not in CPU_FREQUENCIES[variant]: + raise cv.Invalid( + f"sdkconfig_options selects {sdkconfig_cpu_frequency}, which {variant} does not support", + path=sdkconfig_path, + ) + _LOGGER.warning( + "sdkconfig_options contains a CPU frequency setting; using %s. " + "Set 'esp32.cpu_frequency' to configure it directly.", + sdkconfig_cpu_frequency, + ) + cpu_frequency = sdkconfig_cpu_frequency + elif ( + sdkconfig_cpu_frequency is not None and sdkconfig_cpu_frequency != cpu_frequency + ): + raise cv.Invalid( + f"esp32.cpu_frequency ({cpu_frequency}) conflicts with sdkconfig_options " + f"({sdkconfig_cpu_frequency})", + path=sdkconfig_path, + ) # if not specified in config, default to the maximum supported frequency # (ESP32-P4 engineering samples are limited to 360MHz, non-engineering can do 400MHz) if cpu_frequency is None: @@ -562,12 +641,17 @@ def set_core_data(config): cpu_frequency = "360MHZ" else: cpu_frequency = choices[-1] - config[CONF_CPU_FREQUENCY] = cpu_frequency elif cpu_frequency not in CPU_FREQUENCIES[variant]: raise cv.Invalid( f"Invalid CPU frequency '{cpu_frequency}' for {config[CONF_VARIANT]}", path=[CONF_CPU_FREQUENCY], ) + if int(cpu_frequency[:-3]) in disabled: + raise cv.Invalid( + f"sdkconfig_options disables the selected CPU frequency ({cpu_frequency})", + path=sdkconfig_path, + ) + config[CONF_CPU_FREQUENCY] = cpu_frequency if variant == VARIANT_ESP32P4 and cpu_frequency == "400MHZ": _LOGGER.warning( @@ -3138,7 +3222,7 @@ async def to_code(config): # Set default CPU frequency add_idf_sdkconfig_option( - f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True + f"{_SDKCONFIG_CPU_FREQUENCY_PREFIX}{config[CONF_CPU_FREQUENCY][:-3]}", True ) # Apply LWIP optimization settings diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 4b7a0a6ab8e..9072a906293 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -111,6 +111,245 @@ def test_esp32_config( assert VARIANT_FRIENDLY[variant].lower() in config["board"] +@pytest.mark.parametrize( + ( + "sdkconfig_options", + "cpu_frequency", + "expected_frequency", + "warns", + "variant", + "framework_type", + ), + [ + pytest.param( + { + "CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y", + "CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160", + }, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="legacy-frequency-options-with-default", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"}, + "160MHz", + "160MHZ", + False, + VARIANT_ESP32, + "esp-idf", + id="explicit-cpu-frequency", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="legacy-scalar-frequency", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="legacy-frequency-choice", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="current-idf-frequency-choice", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "n"}, + None, + "240MHZ", + False, + VARIANT_ESP32, + "esp-idf", + id="disabled-frequency-choice", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ_240": "n"}, + "240MHz", + "240MHZ", + False, + VARIANT_ESP32, + "esp-idf", + id="ignore-unrecognized-legacy-disabled-choice", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32C3, + "esp-idf", + id="current-scalar-frequency", + ), + pytest.param( + {"CONFIG_ESP32S2_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32S2, + "esp-idf", + id="s2-legacy-scalar-frequency", + ), + pytest.param( + {"CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160": "y"}, + None, + "160MHZ", + True, + VARIANT_ESP32S3, + "esp-idf", + id="s3-legacy-choice-frequency", + ), + pytest.param( + {"CONFIG_ESP32C3_DEFAULT_CPU_FREQ_80": "y"}, + None, + "80MHZ", + True, + VARIANT_ESP32C3, + "esp-idf", + id="c3-legacy-choice-frequency", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "arduino", + id="arduino-framework-current-scalar-frequency", + ), + ], +) +def test_sdkconfig_cpu_frequency_is_honored( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + sdkconfig_options: dict[str, str], + cpu_frequency: str | None, + expected_frequency: str, + warns: bool, + variant: str, + framework_type: str, +) -> None: + """SDK config frequency settings are honored and recommend the native config key.""" + platform_framework = ( + PlatformFramework.ESP32_ARDUINO + if framework_type == "arduino" + else PlatformFramework.ESP32_IDF + ) + set_core_config(platform_framework) + from esphome.components.esp32 import CONFIG_SCHEMA + + config: dict[str, Any] = { + "variant": variant, + "framework": { + "type": framework_type, + "sdkconfig_options": sdkconfig_options, + }, + } + if cpu_frequency is not None: + config["cpu_frequency"] = cpu_frequency + + with caplog.at_level(logging.WARNING): + config = CONFIG_SCHEMA(config) + + assert ("CPU frequency setting" in caplog.text) is warns + assert config["cpu_frequency"] == expected_frequency + + +@pytest.mark.parametrize( + ("variant", "cpu_frequency", "sdkconfig_options", "error_match"), + [ + pytest.param( + VARIANT_ESP32C3, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "fast"}, + "must be an integer MHz value", + id="integer-option-not-a-number", + ), + pytest.param( + VARIANT_ESP32C3, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"}, + "ESP32C3 does not support", + id="unsupported-frequency", + ), + pytest.param( + VARIANT_ESP32C3, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "yes"}, + "must be y or n", + id="choice-option-not-y-or-n", + ), + pytest.param( + VARIANT_ESP32C3, + None, + { + "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y", + "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y", + }, + r"conflicting CPU frequencies \(160MHz, 240MHz\)", + id="two-frequencies-selected", + ), + pytest.param( + VARIANT_ESP32, + "160MHz", + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"}, + "conflicts with sdkconfig_options", + id="conflicts-with-explicit-value", + ), + pytest.param( + VARIANT_ESP32, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "n"}, + "disables the selected CPU frequency", + id="default-frequency-disabled", + ), + pytest.param( + VARIANT_ESP32, + "160MHz", + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "n"}, + "disables the selected CPU frequency", + id="explicit-frequency-disabled-by-legacy-option", + ), + ], +) +def test_sdkconfig_cpu_frequency_errors( + set_core_config: SetCoreConfigCallable, + variant: str, + cpu_frequency: str | None, + sdkconfig_options: dict[str, str], + error_match: str, +) -> None: + """Bad, conflicting or disabled sdkconfig frequencies fail at validation.""" + set_core_config(PlatformFramework.ESP32_IDF) + from esphome.components.esp32 import CONFIG_SCHEMA + + config: dict[str, Any] = { + "variant": variant, + "framework": {"type": "esp-idf", "sdkconfig_options": sdkconfig_options}, + } + if cpu_frequency is not None: + config["cpu_frequency"] = cpu_frequency + + with pytest.raises(cv.Invalid, match=error_match) as exc_info: + CONFIG_SCHEMA(config) + assert exc_info.value.path == ["framework", "sdkconfig_options"] + + @pytest.mark.parametrize( ("config_toolchain", "expected"), [ From ebbe3860eca1edee66f564483a64a3ee414bdfb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 09:43:57 -1000 Subject: [PATCH 016/651] [const] Move aqi to the shared constants (#20494) --- esphome/components/aqi/__init__.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/ens160_base/__init__.py | 2 +- esphome/components/hm3301/sensor.py | 3 ++- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py index 02c0bd2228a..56308e32983 100644 --- a/esphome/components/aqi/__init__.py +++ b/esphome/components/aqi/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.const import CONF_AQI # noqa: F401 CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"] DOMAIN = "aqi" @@ -6,7 +7,6 @@ DOMAIN = "aqi" aqi_ns = cg.esphome_ns.namespace("aqi") AQICalculatorType = aqi_ns.enum("AQICalculatorType") -CONF_AQI = "aqi" CONF_CALCULATION_TYPE = "calculation_type" CONF_EXTENDED_RANGE = "extended_range" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6b1ffc5473d..e93d508a3d8 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -9,6 +9,7 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_ALLOWED_IPS = "allowed_ips" +CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index b41575c7171..8ef5971f17a 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( CONF_COMPENSATION, @@ -26,7 +27,6 @@ DOMAIN = "ens160_base" ens160_ns = cg.esphome_ns.namespace("ens160_base") -CONF_AQI = "aqi" CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 79fb81ea5f5..d5e15f7c620 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -2,7 +2,8 @@ import logging import esphome.codegen as cg from esphome.components import i2c, sensor -from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE +from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE +from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( CONF_ID, From 6559ea470b1119d0c7a14d04ea905afcde7ae271 Mon Sep 17 00:00:00 2001 From: DAVe3283 Date: Sat, 10 Oct 2026 14:58:09 -0600 Subject: [PATCH 017/651] =?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 0c5e80bfe5f..be38317497a 100644 --- a/esphome/components/i2c/i2c_bus.h +++ b/esphome/components/i2c/i2c_bus.h @@ -4,6 +4,7 @@ #include #include +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" namespace esphome::i2c { @@ -53,6 +54,15 @@ class I2CBus { return this->write_readv(address, buffer, len, nullptr, 0); } +#ifdef I2C_PORT_FREQUENCY_COUNT + /// @brief Switch the bus frequency for the transactions that follow; an external bus + /// that does not override this reports an error and keeps its frequency + virtual ErrorCode switch_frequency(uint32_t frequency) { return ERROR_UNKNOWN; } + + /// @brief The frequency the bus runs at + virtual uint32_t get_frequency() const { return 0; } +#endif + protected: /// @brief Scans the I2C bus for devices. Devices presence is kept in an array of std::pair /// that contains the address and the corresponding bool presence flag. diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 95aee2bddb2..215cc1364f8 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -15,7 +15,7 @@ ESPHOME_LOG_TAG(TAG, "i2c"); static constexpr size_t I2C_MAX_LOG_BYTES = 32; void ArduinoI2CBus::setup() { - recover_(); + this->recover_(); #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) @@ -25,9 +25,9 @@ void ArduinoI2CBus::setup() { // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf // RP2350 datasheet Table 7 (section 9.4): https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf if ((this->sda_pin_ / 2) % 2 == 0) { - wire_ = &Wire; + this->wire_ = &Wire; } else { - wire_ = &Wire1; + this->wire_ = &Wire1; } #endif @@ -42,22 +42,22 @@ void ArduinoI2CBus::setup() { void ArduinoI2CBus::set_pins_and_clock_() { #ifdef USE_RP2 - wire_->setSDA(this->sda_pin_); - wire_->setSCL(this->scl_pin_); - wire_->begin(); + this->wire_->setSDA(this->sda_pin_); + this->wire_->setSCL(this->scl_pin_); + this->wire_->begin(); #else - wire_->begin(static_cast(sda_pin_), static_cast(scl_pin_)); + this->wire_->begin(static_cast(this->sda_pin_), static_cast(this->scl_pin_)); #endif - if (timeout_ > 0) { // if timeout specified in yaml + if (this->timeout_ > 0) { // if timeout specified in yaml #if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h - wire_->setClockStretchLimit(timeout_); // unit: us + this->wire_->setClockStretchLimit(this->timeout_); // unit: us #elif defined(USE_RP2) // https://github.com/earlephilhower/ArduinoCore-API/blob/e37df85425e0ac020bfad226d927f9b00d2e0fb7/api/Stream.h - wire_->setTimeout(timeout_ / 1000); // unit: ms + this->wire_->setTimeout(this->timeout_ / 1000); // unit: ms #endif } - wire_->setClock(frequency_); + this->wire_->setClock(this->frequency_); } void ArduinoI2CBus::dump_config() { @@ -67,7 +67,7 @@ void ArduinoI2CBus::dump_config() { " SCL Pin: GPIO%u\n" " Frequency: %u Hz", this->sda_pin_, this->scl_pin_, this->frequency_); - if (timeout_ > 0) { + if (this->timeout_ > 0) { #if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); #elif defined(USE_RP2) @@ -87,10 +87,10 @@ void ArduinoI2CBus::dump_config() { } if (this->scan_) { ESP_LOGI(TAG, "Results from bus scan:"); - if (scan_results_.empty()) { + if (this->scan_results_.empty()) { ESP_LOGI(TAG, "Found no devices"); } else { - for (const auto &s : scan_results_) { + for (const auto &s : this->scan_results_) { if (s.second) { ESP_LOGI(TAG, "Found device at address 0x%02X", s.first); } else { @@ -106,7 +106,7 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe #if defined(USE_ESP8266) this->set_pins_and_clock_(); // reconfigure Wire global state in case there are multiple instances #endif - if (!initialized_) { + if (!this->initialized_) { ESP_LOGD(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } @@ -118,22 +118,22 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe uint8_t status = 0; if (write_count != 0 || read_count == 0) { - wire_->beginTransmission(address); - size_t ret = wire_->write(write_buffer, write_count); + this->wire_->beginTransmission(address); + size_t ret = this->wire_->write(write_buffer, write_count); if (ret != write_count) { ESP_LOGV(TAG, "TX failed"); return ERROR_UNKNOWN; } - status = wire_->endTransmission(read_count == 0); + status = this->wire_->endTransmission(read_count == 0); } if (status == 0 && read_count != 0) { - size_t ret2 = wire_->requestFrom(address, read_count, true); + size_t ret2 = this->wire_->requestFrom(address, read_count, true); if (ret2 != read_count) { ESP_LOGVV(TAG, "RX %u from %02X failed with error %u", read_count, address, ret2); return ERROR_TIMEOUT; } for (size_t j = 0; j != read_count; j++) - read_buffer[j] = wire_->read(); + read_buffer[j] = this->wire_->read(); } // Avoid switch to prevent compiler-generated lookup table in RAM on ESP8266 if (status == 0) @@ -154,6 +154,19 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe return ERROR_UNKNOWN; } +#ifdef I2C_PORT_FREQUENCY_COUNT +ErrorCode ArduinoI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + this->frequency_ = frequency; + if (this->initialized_) { + this->wire_->setClock(this->frequency_); + } + return ERROR_OK; +} +#endif + /// Perform I2C bus recovery, see: /// https://www.nxp.com/docs/en/user-guide/UM10204.pdf /// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf @@ -167,15 +180,15 @@ void ArduinoI2CBus::recover_() { const auto half_period_usec = 1000000 / 100000 / 2; // Activate input and pull up resistor for the SCL pin. - pinMode(scl_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT // This should make the signal on the line HIGH. If SCL is pulled low // on the I2C bus however, then some device is interfering with the SCL // line. In that case, the I2C bus cannot be recovered. delayMicroseconds(half_period_usec); - if (digitalRead(scl_pin_) == LOW) { // NOLINT + if (digitalRead(this->scl_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } @@ -189,18 +202,18 @@ void ArduinoI2CBus::recover_() { // Make sure that switching to output mode will make SCL low, just in // case other code has setup the pin for a HIGH signal. - digitalWrite(scl_pin_, LOW); // NOLINT + digitalWrite(this->scl_pin_, LOW); // NOLINT delayMicroseconds(half_period_usec); for (auto i = 0; i < 9; i++) { // Release pull up resistor and switch to output to make the signal LOW. - pinMode(scl_pin_, INPUT); // NOLINT - pinMode(scl_pin_, OUTPUT); // NOLINT + pinMode(this->scl_pin_, INPUT); // NOLINT + pinMode(this->scl_pin_, OUTPUT); // NOLINT delayMicroseconds(half_period_usec); // Release output and activate pull up resistor to make the signal HIGH. - pinMode(scl_pin_, INPUT); // NOLINT - pinMode(scl_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->scl_pin_, INPUT); // NOLINT + pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT delayMicroseconds(half_period_usec); // When SCL is kept LOW at this point, we might be looking at a device @@ -211,28 +224,28 @@ void ArduinoI2CBus::recover_() { // should recover in a few ms or less else not likely to recovery at // all. auto wait = 250; - while (wait-- && digitalRead(scl_pin_) == LOW) { // NOLINT + while (wait-- && digitalRead(this->scl_pin_) == LOW) { // NOLINT App.feed_wdt(); delayMicroseconds(half_period_usec * 2); } - if (digitalRead(scl_pin_) == LOW) { // NOLINT + if (digitalRead(this->scl_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } } // Activate input and pull resistor for the SDA pin, so we can verify // that SDA is pulled HIGH in the following step. - pinMode(sda_pin_, INPUT_PULLUP); // NOLINT - digitalWrite(sda_pin_, LOW); // NOLINT + pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT + digitalWrite(this->sda_pin_, LOW); // NOLINT // By now, any stuck device ought to have sent all remaining bits of its // transaction, meaning that it should have freed up the SDA line, resulting // in SDA being pulled up. - if (digitalRead(sda_pin_) == LOW) { // NOLINT + if (digitalRead(this->sda_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SDA_LOW; + this->recovery_result_ = RECOVERY_FAILED_SDA_LOW; return; } @@ -248,8 +261,8 @@ void ArduinoI2CBus::recover_() { // SCL and SDA are already high at this point, so we can generate a START // condition by making the SDA signal LOW. delayMicroseconds(half_period_usec); - pinMode(sda_pin_, INPUT); // NOLINT - pinMode(sda_pin_, OUTPUT); // NOLINT + pinMode(this->sda_pin_, INPUT); // NOLINT + pinMode(this->sda_pin_, OUTPUT); // NOLINT // From the specification: // "A START condition immediately followed by a STOP condition (void @@ -258,10 +271,10 @@ void ArduinoI2CBus::recover_() { // Finally, we'll bring the I2C bus into a starting state by generating // a STOP condition. delayMicroseconds(half_period_usec); - pinMode(sda_pin_, INPUT); // NOLINT - pinMode(sda_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->sda_pin_, INPUT); // NOLINT + pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT - recovery_result_ = RECOVERY_COMPLETED; + this->recovery_result_ = RECOVERY_COMPLETED; } } // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index 71e91e770bb..9feea9f7ffa 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -22,13 +22,17 @@ class ArduinoI2CBus final : public InternalI2CBus, public Component { size_t read_count) override; float get_setup_priority() const override { return setup_priority::BUS; } - void set_scan(bool scan) { scan_ = scan; } - void set_sda_pin(uint8_t sda_pin) { sda_pin_ = sda_pin; } - void set_scl_pin(uint8_t scl_pin) { scl_pin_ = scl_pin; } - void set_frequency(uint32_t frequency) { frequency_ = frequency; } - void set_timeout(uint32_t timeout) { timeout_ = timeout; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } + void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } int get_port() const override { return 0; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#ifdef I2C_PORT_FREQUENCY_COUNT + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif private: void recover_(); diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index b13b525abbb..931bcfa0889 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -32,8 +32,8 @@ void IDFI2CBus::setup() { i2c_master_bus_config_t bus_conf{}; memset(&bus_conf, 0, sizeof(bus_conf)); - bus_conf.sda_io_num = gpio_num_t(sda_pin_); - bus_conf.scl_io_num = gpio_num_t(scl_pin_); + bus_conf.sda_io_num = gpio_num_t(this->sda_pin_); + bus_conf.scl_io_num = gpio_num_t(this->scl_pin_); bus_conf.glitch_ignore_cnt = 7; #if SOC_LP_I2C_SUPPORTED if (this->lp_mode_) { @@ -59,7 +59,7 @@ void IDFI2CBus::setup() { } #endif bus_conf.i2c_port = this->port_; - bus_conf.flags.enable_internal_pullup = sda_pullup_enabled_ || scl_pullup_enabled_; + bus_conf.flags.enable_internal_pullup = this->sda_pullup_enabled_ || this->scl_pullup_enabled_; esp_err_t err = i2c_new_master_bus(&bus_conf, &this->bus_); if (err != ESP_OK) { ESP_LOGW(TAG, "i2c_new_master_bus failed: %s", esp_err_to_name(err)); @@ -67,18 +67,21 @@ void IDFI2CBus::setup() { return; } - i2c_device_config_t dev_conf{}; - memset(&dev_conf, 0, sizeof(dev_conf)); - dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7; - dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED; - dev_conf.scl_speed_hz = this->frequency_; - dev_conf.scl_wait_us = this->timeout_; - err = i2c_master_bus_add_device(this->bus_, &dev_conf, &this->dev_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err)); + this->dev_ = this->add_device_(this->frequency_); + if (this->dev_ == nullptr) { this->mark_failed(); return; } +#ifdef I2C_PORT_FREQUENCY_COUNT + this->add_frequency(this->frequency_); + for (auto &device : this->devices_) { + device.dev = device.frequency == this->frequency_ ? this->dev_ : this->add_device_(device.frequency); + if (device.dev == nullptr) { + this->mark_failed(); + return; + } + } +#endif this->initialized_ = true; @@ -88,6 +91,21 @@ void IDFI2CBus::setup() { } } +i2c_master_dev_handle_t IDFI2CBus::add_device_(uint32_t frequency) { + i2c_device_config_t dev_conf{}; + dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7; + dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED; + dev_conf.scl_speed_hz = frequency; + dev_conf.scl_wait_us = this->timeout_; + i2c_master_dev_handle_t dev{}; + const esp_err_t err = i2c_master_bus_add_device(this->bus_, &dev_conf, &dev); + if (err != ESP_OK) { + ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err)); + return nullptr; + } + return dev; +} + void IDFI2CBus::dump_config() { ESP_LOGCONFIG(TAG, "I2C Bus:"); ESP_LOGCONFIG(TAG, @@ -95,7 +113,7 @@ void IDFI2CBus::dump_config() { " SCL Pin: GPIO%u\n" " Frequency: %" PRIu32 " Hz", this->sda_pin_, this->scl_pin_, this->frequency_); - if (timeout_ > 0) { + if (this->timeout_ > 0) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 "us", this->timeout_); } switch (this->recovery_result_) { @@ -111,10 +129,10 @@ void IDFI2CBus::dump_config() { } if (this->scan_) { ESP_LOGCONFIG(TAG, "Results from bus scan:"); - if (scan_results_.empty()) { + if (this->scan_results_.empty()) { ESP_LOGCONFIG(TAG, "Found no devices"); } else { - for (const auto &s : scan_results_) { + for (const auto &s : this->scan_results_) { if (s.second) { ESP_LOGCONFIG(TAG, "Found device at address 0x%02X", s.first); } else { @@ -129,7 +147,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s size_t read_count) { // logging is only enabled with v level, if warnings are shown the caller // should log them - if (!initialized_) { + if (!this->initialized_) { ESP_LOGW(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } @@ -198,14 +216,44 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s return ERROR_OK; } +#ifdef I2C_PORT_FREQUENCY_COUNT +void IDFI2CBus::add_frequency(uint32_t frequency) { + for (const auto &device : this->devices_) { + if (device.frequency == frequency) { + return; + } + } + this->devices_.push_back({frequency, nullptr}); +} + +ErrorCode IDFI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + if (!this->initialized_) { + this->frequency_ = frequency; + return ERROR_OK; + } + // Every handle exists since setup(); a frequency nobody registered has none + for (const auto &device : this->devices_) { + if (device.frequency == frequency) { + this->dev_ = device.dev; + this->frequency_ = frequency; + return ERROR_OK; + } + } + return ERROR_UNKNOWN; +} +#endif + /// Perform I2C bus recovery, see: /// https://www.nxp.com/docs/en/user-guide/UM10204.pdf /// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf void IDFI2CBus::recover_() { ESP_LOGI(TAG, "Performing bus recovery"); - const auto scl_pin = static_cast(scl_pin_); - const auto sda_pin = static_cast(sda_pin_); + const auto scl_pin = static_cast(this->scl_pin_); + const auto sda_pin = static_cast(this->sda_pin_); // For the upcoming operations, target for a 60kHz toggle frequency. // 1000kHz is the maximum frequency for I2C running in standard-mode, @@ -218,7 +266,7 @@ void IDFI2CBus::recover_() { // Configure SCL pin for open drain input/output, with a pull up resistor. gpio_set_level(scl_pin, 1); gpio_config_t scl_config{}; - scl_config.pin_bit_mask = 1ULL << scl_pin_; + scl_config.pin_bit_mask = 1ULL << this->scl_pin_; scl_config.mode = GPIO_MODE_INPUT_OUTPUT_OD; scl_config.pull_up_en = GPIO_PULLUP_ENABLE; scl_config.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -228,7 +276,7 @@ void IDFI2CBus::recover_() { // Configure SDA pin for open drain input/output, with a pull up resistor. gpio_set_level(sda_pin, 1); gpio_config_t sda_conf{}; - sda_conf.pin_bit_mask = 1ULL << sda_pin_; + sda_conf.pin_bit_mask = 1ULL << this->sda_pin_; sda_conf.mode = GPIO_MODE_INPUT_OUTPUT_OD; sda_conf.pull_up_en = GPIO_PULLUP_ENABLE; sda_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -240,7 +288,7 @@ void IDFI2CBus::recover_() { delayMicroseconds(half_period_usec); if (gpio_get_level(scl_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } @@ -271,7 +319,7 @@ void IDFI2CBus::recover_() { } if (gpio_get_level(scl_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } } @@ -281,7 +329,7 @@ void IDFI2CBus::recover_() { // in SDA being pulled up. if (gpio_get_level(sda_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SDA_LOW; + this->recovery_result_ = RECOVERY_FAILED_SDA_LOW; return; } @@ -308,7 +356,7 @@ void IDFI2CBus::recover_() { delayMicroseconds(half_period_usec); gpio_set_level(sda_pin, 1); - recovery_result_ = RECOVERY_COMPLETED; + this->recovery_result_ = RECOVERY_COMPLETED; } } // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 92e96f649b6..b9d8824d25e 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -34,13 +34,28 @@ class IDFI2CBus final : public InternalI2CBus, public Component { #endif int get_port() const override { return this->port_; } +#ifdef I2C_PORT_FREQUENCY_COUNT + /// Register a frequency used on the bus so setup() creates its device handle + void add_frequency(uint32_t frequency); + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif private: void recover_(); + i2c_master_dev_handle_t add_device_(uint32_t frequency); RecoveryCode recovery_result_{}; protected: i2c_master_dev_handle_t dev_{}; +#ifdef I2C_PORT_FREQUENCY_COUNT + // One handle per frequency used on the bus, registered at code generation + struct FrequencyDevice { + uint32_t frequency; + i2c_master_dev_handle_t dev; + }; + StaticVector devices_; +#endif i2c_master_bus_handle_t bus_{}; i2c_port_t port_{}; uint8_t sda_pin_{}; diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h index 8064016a520..086baab26ca 100644 --- a/esphome/components/i2c/i2c_bus_host.h +++ b/esphome/components/i2c/i2c_bus_host.h @@ -21,7 +21,12 @@ class HostI2CBus final : public I2CBus, public Component { void set_device(const std::string &device) { this->device_ = device; } void set_scan(bool scan) { this->scan_ = scan; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#ifdef I2C_PORT_FREQUENCY_COUNT + // The kernel driver owns the clock, so switch_frequency keeps the base default + uint32_t get_frequency() const override { return this->frequency_; } +#endif const std::string &get_device() const { return this->device_; } diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 4145cd58592..ee6e66893e0 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -120,15 +120,34 @@ ErrorCode ZephyrI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer return ERROR_OK; } -void ZephyrI2CBus::set_frequency(uint32_t frequency) { - this->dev_config_ &= ~I2C_SPEED_MASK; - if (frequency >= 400000) { - this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_FAST); - } else { - this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_STANDARD); - } +static uint32_t speed_config(uint32_t dev_config, uint32_t frequency) { + const uint32_t speed = frequency >= 400000 ? I2C_SPEED_FAST : I2C_SPEED_STANDARD; + return (dev_config & ~I2C_SPEED_MASK) | I2C_SPEED_SET(speed); } +void ZephyrI2CBus::set_frequency(uint32_t frequency) { +#ifdef I2C_PORT_FREQUENCY_COUNT + this->frequency_ = frequency; +#endif + this->dev_config_ = speed_config(this->dev_config_, frequency); +} + +#ifdef I2C_PORT_FREQUENCY_COUNT +ErrorCode ZephyrI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + const uint32_t dev_config = speed_config(this->dev_config_, frequency); + // Until ready, setup() applies dev_config_ + if (this->is_ready() && i2c_configure(this->i2c_dev_, dev_config) < 0) { + return ERROR_UNKNOWN; + } + this->dev_config_ = dev_config; + this->frequency_ = frequency; + return ERROR_OK; +} +#endif + } // namespace esphome::i2c #endif diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 3ada1e0a0f2..413d78d6a38 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -22,6 +22,10 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component { void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } void set_frequency(uint32_t frequency); +#ifdef I2C_PORT_FREQUENCY_COUNT + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif int get_port() const override { return 0; } @@ -31,6 +35,9 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component { uint8_t sda_pin_{}; uint8_t scl_pin_{}; uint32_t dev_config_{}; +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t frequency_{}; +#endif }; } // namespace esphome::i2c diff --git a/esphome/components/tca9548a/__init__.py b/esphome/components/tca9548a/__init__.py index a1945e97c52..368e34f3250 100644 --- a/esphome/components/tca9548a/__init__.py +++ b/esphome/components/tca9548a/__init__.py @@ -1,7 +1,14 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_CHANNELS, CONF_ID +from esphome.const import ( + CONF_CHANNEL, + CONF_CHANNELS, + CONF_FREQUENCY, + CONF_I2C_ID, + CONF_ID, +) +from esphome.core import CORE, ID from esphome.types import ConfigType CODEOWNERS = ["@andreashergert1984"] @@ -16,14 +23,51 @@ TCA9548AChannel = tca9548a_ns.class_("TCA9548AChannel", i2c.I2CBus) MULTI_CONF = True CONF_BUS_ID = "bus_id" +# Sizes the root bus's frequency table; only emitted when a frequency is set +_request_port_frequency_slot = cg.slot_counter("I2C_PORT_FREQUENCY_COUNT") + + +def _root_bus_id(bus_id: ID) -> ID: + """The hardware bus behind a port, through any multiplexers in between.""" + parents = { + str(chan[CONF_BUS_ID]): mux[CONF_I2C_ID] + for mux in CORE.config.get(DOMAIN, []) + for chan in mux.get(CONF_CHANNELS, []) + } + while (parent := parents.get(str(bus_id))) is not None: + bus_id = parent + return bus_id + + +def _declared_bus_type(bus_id: ID) -> cg.MockObjClass | None: + """The class the i2c component declared for a bus, or None for an external bus.""" + for bus in CORE.config.get(i2c.DOMAIN, []): + if bus[CONF_ID].id == bus_id.id: + return bus[CONF_ID].type + return None + + +async def _add_frequency(root_bus_id: ID, frequency: int) -> None: + """Count the frequency against the root bus; the built-in ESP32 bus also + creates its device handle in setup().""" + _request_port_frequency_slot(str(root_bus_id)) + if _declared_bus_type(root_bus_id) is i2c.IDFI2CBus: + root = await cg.get_variable(root_bus_id) + cg.add(root.add_frequency(frequency)) + + +_FREQUENCY = cv.All(cv.frequency, cv.Range(min=0, min_included=False)) + CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(TCA9548AComponent), + cv.Optional(CONF_FREQUENCY): _FREQUENCY, cv.Optional(CONF_CHANNELS, default=[]): cv.ensure_list( { cv.Required(CONF_BUS_ID): cv.declare_id(TCA9548AChannel), cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=7), + cv.Optional(CONF_FREQUENCY): _FREQUENCY, } ), } @@ -37,8 +81,15 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + root_bus_id = _root_bus_id(config[CONF_I2C_ID]) + if (frequency := config.get(CONF_FREQUENCY)) is not None: + await _add_frequency(root_bus_id, int(frequency)) + cg.add(var.set_frequency(int(frequency))) for conf in config[CONF_CHANNELS]: chan = cg.new_Pvariable(conf[CONF_BUS_ID]) cg.add(chan.set_parent(var)) cg.add(chan.set_channel(conf[CONF_CHANNEL])) + if (frequency := conf.get(CONF_FREQUENCY)) is not None: + await _add_frequency(root_bus_id, int(frequency)) + cg.add(chan.set_frequency(int(frequency))) diff --git a/esphome/components/tca9548a/tca9548a.cpp b/esphome/components/tca9548a/tca9548a.cpp index f10578c97c2..ac33ca8dedb 100644 --- a/esphome/components/tca9548a/tca9548a.cpp +++ b/esphome/components/tca9548a/tca9548a.cpp @@ -1,4 +1,6 @@ #include "tca9548a.h" + +#include #include "esphome/core/log.h" namespace esphome::tca9548a { @@ -7,20 +9,111 @@ ESPHOME_LOG_TAG(TAG, "tca9548a"); i2c::ErrorCode TCA9548AChannel::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, size_t read_count) { - auto err = this->parent_->switch_to_channel(channel_); +#ifdef I2C_PORT_FREQUENCY_COUNT + const uint32_t select_frequency = this->select_frequency_(); + if (select_frequency != 0) { + return this->write_readv_at_port_frequency_(select_frequency, address, write_buffer, write_count, read_buffer, + read_count); + } +#endif + auto err = this->parent_->switch_to_channel(this->channel_); if (err != i2c::ERROR_OK) return err; err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); this->parent_->disable_all_channels(); return err; } + +#ifdef I2C_PORT_FREQUENCY_COUNT +// The multiplexer's own frequency, else the slower of the bus and the port +uint32_t TCA9548AChannel::select_frequency_() const { + if (this->parent_->frequency_ != 0) + return this->parent_->frequency_; + if (this->frequency_ == 0) + return 0; + // A bus that does not report its frequency gets the port's, and fails the switch + const uint32_t bus_frequency = this->parent_->bus_->get_frequency(); + return bus_frequency != 0 ? std::min(this->frequency_, bus_frequency) : this->frequency_; +} + +// The port frequency, else the multiplexer's +uint32_t TCA9548AChannel::transfer_frequency_() const { + return this->frequency_ != 0 ? this->frequency_ : this->parent_->frequency_; +} + +i2c::ErrorCode TCA9548AChannel::write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address, + const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { + const uint32_t original_frequency = this->parent_->bus_->get_frequency(); + // A failed switch leaves the bus as it was + auto err = this->switch_bus_(select_frequency); + if (err != i2c::ERROR_OK) + return err; + + err = this->parent_->switch_to_channel(this->channel_); + if (err == i2c::ERROR_OK) { + err = this->transfer_(select_frequency, address, write_buffer, write_count, read_buffer, read_count); + this->parent_->disable_all_channels(); + } + const i2c::ErrorCode restored = this->switch_bus_(original_frequency); + return err != i2c::ERROR_OK ? err : restored; +} + +// Transfer at the port frequency, back to the select frequency for the deselect +i2c::ErrorCode TCA9548AChannel::transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, + size_t write_count, uint8_t *read_buffer, size_t read_count) { + const uint32_t transfer_frequency = this->transfer_frequency_(); + if (transfer_frequency == select_frequency) { + return this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); + } + auto err = this->switch_bus_(transfer_frequency); + if (err == i2c::ERROR_OK) { + err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); + } + const i2c::ErrorCode restored = this->switch_bus_(select_frequency); + return err != i2c::ERROR_OK ? err : restored; +} + +// Forward to the shared upstream bus +i2c::ErrorCode TCA9548AChannel::switch_frequency(uint32_t frequency) { + return this->parent_->bus_->switch_frequency(frequency); +} + +i2c::ErrorCode TCA9548AChannel::switch_bus_(uint32_t frequency) { + const i2c::ErrorCode err = this->switch_frequency(frequency); + if (err != i2c::ERROR_OK) { + this->parent_->status_set_error(LOG_STR("Failed to switch the bus frequency")); + } + return err; +} + +uint32_t TCA9548AChannel::get_frequency() const { return this->parent_->bus_->get_frequency(); } +#endif + void TCA9548AComponent::setup() { uint8_t status = 0; - if (this->read(&status, 1) != i2c::ERROR_OK) { +#ifdef I2C_PORT_FREQUENCY_COUNT + const uint32_t original_frequency = this->bus_->get_frequency(); + if (this->frequency_ != 0 && this->bus_->switch_frequency(this->frequency_) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to switch the bus frequency")); + return; + } +#endif + const i2c::ErrorCode err = this->read(&status, 1); +#ifdef I2C_PORT_FREQUENCY_COUNT + const bool restored = this->frequency_ == 0 || this->bus_->switch_frequency(original_frequency) == i2c::ERROR_OK; +#endif + if (err != i2c::ERROR_OK) { ESP_LOGE(TAG, "TCA9548A failed"); this->mark_failed(); return; } +#ifdef I2C_PORT_FREQUENCY_COUNT + if (!restored) { + this->mark_failed(LOG_STR("Failed to switch the bus frequency")); + return; + } +#endif ESP_LOGD(TAG, "Channels currently open: %d", status); } void TCA9548AComponent::dump_config() { @@ -32,14 +125,13 @@ i2c::ErrorCode TCA9548AComponent::switch_to_channel(uint8_t channel) { if (this->is_failed()) return i2c::ERROR_NOT_INITIALIZED; - uint8_t channel_val = 1 << channel; + const uint8_t channel_val = 1 << channel; return this->write(&channel_val, 1); } void TCA9548AComponent::disable_all_channels() { if (this->write(&TCA9548A_DISABLE_CHANNELS_COMMAND, 1) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Failed to disable all channels."); - this->status_set_error(); // couldn't disable channels, set error status + this->status_set_error(LOG_STR("Failed to disable all channels.")); } } diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index a98c226d322..5b0d8a0a769 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -10,13 +10,30 @@ static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00; class TCA9548AComponent; class TCA9548AChannel final : public i2c::I2CBus { public: - void set_channel(uint8_t channel) { channel_ = channel; } - void set_parent(TCA9548AComponent *parent) { parent_ = parent; } + void set_channel(uint8_t channel) { this->channel_ = channel; } + void set_parent(TCA9548AComponent *parent) { this->parent_ = parent; } i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, size_t read_count) override; +#ifdef I2C_PORT_FREQUENCY_COUNT + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + i2c::ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override; +#endif + protected: +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t select_frequency_() const; + uint32_t transfer_frequency_() const; + i2c::ErrorCode write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, + size_t write_count, uint8_t *read_buffer, size_t read_count); + i2c::ErrorCode transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count); + /// Switch the upstream bus, flagging the multiplexer on failure + i2c::ErrorCode switch_bus_(uint32_t frequency); + uint32_t frequency_{0}; +#endif uint8_t channel_; TCA9548AComponent *parent_; }; @@ -30,8 +47,15 @@ class TCA9548AComponent final : public Component, public i2c::I2CDevice { i2c::ErrorCode switch_to_channel(uint8_t channel); void disable_all_channels(); +#ifdef I2C_PORT_FREQUENCY_COUNT + /// Addressing frequency, and the default for the ports + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#endif protected: friend class TCA9548AChannel; +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t frequency_{0}; +#endif }; } // namespace esphome::tca9548a diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 731c0d20171..d4aca85962f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -137,6 +137,7 @@ #define USE_LVGL_TOUCHSCREEN #define USE_MDNS #define USE_MDNS_STORE_SERVICES +#define I2C_PORT_FREQUENCY_COUNT 1 #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 diff --git a/tests/components/tca9548a/common.yaml b/tests/components/tca9548a/common.yaml index bfaf1e26fbb..eea1b9964db 100644 --- a/tests/components/tca9548a/common.yaml +++ b/tests/components/tca9548a/common.yaml @@ -2,9 +2,13 @@ tca9548a: - id: multiplex0 i2c_id: i2c_bus address: 0x70 + frequency: 400 kHz channels: - bus_id: multiplex0_chan0 channel: 0 + - bus_id: multiplex0_chan1 + channel: 1 + frequency: 1 MHz - id: multiplex1 i2c_id: i2c_bus address: 0x71 diff --git a/tests/components/tca9548a/test.nrf52-adafruit.yaml b/tests/components/tca9548a/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..a6641791371 --- /dev/null +++ b/tests/components/tca9548a/test.nrf52-adafruit.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + tca9548a: !include common.yaml From 78d3b9f5a1f255154f45ec9de26ac80c1a9f2296 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Sat, 10 Oct 2026 21:17:01 +0000 Subject: [PATCH 018/651] [nrf52] Fix BLE OTA/log device discovery & transport selection on macOS (#17101) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/nrf52/__init__.py | 8 +- esphome/components/nrf52/ble_logger.py | 23 ++++- esphome/components/nrf52/ota.py | 21 ++--- tests/unit_tests/test_nrf52_ota.py | 121 +++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 tests/unit_tests/test_nrf52_ota.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index de9dc6401e4..f2a78259973 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -732,13 +732,13 @@ def upload_program(config: ConfigType, args, host: str) -> bool: return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths - from .ble_logger import is_mac_address + from .ble_logger import is_ble_address from .ota import smpmgr_scan, smpmgr_upload if host == "BLE": mcumgr_device = asyncio.run(smpmgr_scan(CORE.name)) - if is_mac_address(host): + if is_ble_address(host): mcumgr_device = host if mcumgr_device: @@ -753,7 +753,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: def show_logs(config: ConfigType, args, devices: list[str]) -> bool: address = devices[0] - from .ble_logger import is_mac_address, logger_connect, logger_scan + from .ble_logger import is_ble_address, logger_connect, logger_scan if devices[0] == "BLE": ble_device = asyncio.run(logger_scan(CORE.name)) @@ -762,7 +762,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool: else: return True - if is_mac_address(address): + if is_ble_address(address): asyncio.run(logger_connect(address)) return True return False diff --git a/esphome/components/nrf52/ble_logger.py b/esphome/components/nrf52/ble_logger.py index f74a49ea897..5edcddfd326 100644 --- a/esphome/components/nrf52/ble_logger.py +++ b/esphome/components/nrf52/ble_logger.py @@ -4,6 +4,7 @@ import re from typing import Final from bleak import BleakClient, BleakScanner, BLEDevice +from bleak.backends.scanner import AdvertisementData from bleak.exc import ( BleakCharacteristicNotFoundError, BleakDBusError, @@ -19,15 +20,31 @@ NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" MAC_ADDRESS_PATTERN: Final = re.compile( r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE ) +# macOS identifies BLE peripherals by a CoreBluetooth UUID instead of a MAC address +UUID_PATTERN: Final = re.compile( + r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", + flags=re.IGNORECASE, +) -def is_mac_address(value: str) -> bool: - return MAC_ADDRESS_PATTERN.match(value) +def is_ble_address(value: str) -> bool: + return bool(MAC_ADDRESS_PATTERN.match(value) or UUID_PATTERN.match(value)) + + +def _name_matches(device: BLEDevice, adv: AdvertisementData, name: str) -> bool: + # device.name can be a stale cached name on macOS; adv.local_name is what is on the air now + return name in (device.name, adv.local_name) + + +async def find_device_by_name(name: str, timeout: float = 10.0) -> BLEDevice | None: + return await BleakScanner.find_device_by_filter( + lambda device, adv: _name_matches(device, adv, name), timeout=timeout + ) async def logger_scan(name: str) -> BLEDevice | None: _LOGGER.info("Scanning bluetooth for %s...", name) - device = await BleakScanner.find_device_by_name(name) + device = await find_device_by_name(name) if not device: _LOGGER.error("%s Bluetooth LE device was not found!", name) return device diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index cafeda64785..9115e218747 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -4,7 +4,6 @@ import json import logging from pathlib import Path -from bleak import BleakScanner from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient @@ -23,9 +22,8 @@ from smpclient.transport.serial import SMPSerialTransport from esphome.core import EsphomeError from esphome.espota2 import ProgressBar -from .ble_logger import is_mac_address +from .ble_logger import find_device_by_name, is_ble_address -SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84" BLE_SCAN_TIMEOUT = 10.0 # seconds RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute @@ -45,12 +43,12 @@ def _json_state(o: object) -> object: async def smpmgr_scan(name: str) -> str: _LOGGER.info("Scanning bluetooth for %s...", name) - for device in await BleakScanner.discover( - timeout=BLE_SCAN_TIMEOUT, service_uuids=[SMP_SERVICE_UUID] - ): - if device.name == name: - return device.address - raise EsphomeError(f"BLE device {name} with OTA service not found") + # No service filter: the SMP UUID only fits in the scan response, which macOS does not filter on. + # The SMP service is checked on connect. + device = await find_device_by_name(name, timeout=BLE_SCAN_TIMEOUT) + if device is None: + raise EsphomeError(f"BLE device {name} not found") + return device.address async def smpmgr_upload(device: str, firmware: Path) -> None: @@ -88,7 +86,7 @@ def _get_image_tlv_sha256(file: Path) -> bytes: async def _smpmgr_upload(device: str, firmware: Path) -> None: image_tlv_sha256 = _get_image_tlv_sha256(firmware) - if is_mac_address(device): + if is_ble_address(device): smp_client = SMPClient(SMPBLETransport(), device) else: smp_client = SMPClient(SMPSerialTransport(), device) @@ -105,7 +103,8 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: ) from exc raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: - raise EsphomeError(f"Connection error with {device}") from exc + # Reached when a device with the right name has no SMP service, among other causes + raise EsphomeError(f"Connection error with {device}: {exc}") from exc _LOGGER.info("Connected %s...", device) try: diff --git a/tests/unit_tests/test_nrf52_ota.py b/tests/unit_tests/test_nrf52_ota.py new file mode 100644 index 00000000000..c0b5e202f70 --- /dev/null +++ b/tests/unit_tests/test_nrf52_ota.py @@ -0,0 +1,121 @@ +"""Tests for nRF52 BLE device discovery and OTA transport selection.""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from esphome.components.nrf52 import ble_logger, ota, show_logs +from esphome.core import EsphomeError + +MAC = "AA:BB:CC:DD:EE:FF" +# macOS/CoreBluetooth identifies peripherals by UUID instead of MAC address +UUID = "0FA1B2C3-D4E5-F607-1829-3A4B5C6D7E8F" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (MAC, True), + (MAC.lower(), True), + (UUID, True), + ("/dev/ttyACM0", False), + ("COM3", False), + ], +) +def test_is_ble_address(value: str, expected: bool) -> None: + assert ble_logger.is_ble_address(value) is expected + + +def _scan_result( + monkeypatch, devices: list[tuple[str, str | None, str | None]] +) -> AsyncMock: + """Stub the bleak scan so the filter is applied to the given (address, name, local_name) devices.""" + + async def find_device_by_filter(filterfunc, timeout): + for address, name, local_name in devices: + device = SimpleNamespace(address=address, name=name) + if filterfunc(device, SimpleNamespace(local_name=local_name)): + return device + return None + + mock = AsyncMock(side_effect=find_device_by_filter) + monkeypatch.setattr(ble_logger.BleakScanner, "find_device_by_filter", mock) + return mock + + +def test_scan_matches_advertised_local_name(monkeypatch) -> None: + """On macOS device.name is cached from an earlier connection; the live name is adv.local_name.""" + _scan_result(monkeypatch, [(UUID, "stale-old-name", "my-device")]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == UUID + + +def test_scan_matches_device_name(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "my-device", None)]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == MAC + + +def test_scan_raises_when_no_device_found(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "someone-else", "someone-else")]) + with pytest.raises(EsphomeError, match="not found"): + asyncio.run(ota.smpmgr_scan("my-device")) + + +@pytest.mark.parametrize( + ("device", "transport"), + [("/dev/ttyACM0", "serial"), ("COM3", "serial"), (MAC, "ble"), (UUID, "ble")], +) +def test_upload_transport(monkeypatch, device: str, transport: str) -> None: + """Serial ports go to the serial transport; MAC addresses and macOS UUIDs to BLE.""" + captured: dict = {} + + class FakeClient: + def __init__(self, transport, address): + captured["transport"] = transport + captured["address"] = address + + async def connect(self) -> None: + pass + + async def disconnect(self) -> None: + pass + + monkeypatch.setattr(ota, "_get_image_tlv_sha256", lambda firmware: b"") + monkeypatch.setattr(ota, "_smpmgr_upload_connected", AsyncMock()) + monkeypatch.setattr(ota, "SMPSerialTransport", lambda: "serial") + monkeypatch.setattr(ota, "SMPBLETransport", lambda: "ble") + monkeypatch.setattr(ota, "SMPClient", FakeClient) + + asyncio.run(ota._smpmgr_upload(device, Path("firmware.bin"))) # pylint: disable=protected-access + assert captured == {"transport": transport, "address": device} + + +@pytest.mark.parametrize( + ("device", "connected_to"), + [("BLE", UUID), (MAC, MAC), (UUID, UUID), ("my-device.local", None)], +) +def test_show_logs_routing(monkeypatch, device: str, connected_to: str | None) -> None: + """A scanned device, a MAC or a UUID is handed to the BLE logger; anything else falls through.""" + scan = AsyncMock(return_value=SimpleNamespace(address=UUID)) + connect = AsyncMock(return_value=0) + monkeypatch.setattr(ble_logger, "logger_scan", scan) + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + handled = show_logs(config={}, args=None, devices=[device]) + + assert handled is (connected_to is not None) + if connected_to is None: + connect.assert_not_called() + else: + connect.assert_awaited_once_with(connected_to) + + +def test_show_logs_returns_when_the_scan_finds_nothing(monkeypatch) -> None: + monkeypatch.setattr(ble_logger, "logger_scan", AsyncMock(return_value=None)) + connect = AsyncMock() + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + assert show_logs(config={}, args=None, devices=["BLE"]) is True + connect.assert_not_called() From 642c3b7a9ecf333b25ce71b39d28adede90144f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 11:43:21 -1000 Subject: [PATCH 019/651] [const] Add the milliampere hour unit to the shared constants (#20495) --- esphome/components/const/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index e93d508a3d8..eb999a05ff4 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -73,3 +73,4 @@ KEY_METADATA = "metadata" UNIT_AMPERE_HOUR = "Ah" UNIT_COUNTS = "#" +UNIT_MILLIAMPERE_HOUR = "mAh" From 5f43b8168eac44bfef1d5490e0e5951fafc01d30 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:00:39 -1000 Subject: [PATCH 020/651] Bump bundled esphome-device-builder to 1.23.1 (#20497) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9edbd6e4f9b..c9aa3dba62c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1 RUN \ platformio settings set enable_telemetry No \ From 924d7364ba9c419b5dbf1c027a256291679f706e Mon Sep 17 00:00:00 2001 From: Paul Traina Date: Sat, 10 Oct 2026 16:22:56 -0700 Subject: [PATCH 021/651] [mipi_spi] ESP32-2432S028-9342 panel is RGB, not BGR (#19419) --- esphome/components/mipi_spi/models/cyd.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index 0a35cb4feea..f8a96739608 100644 --- a/esphome/components/mipi_spi/models/cyd.py +++ b/esphome/components/mipi_spi/models/cyd.py @@ -1,3 +1,4 @@ +from esphome.components.mipi import MODE_RGB from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER from .ili import GC9A01A, ILI9341, ILI9342, ST7789V @@ -21,8 +22,10 @@ ST7789V.extend( # fmt: off ILI9342.extend( - # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller + # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller. + # The panel on this board is wired RGB, not the BGR default. "ESP32-2432S028-9342", + color_order=MODE_RGB, data_rate="40MHz", cs_pin={"number": 15, "ignore_strapping_warning": True}, dc_pin={"number": 2, "ignore_strapping_warning": True}, From 1763adf526b338b52d775140b9128e253bcef0e6 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:24:13 +0200 Subject: [PATCH 022/651] [zigbee] add support for SED on esp32 (#20487) --- esphome/components/zigbee/__init__.py | 28 +++++++++++++--- esphome/components/zigbee/const.py | 1 + esphome/components/zigbee/const_zephyr.py | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 37 ++++++++++++++-------- esphome/components/zigbee/zigbee_esp32.h | 5 ++- esphome/components/zigbee/zigbee_esp32.py | 7 ++++ esphome/components/zigbee/zigbee_zephyr.py | 2 +- tests/components/zigbee/common_esp32.yaml | 5 +-- 8 files changed, 63 insertions(+), 23 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 0dc3b4b6f71..d01eddb9ff7 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -13,7 +13,14 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S31, ) import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START +from esphome.const import ( + CONF_ID, + CONF_INTERNAL, + CONF_MODEL, + CONF_NAME, + CONF_ON_START, + CONF_POLLING_INTERVAL, +) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType @@ -25,6 +32,7 @@ from .const import ( CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_SLEEPY, CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, @@ -35,7 +43,6 @@ from .const import ( from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER_ZEPHYR, - CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, ) @@ -128,8 +135,11 @@ NUMBER_SCHEMA = ( def _validate_router_sleepy(config: ConfigType) -> ConfigType: - if config.get(CONF_ROUTER) and config.get(CONF_SLEEPY): - raise cv.Invalid("router and sleepy are mutually exclusive") + if config.get(CONF_ROUTER): + if config.get(CONF_SLEEPY): + raise cv.Invalid("router and sleepy are mutually exclusive") + if config.get(CONF_POLLING_INTERVAL): + raise cv.Invalid(f"{CONF_POLLING_INTERVAL} is only valid for end devices.") return config @@ -160,9 +170,17 @@ CONFIG_SCHEMA = cv.All( ), cv.requires_component("nrf52"), ), - cv.OnlyWith(CONF_SLEEPY, "nrf52", default=False): cv.All( + cv.Optional(CONF_SLEEPY, default=False): cv.All( cv.boolean, ), + cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.requires_component(ESP32_DOMAIN), + cv.positive_time_period_milliseconds, + cv.Range( + min=cv.TimePeriodMilliseconds(milliseconds=200), + max=cv.TimePeriodMinutes(minutes=60), # 1 hour + ), + ), } ).extend(cv.COMPONENT_SCHEMA), _validate_router_sleepy, diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 86cac665b43..aad2b5c0e39 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -72,6 +72,7 @@ CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" +CONF_SLEEPY = "sleepy" CONF_USE_DEVICE_TYPE = "use_device_type" POWER_SOURCE = { "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index bf8e8287c4b..7177b9fc798 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -4,7 +4,6 @@ CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" -CONF_SLEEPY = "sleepy" CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 89417e748d2..7c197133c2b 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -319,7 +319,7 @@ void ZigbeeComponent::setup() { #else esp_zigbee_zed_config_s zb_zed_cfg = { .ed_timeout = EZB_NWK_ED_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, + .keep_alive = this->keep_alive_, }; device_config.zed_config = zb_zed_cfg; #endif @@ -339,6 +339,8 @@ void ZigbeeComponent::setup() { #ifdef CONFIG_ZB_ZCZR ezb_bdb_set_router_rejoin_required(true); +#else + ezb_nwk_set_rx_on_when_idle(!this->sleepy_); // if sleepy, disable RX when idle #endif ezb_aps_secur_enable_distributed_security(false); @@ -419,18 +421,27 @@ void ZigbeeComponent::loop() { void ZigbeeComponent::dump_config() { if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - ESP_LOGCONFIG(TAG, - "Zigbee\n" - " Model: %.*s\n" - " Router: %s\n" - " Device is joined to the network: %s\n" - " Current channel: %d\n" - " Short addr: 0x%04X\n" - " Short pan id: 0x%04X", - this->basic_cluster_data_.model[0], - reinterpret_cast(this->basic_cluster_data_.model + 1), - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), YESNO(ezb_bdb_dev_joined()), - ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); + ESP_LOGCONFIG( + TAG, + "Zigbee\n" + " Model: %.*s\n" +#ifdef CONFIG_ZB_ZCZR + " Router: %s\n" +#else + " Sleepy end device: %s\n" + " Poll interval: %" PRIu32 " ms\n" +#endif + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), +#ifdef CONFIG_ZB_ZCZR + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), +#else + YESNO(this->sleepy_), this->keep_alive_, +#endif + YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); esp_zigbee_lock_release(); } else { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 093ae959390..28ea64c8cea 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -20,7 +20,6 @@ namespace esphome::zigbee { /* Zigbee configuration */ -static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ static const uint8_t MAX_CHILDREN = 10; static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 */ static constexpr uint8_t MAX_ZB_QUEUE_SIZE = 32; @@ -43,6 +42,8 @@ class ZigbeeComponent final : public Component { void loop() override; void dump_config() override; + void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } + void set_keep_alive(uint32_t keep_alive) { this->keep_alive_ = keep_alive; } void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); @@ -73,6 +74,8 @@ class ZigbeeComponent final : public Component { bool is_joined() { return this->joined_; } protected: + bool sleepy_{false}; + uint32_t keep_alive_{3000}; struct { uint8_t *model; uint8_t *manufacturer; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 103e901b13b..68136d57192 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -24,6 +24,7 @@ from esphome.const import ( CONF_MAX_LENGTH, CONF_MODEL, CONF_NAME, + CONF_POLLING_INTERVAL, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, @@ -45,6 +46,7 @@ from .const import ( CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_SLEEPY, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, POWER_SOURCE, @@ -412,6 +414,11 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if config.get(CONF_SLEEPY): + cg.add(var.set_sleepy(True)) + if (keep_alive := config.get(CONF_POLLING_INTERVAL, 3000)) != 3000: + cg.add(var.set_keep_alive(keep_alive)) + cg.add( var.set_basic_cluster( config[CONF_MODEL], diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 3f863140e69..8197796b6db 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -24,6 +24,7 @@ from .const import ( BACNET_UNITS, CONF_POWER_SOURCE, CONF_ROUTER, + CONF_SLEEPY, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -35,7 +36,6 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, - CONF_SLEEPY, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_NUMBER, diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 3d19180c064..84b79bb84f0 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -58,8 +58,9 @@ switch: zigbee: model: zigbee_test - router: false - power_source: MAINS_SINGLE_PHASE + sleepy: true + polling_interval: 2s + power_source: BATTERY on_join: then: - logger.log: "Joined network" From 3be8c5b6b8f7bd769d1f6a81963021668a1661d1 Mon Sep 17 00:00:00 2001 From: fakuivan Date: Sat, 10 Oct 2026 22:07:36 -0300 Subject: [PATCH 023/651] [canbus] Fix high frame rates causing crashes due to unbounded iteration (#14481) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/canbus/__init__.py | 20 ++++++++++++++++---- esphome/components/canbus/canbus.cpp | 22 +++++++++++++++------- esphome/components/canbus/canbus.h | 6 ++++-- tests/components/canbus/common.yaml | 1 + 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 51bda0c75df..23dc41d5092 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -20,6 +20,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request" CONF_CANBUS_ID = "canbus_id" CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" +CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop" +DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h +DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h def validate_id(config: ConfigType) -> ConfigType: @@ -87,8 +90,13 @@ CANBUS_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(CanbusComponent), cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), - cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True), + cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum( + CAN_SPEEDS, upper=True + ), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, + cv.Optional( + CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP + ): cv.positive_not_null_int, cv.Optional(CONF_ON_FRAME): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger), @@ -109,9 +117,13 @@ CANBUS_SCHEMA.add_extra(validate_id) async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - cg.add(var.set_can_id([config[CONF_CAN_ID]])) - cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]])) - cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]])) + cg.add(var.set_can_id(config[CONF_CAN_ID])) + if config[CONF_USE_EXTENDED_ID]: + cg.add(var.set_use_extended_id(True)) + if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE: + cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate])) + if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP: + cg.add(var.set_max_frames_per_loop(max_frames)) for conf in config.get(CONF_ON_FRAME, []): can_id = conf[CONF_CAN_ID] diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 828eb77d1ca..88eed2246fd 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -66,16 +66,18 @@ void Canbus::add_trigger(CanbusTrigger *trigger) { void Canbus::loop() { struct CanFrame can_message; - // read all messages until queue is empty - int message_counter = 0; - while (this->read_message(&can_message) == canbus::ERROR_OK) { - message_counter++; + uint32_t message_counter = 0; + for (; message_counter < this->max_frames_per_loop_; message_counter++) { + if (this->read_message(&can_message) != canbus::ERROR_OK) { + break; + } + if (can_message.use_extended_id) { - ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter, + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1, can_message.can_id, can_message.can_data_length_code); } else { - ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id, - can_message.can_data_length_code); + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1, + can_message.can_id, can_message.can_data_length_code); } std::vector data; @@ -99,6 +101,12 @@ void Canbus::loop() { } } } + + if (message_counter == this->max_frames_per_loop_) { + ESP_LOGW(TAG, + "Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped", + this->max_frames_per_loop_); + } } } // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 8685c026b0b..432eb77e13d 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -82,6 +82,7 @@ class Canbus : public Component { void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } + void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } void add_trigger(CanbusTrigger *trigger); /** @@ -100,8 +101,9 @@ class Canbus : public Component { template friend class CanbusSendAction; std::vector triggers_{}; uint32_t can_id_; - bool use_extended_id_; - CanSpeed bit_rate_; + uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py + CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py + bool use_extended_id_{false}; CallbackManager &data)> callback_manager_{}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 57ee25bfc16..14e0d6bf5a6 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -5,6 +5,7 @@ canbus: tx_pin: 5 can_id: 4 bit_rate: 50kbps + max_frames_per_loop: 100 on_frame: - can_id: 500 then: From 8af91c966304aed12af8d33374ec4e0afd5d5b70 Mon Sep 17 00:00:00 2001 From: Achilleas Kotsis Date: Sun, 11 Oct 2026 04:23:31 +0300 Subject: [PATCH 024/651] [feedback] Support for overshoot_duration (#13186) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/feedback/cover.py | 22 ++++++++++++++++ .../components/feedback/feedback_cover.cpp | 26 +++++++++++++++++-- esphome/components/feedback/feedback_cover.h | 9 +++++++ esphome/core/defines.h | 1 + tests/components/feedback/common.yaml | 13 ++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index 032d01c8e51..9fbda9a9737 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -24,6 +24,7 @@ CONF_HAS_BUILT_IN_ENDSTOP = "has_built_in_endstop" CONF_INFER_ENDSTOP_FROM_MOVEMENT = "infer_endstop_from_movement" CONF_DIRECTION_CHANGE_WAIT_TIME = "direction_change_wait_time" CONF_ACCELERATION_WAIT_TIME = "acceleration_wait_time" +CONF_OVERSHOOT_DURATION = "overshoot_duration" CONF_OBSTACLE_ROLLBACK = "obstacle_rollback" endstop_ns = cg.esphome_ns.namespace("feedback") @@ -82,6 +83,9 @@ CONFIG_FEEDBACK_COVER_BASE_SCHEMA = ( cv.Optional( CONF_ACCELERATION_WAIT_TIME, "0s" ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_OVERSHOOT_DURATION, "0s" + ): cv.positive_time_period_milliseconds, cv.Optional(CONF_OBSTACLE_ROLLBACK, default="10%"): cv.percentage, }, ) @@ -89,10 +93,25 @@ CONFIG_FEEDBACK_COVER_BASE_SCHEMA = ( ) +def validate_overshoot(config: ConfigType) -> ConfigType: + if not config[CONF_OVERSHOOT_DURATION].total_milliseconds: + return config + if config[CONF_HAS_BUILT_IN_ENDSTOP]: + raise cv.Invalid( + f"{CONF_OVERSHOOT_DURATION} has no effect with {CONF_HAS_BUILT_IN_ENDSTOP}" + ) + if CONF_OPEN_ENDSTOP in config and CONF_CLOSE_ENDSTOP in config: + raise cv.Invalid( + f"{CONF_OVERSHOOT_DURATION} has no effect when both endstop sensors are supplied" + ) + return config + + CONFIG_SCHEMA = cv.All( CONFIG_FEEDBACK_COVER_BASE_SCHEMA, cv.has_none_or_all_keys(CONF_OPEN_SENSOR, CONF_CLOSE_SENSOR), validate_infer_endstop, + validate_overshoot, ) @@ -160,4 +179,7 @@ async def to_code(config: ConfigType) -> None: var.set_direction_change_waittime(config[CONF_DIRECTION_CHANGE_WAIT_TIME]) ) cg.add(var.set_acceleration_wait_time(config[CONF_ACCELERATION_WAIT_TIME])) + if (overshoot := config[CONF_OVERSHOOT_DURATION]).total_milliseconds: + cg.add_define("USE_FEEDBACK_COVER_OVERSHOOT") + cg.add(var.set_overshoot_duration(overshoot)) cg.add(var.set_obstacle_rollback(config[CONF_OBSTACLE_ROLLBACK])) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index fb6c3929b04..8fa56bd370e 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -79,6 +79,11 @@ void FeedbackCover::dump_config() { if (this->acceleration_wait_time_) { ESP_LOGCONFIG(TAG, " Acceleration wait time: %.1fs", this->acceleration_wait_time_ / 1e3f); } +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + if (this->overshoot_duration_) { + ESP_LOGCONFIG(TAG, " Overshoot duration: %.1fs", this->overshoot_duration_ / 1e3f); + } +#endif #ifdef USE_BINARY_SENSOR if (this->obstacle_rollback_ && (this->open_obstacle_ != nullptr || this->close_obstacle_ != nullptr)) { ESP_LOGCONFIG(TAG, " Obstacle rollback: %.1f%%", this->obstacle_rollback_ * 100); @@ -234,10 +239,22 @@ void FeedbackCover::loop() { // (stoping from endstop sensor is handled in callback) if (this->current_trigger_operation_ != COVER_OPERATION_IDLE) { if (this->is_at_target_()) { - if (this->has_built_in_endstop_ && - (this->target_position_ == COVER_OPEN || this->target_position_ == COVER_CLOSED)) { + const bool at_end = this->target_position_ == COVER_OPEN || this->target_position_ == COVER_CLOSED; + if (this->has_built_in_endstop_ && at_end) { // Don't trigger stop, let the cover stop by itself. this->set_current_operation_(COVER_OPERATION_IDLE, true); +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + } else if (at_end && this->overshoot_duration_) { + // The define is global, so a cover without the option still lands here with a zero duration + // Keep driving past the end position for a while so accumulated timing error is squeezed out + if (!this->start_overshoot_time_) { + this->start_overshoot_time_ = now; + } else if (now - this->start_overshoot_time_ > this->overshoot_duration_ || + now - this->start_dir_time_ > this->max_duration_) { + ESP_LOGD(TAG, "'%s' - Overshoot duration reached. Stopping cover.", this->name_.c_str()); + this->start_direction_(COVER_OPERATION_IDLE); + } +#endif } else { this->start_direction_(COVER_OPERATION_IDLE); } @@ -340,6 +357,11 @@ void FeedbackCover::start_direction_(CoverOperation dir) { binary_sensor::BinarySensor *obstacle{nullptr}; #endif +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + // Any new command ends a running overshoot + this->start_overshoot_time_ = 0; +#endif + switch (dir) { case COVER_OPERATION_IDLE: trig = &this->stop_trigger_; diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index 3e4600acd2f..b641f6e7163 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -33,6 +33,9 @@ class FeedbackCover final : public cover::Cover, public Component { void set_has_built_in_endstop(bool value) { this->has_built_in_endstop_ = value; } void set_assumed_state(bool value) { this->assumed_state_ = value; } void set_max_duration(uint32_t max_duration) { this->max_duration_ = max_duration; } +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + void set_overshoot_duration(uint32_t overshoot_duration) { this->overshoot_duration_ = overshoot_duration; } +#endif void set_obstacle_rollback(float obstacle_rollback) { this->obstacle_rollback_ = obstacle_rollback; } void set_update_interval(uint32_t interval) { this->update_interval_ = interval; } void set_infer_endstop(bool infer_endstop) { this->infer_endstop_ = infer_endstop; } @@ -67,6 +70,9 @@ class FeedbackCover final : public cover::Cover, public Component { uint32_t open_duration_{0}; uint32_t close_duration_{0}; uint32_t max_duration_{UINT32_MAX}; +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + uint32_t overshoot_duration_{0}; +#endif optional direction_change_waittime_{}; uint32_t acceleration_wait_time_{0}; bool has_built_in_endstop_{false}; @@ -80,6 +86,9 @@ class FeedbackCover final : public cover::Cover, public Component { uint32_t last_recompute_time_{0}; uint32_t start_dir_time_{0}; uint32_t last_publish_time_{0}; +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + uint32_t start_overshoot_time_{0}; +#endif float target_position_{0}; uint32_t update_interval_{1000}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d4aca85962f..0e5e2f4f292 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -73,6 +73,7 @@ #define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN +#define USE_FEEDBACK_COVER_OVERSHOOT #define USE_GPIO_BINARY_SENSOR_INTERRUPT #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH diff --git a/tests/components/feedback/common.yaml b/tests/components/feedback/common.yaml index f93d54e8b63..a892aa614fc 100644 --- a/tests/components/feedback/common.yaml +++ b/tests/components/feedback/common.yaml @@ -37,3 +37,16 @@ cover: - logger.log: Close Action stop_action: - logger.log: Stop Action + # Time-only cover with overshoot next to one without: the define is global, so both paths compile + - platform: feedback + name: Feedback Cover Plain + id: feedback_plain + open_duration: 10s + close_duration: 10s + overshoot_duration: 2s + open_action: + - logger.log: Plain Open + close_action: + - logger.log: Plain Close + stop_action: + - logger.log: Plain Stop From eeaa8c78d84082fc7c3cf7f68e2a327759c81bef Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:33:18 +1100 Subject: [PATCH 025/651] [cli] Add line format error message option (#20470) Co-authored-by: Claude Opus 5.5 --- esphome/__main__.py | 36 ++- esphome/components/substitutions/__init__.py | 17 +- esphome/config.py | 103 +++++- esphome/config_validation.py | 18 +- esphome/const.py | 9 + esphome/core/__init__.py | 4 + esphome/voluptuous_schema.py | 10 +- esphome/vscode.py | 13 +- tests/unit_tests/test_config_error_format.py | 312 +++++++++++++++++++ tests/unit_tests/test_main.py | 57 ++++ tests/unit_tests/test_vscode.py | 72 +++++ 11 files changed, 618 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/test_config_error_format.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 905339d2bcc..77c1c9f1ade 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -51,6 +51,7 @@ from esphome.const import ( KEY_ESP32, KEY_VARIANT, SECRETS_FILES, + ErrorFormat, Toolchain, ) from esphome.core import CORE, EsphomeError, coroutine @@ -2205,6 +2206,16 @@ def _add_states_args(parser: argparse.ArgumentParser) -> None: ) +def _error_format(value: str) -> ErrorFormat: + try: + return ErrorFormat(value.lower()) + except ValueError: + choices = ", ".join(ErrorFormat) + raise argparse.ArgumentTypeError( + f"invalid choice: '{value}' (choose from {choices})" + ) from None + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2267,6 +2278,17 @@ def parse_args(argv): ), ) + options_parser.add_argument( + "--error-format", + type=_error_format, + metavar="{" + ",".join(f.value for f in ErrorFormat) + "}", + help=( + "How configuration errors are shown. 'yaml' shows them inside the " + "failing YAML; 'line' prints one 'file:line:column: error: message' " + "line per error. Default: $ESPHOME_ERROR_FORMAT, else 'yaml'." + ), + ) + parser = argparse.ArgumentParser( description=f"ESPHome {const.__version__}", parents=[options_parser] ) @@ -2570,9 +2592,18 @@ def parse_args(argv): args, unknown_args = parser.parse_known_args(arguments) if unknown_args: _LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args) - return args + else: + args = parser.parse_args(arguments) - return parser.parse_args(arguments) + # Read here rather than as the option default, so a bad value is reported + # against the environment variable instead of --error-format + if args.error_format is None: + env_value = os.getenv("ESPHOME_ERROR_FORMAT", ErrorFormat.YAML.value) + try: + args.error_format = _error_format(env_value) + except argparse.ArgumentTypeError as err: + parser.error(f"ESPHOME_ERROR_FORMAT: {err}") + return args def _warn_if_source_tree_mismatch() -> None: @@ -2645,6 +2676,7 @@ def run_esphome(argv): args = parse_args(argv) CORE.dashboard = args.dashboard CORE.testing_mode = args.testing_mode + CORE.error_format = args.error_format # Create address cache from command-line arguments CORE.address_cache = AddressCache.from_cli_args( diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 93cf588e02a..711dd9e8157 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -68,8 +68,9 @@ def raise_first_undefined( for e, p_path, _ in errors[1:] ) _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) - raise cv.Invalid( - f"Undefined variable in {context_label}: {err.message}\n{format_path(err_path, err_value)}" + raise cv.SourceTraceInvalid( + f"Undefined variable in {context_label}: {err.message}", + format_path(err_path, err_value), ) @@ -223,13 +224,13 @@ def _expand_substitutions( errors=errors, ) except JinjaError as err: - raise cv.Invalid( + raise cv.SourceTraceInvalid( f"{err.error_name()} Error evaluating jinja expression" f" '{value}': {str(err.parent())}." f"\nEvaluation stack: (most recent evaluation last)" f"\n{err.stack_trace_str()}" - f"\nRelevant context:\n{err.context_trace_str()}" - f"\n{format_path(path, orig_value)}", + f"\nRelevant context:\n{err.context_trace_str()}", + format_path(path, orig_value), path, ) from err else: @@ -389,9 +390,9 @@ def resolve_include( return include.load() if include.should_load() else {} except (esphome.core.EsphomeError, cv.Invalid) as err: resolved = f" (expanded from '{original}')" if substituted else "" - raise cv.Invalid( - f"Error including file '{filename}'{resolved}: {err}" - f"\n{format_path(path, original)}", + raise cv.SourceTraceInvalid( + f"Error including file '{filename}'{resolved}: {err}", + format_path(path, original), path + [f"<{filename}>"], ) from err diff --git a/esphome/config.py b/esphome/config.py index 28a688f15ab..682557ecfc9 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -12,6 +12,7 @@ import re from typing import TYPE_CHECKING, Any import voluptuous as vol +import yaml from esphome import core, loader, pins, yaml_util from esphome.components.substitutions import do_substitution_pass @@ -26,8 +27,9 @@ from esphome.const import ( CONF_PACKAGES, CONF_PLATFORM, CONF_SUBSTITUTIONS, + ErrorFormat, ) -from esphome.core import CORE, DocumentRange, EsphomeError +from esphome.core import CORE, DocumentLocation, DocumentRange, EsphomeError # `esphome.core.config` is imported lazily at its two use sites below. # It pulls in `esphome.automation` and `esphome.config_validation`, which @@ -38,7 +40,7 @@ from esphome.loader import ComponentManifest, get_component, get_platform from esphome.log import AnsiFore, color from esphome.types import ConfigFragmentType, ConfigType from esphome.util import OrderedDict, safe_print -from esphome.voluptuous_schema import ExtraKeysInvalid +from esphome.voluptuous_schema import ExtraKeysInvalid, KeyInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret if TYPE_CHECKING: @@ -285,12 +287,8 @@ class Config(OrderedDict, fv.FinalValidateConfig): last_root = max( i for i, v in enumerate(error.path) if v is cv.ROOT_CONFIG_PATH ) - # can't change the path so re-create the error - error = vol.Invalid( - message=error.error_message, - path=error.path[last_root + 1 :], - error_type=error.error_type, - ) + # Trim in place, as Invalid.prepend() does, to keep the subclass + error._path = error.path[last_root + 1 :] # pylint: disable=protected-access self.errors.append(error) def add_validation_step(self, step: ConfigValidationStep): @@ -1513,6 +1511,7 @@ class InvalidYAMLError(EsphomeError): message = f"Invalid YAML syntax:\n\n{base}" super().__init__(message) self.base_exc = base_exc + self.base_message = base def _load_config( @@ -1555,6 +1554,86 @@ def load_config( raise EsphomeError(f"Error while parsing config: {err}") from err +def get_invalid_range(res: Config, invalid: vol.Invalid) -> DocumentRange | None: + """Return the source range of the YAML that caused *invalid*.""" + return res.get_deepest_document_range_for_path( + invalid.path, isinstance(invalid, KeyInvalid) + ) + + +def _format_location(location: DocumentLocation | None) -> str: + """Return a 1-based `file:line:column`, or the main config file if unknown.""" + if location is None: + return str(CORE.config_path) + return f"{location.document}:{location.line + 1}:{location.column + 1}" + + +def _print_line_message(location: str, kind: str, message: object) -> None: + """Print *message* as `location: kind: text`, one line per message line. + + Lines after the first become `note:` lines, so tools that read one + message per line still see every line with its location. + """ + lines = [line.strip() for line in str(message).splitlines() if line.strip()] + first, *rest = lines or [""] + safe_print(f"{location}: {kind}: {first}") + for line in rest: + safe_print(f"{location}: note: {line}") + + +def _find_marked_yaml_error(err: BaseException) -> yaml.MarkedYAMLError | None: + cause: BaseException | None = err + while cause is not None and not isinstance(cause, yaml.MarkedYAMLError): + cause = cause.__cause__ + return cause + + +def _print_marked_yaml_error(err: yaml.MarkedYAMLError) -> None: + # Errors raised as MarkedYAMLError(message, mark) put the message in context + mark = err.problem_mark or err.context_mark + location = DocumentLocation.from_mark(mark) if mark is not None else None + if not (message := err.problem or err.context): + # str() includes the note, so it is not printed separately + _print_line_message(_format_location(location), "error", str(err)) + return + _print_line_message(_format_location(location), "error", message) + if err.problem and err.context and err.context_mark is not None: + context_location = DocumentLocation.from_mark(err.context_mark) + _print_line_message(_format_location(context_location), "note", err.context) + if err.note: + _print_line_message(_format_location(location), "note", err.note) + + +def _print_line_errors(res: Config) -> None: + for err in res.errors: + doc_range = get_invalid_range(res, err) + location = _format_location( + doc_range.start_mark if doc_range is not None else None + ) + # A YAML error in an included file is wrapped in a validation error + if (yaml_err := _find_marked_yaml_error(err)) is not None: + _print_marked_yaml_error(yaml_err) + _print_line_message(location, "note", "included from here") + elif isinstance(err, cv.SourceTraceInvalid): + # The trace only repeats the location this line already starts with + _print_line_message(location, "error", humanize_error(res, err.detail)) + else: + _print_line_message(location, "error", _format_vol_invalid(err, res)) + if doc_range is None and err.path: + # No source position, so give the config path to help find it + path = "->".join(str(item) for item in err.path) + _print_line_message(location, "note", f"In: {path}") + + +def _print_line_load_error(err: EsphomeError) -> None: + if (yaml_err := _find_marked_yaml_error(err)) is not None: + _print_marked_yaml_error(yaml_err) + else: + # Drop the "Invalid YAML syntax" heading; it is wrong for unreadable files + message = err.base_message if isinstance(err, InvalidYAMLError) else err + _print_line_message(_format_location(None), "error", message) + + def line_info(config, path, highlight=True): """Display line config source.""" if not highlight: @@ -1703,7 +1782,13 @@ def read_config( snapshot_user_config=snapshot_user_config, ) except EsphomeError as err: - _LOGGER.error("Error while reading config: %s", err) + if CORE.error_format == ErrorFormat.LINE: + _print_line_load_error(err) + else: + _LOGGER.error("Error while reading config: %s", err) + return None + if res.errors and CORE.error_format == ErrorFormat.LINE: + _print_line_errors(res) return None if res.errors: if not CORE.verbose: diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 79d7068838a..64d22f9a143 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -113,7 +113,7 @@ from esphome.schema_extractors import ( # Deprecated re-export for external components; remove before 2027.2.0 # pylint: disable-next=unused-import from esphome.util import parse_esphome_version # noqa: F401 -from esphome.voluptuous_schema import _Schema +from esphome.voluptuous_schema import KeyInvalid, _Schema from esphome.yaml_util import SensitiveStr, make_data_base if typing.TYPE_CHECKING: @@ -452,6 +452,20 @@ def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schem return schema.extend(overrides) +class SourceTraceInvalid(Invalid): + """An error whose message is followed by where the bad value came from. + + ``detail`` is the message without that trace, for output that already + gives the location. + """ + + def __init__( + self, detail: str, source_trace: str, path: list[typing.Hashable] | None = None + ) -> None: + super().__init__(f"{detail}\n{source_trace}", path) + self.detail = detail + + class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" @@ -2293,7 +2307,7 @@ def validate_registry_entry(name, registry): if key is None: raise Invalid(f"Key missing from {name}! Got {value}") if key not in registry: - raise Invalid(f"Unable to find {name} with the name '{key}'", [key]) + raise KeyInvalid(f"Unable to find {name} with the name '{key}'", [key]) key2 = next((x for x in value if x != key and x not in ignore_keys), None) if key2 is not None: raise Invalid( diff --git a/esphome/const.py b/esphome/const.py index 7774722795d..115ccad7470 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -27,6 +27,15 @@ class Toolchain(StrEnum): HOST = "host" +class ErrorFormat(StrEnum): + """How configuration errors are printed.""" + + # Errors embedded in a dump of the failing YAML + YAML = "yaml" + # One `file:line:col: error: message` line per error + LINE = "line" + + # Toolchains that drive their build natively and never read platformio.ini. # SDK_NRF is absent on purpose: the zephyr backend keeps consuming # platformio_options. diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 1668263e337..61092ec4272 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -30,6 +30,7 @@ from esphome.const import ( PLATFORM_NRF52, PLATFORM_RP2, PLATFORM_RTL87XX, + ErrorFormat, Toolchain, ) @@ -681,6 +682,8 @@ class EsphomeCore: self.verbose = False # Whether ESPHome was started in quiet mode self.quiet = False + # How configuration errors are printed + self.error_format: ErrorFormat = ErrorFormat.YAML # A list of all known ID classes self.id_classes = {} # The current component being processed during validation @@ -734,6 +737,7 @@ class EsphomeCore: self._config_hash = None self.skip_external_update = False self.toolchain = None + self.error_format = ErrorFormat.YAML PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 904963ba4e8..a31f3252ce3 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -6,10 +6,14 @@ import voluptuous as vol from esphome.schema_extractors import schema_extractor_extended -class ExtraKeysInvalid(vol.Invalid): +class KeyInvalid(vol.Invalid): + """An error caused by a mapping key itself rather than by its value.""" + + +class ExtraKeysInvalid(KeyInvalid): def __init__(self, *arg, **kwargs): self.candidates = kwargs.pop("candidates") - vol.Invalid.__init__(self, *arg, **kwargs) + super().__init__(*arg, **kwargs) def ensure_multiple_invalid(err): @@ -192,7 +196,7 @@ class _Schema(vol.Schema): ) else: errors.append( - vol.Invalid("extra keys not allowed", key_path) + KeyInvalid("extra keys not allowed", key_path) ) # for any required keys left that weren't found and don't have defaults: diff --git a/esphome/vscode.py b/esphome/vscode.py index ba7b4e727b5..e4ff0bb6ea6 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -7,19 +7,13 @@ import sys import traceback from typing import Any -from esphome.config import Config, _format_vol_invalid, validate_config +from esphome.config import _format_vol_invalid, get_invalid_range, validate_config import esphome.config_validation as cv from esphome.const import __version__ as ESPHOME_VERSION from esphome.core import CORE, DocumentRange, EsphomeError from esphome.yaml_util import parse_yaml -def _get_invalid_range(res: Config, invalid: cv.Invalid) -> DocumentRange | None: - return res.get_deepest_document_range_for_path( - invalid.path, invalid.error_message == "extra keys not allowed" - ) - - def _dump_range(range: DocumentRange | None) -> dict | None: if range is None: return None @@ -155,8 +149,9 @@ def read_config(args): else: for err in res.errors: try: - range_ = _get_invalid_range(res, err) + range_ = get_invalid_range(res, err) vs.add_validation_error(range_, _format_vol_invalid(err, res)) except Exception: # noqa: BLE001 # pylint: disable=broad-except - continue + # Report the error without a range rather than drop it + vs.add_validation_error(None, str(err)) print(vs.dump()) diff --git a/tests/unit_tests/test_config_error_format.py b/tests/unit_tests/test_config_error_format.py new file mode 100644 index 00000000000..395253dd708 --- /dev/null +++ b/tests/unit_tests/test_config_error_format.py @@ -0,0 +1,312 @@ +"""Tests for the `--error-format line` output of read_config.""" + +from pathlib import Path + +import pytest +import voluptuous as vol +import yaml + +from esphome import config_validation as cv +from esphome.config import ( + Config, + InvalidYAMLError, + _print_line_errors, + _print_line_load_error, + _print_line_message, + _print_marked_yaml_error, + read_config, +) +from esphome.const import ErrorFormat +from esphome.core import CORE +from esphome.voluptuous_schema import ExtraKeysInvalid, KeyInvalid + + +def _read( + tmp_path: Path, files: dict[str, str], capsys: pytest.CaptureFixture[str] +) -> list[str]: + for name, content in files.items(): + (tmp_path / name).write_text(content) + CORE.config_path = tmp_path / "test.yaml" + CORE.error_format = ErrorFormat.LINE + assert read_config({}) is None + return capsys.readouterr().out.splitlines() + + +def test_validation_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + lines = _read( + tmp_path, + {"test.yaml": "esphome:\n name: test\nhost:\nlogger:\n levl: DEBUG\n"}, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:5:3: error: [levl] is an invalid option for " + "[logger]. Did you mean [level]?" + ) + ] + + +def test_validation_error_in_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\npackages:\n pkg: !include pkg.yaml\n" + ), + "pkg.yaml": "logger:\n levl: DEBUG\n", + }, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'pkg.yaml'}:2:3: error: [levl] is an invalid option for " + "[logger]. Did you mean [level]?" + ) + ] + + +def test_unknown_action_anchors_on_the_key( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # validate_registry_entry raises KeyInvalid for an unknown action, so the + # location is the key, not the action's value. + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\nlogger:\nbutton:\n" + ' - platform: template\n on_press:\n - loggr.log: "msg"\n' + ) + }, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:8:9: error: Unable to find action " + "with the name 'loggr.log'." + ) + ] + + +def test_yaml_syntax_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + lines = _read(tmp_path, {"test.yaml": "esphome:\n name: [1, 2\nhost:\n"}, capsys) + assert lines == [ + f"{tmp_path / 'test.yaml'}:3:5: error: expected ',' or ']', but got ':'", + f"{tmp_path / 'test.yaml'}:2:9: note: while parsing a flow sequence", + ] + + +def test_yaml_syntax_error_in_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\npackages:\n pkg: !include pkg.yaml\n" + ), + "pkg.yaml": "logger:\n level: [1, 2\nwifi:\n", + }, + capsys, + ) + assert lines == [ + f"{tmp_path / 'pkg.yaml'}:3:5: error: expected ',' or ']', but got ':'", + f"{tmp_path / 'pkg.yaml'}:2:10: note: while parsing a flow sequence", + f"{tmp_path / 'test.yaml'}:5:3: note: included from here", + ] + + +def test_secret_syntax_error_in_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "sub").mkdir() + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\npackages:\n p: !include sub/pkg.yaml\n" + ), + "sub/pkg.yaml": "logger:\n level: !secret lvl\n", + "secrets.yaml": "lvl: [1, 2\nx: 1\n", + }, + capsys, + ) + assert lines == [ + f"{tmp_path / 'secrets.yaml'}:2:2: error: expected ',' or ']', but got ':'", + f"{tmp_path / 'secrets.yaml'}:1:6: note: while parsing a flow sequence", + f"{tmp_path / 'test.yaml'}:5:3: note: included from here", + ] + + +def test_undefined_secret(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + lines = _read( + tmp_path, + { + "test.yaml": "esphome:\n name: !secret nope\nhost:\n", + "secrets.yaml": "other: 1\n", + }, + capsys, + ) + assert lines == [f"{tmp_path / 'test.yaml'}:2:9: error: Secret 'nope' not defined"] + + +def test_missing_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + CORE.config_path = tmp_path / "missing.yaml" + CORE.error_format = ErrorFormat.LINE + assert read_config({}) is None + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 1 + assert lines[0].startswith( + f"{tmp_path / 'missing.yaml'}: error: Error reading file " + ) + + +def test_source_trace_omitted( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\n" + "packages:\n p: !include ${ undefined_var }.yaml\n" + ), + }, + capsys, + ) + # The "In: packages->p in test.yaml 5:3" trace would repeat the location + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:5:3: error: Error including file " + "'${ undefined_var }.yaml': Cannot load include with unresolved " + "substitutions: ${ undefined_var }.yaml." + ), + ] + + +def test_print_line_message_skips_blank_lines( + capsys: pytest.CaptureFixture[str], +) -> None: + _print_line_message("a.yaml:1:1", "error", "first\n\n second \n") + assert capsys.readouterr().out.splitlines() == [ + "a.yaml:1:1: error: first", + "a.yaml:1:1: note: second", + ] + + +def test_reset_restores_default_format() -> None: + CORE.error_format = ErrorFormat.LINE + CORE.reset() + assert CORE.error_format is ErrorFormat.YAML + + +@pytest.mark.parametrize( + "error", + [ + KeyInvalid("Unable to find action", ["a", cv.ROOT_CONFIG_PATH, "b"]), + ExtraKeysInvalid( + "extra keys not allowed", + ["a", cv.ROOT_CONFIG_PATH, "b"], + candidates=["c"], + ), + ], +) +def test_add_error_keeps_error_class(error: KeyInvalid) -> None: + res = Config() + res.add_error(error) + (added,) = res.errors + assert type(added) is type(error) + assert added.path == ["b"] + assert getattr(added, "candidates", None) == getattr(error, "candidates", None) + + +def test_default_format_logs_load_error( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + CORE.config_path = tmp_path / "missing.yaml" + assert read_config({}) is None + assert capsys.readouterr().out == "" + assert "Error while reading config: Invalid YAML syntax" in caplog.text + + +def test_exception_as_error_message( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # "ms" with no number makes the time period validator raise Invalid(ValueError) + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\nsensor:\n - platform: template\n" + " name: foo\n update_interval: ms\n" + ) + }, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:7:22: error: " + "could not convert string to float: ''." + ) + ] + + +def _mark(line: int, column: int) -> yaml.Mark: + return yaml.Mark("a.yaml", 0, line, column, None, None) + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + ( + yaml.MarkedYAMLError( + problem="bad value", problem_mark=_mark(2, 1), note="a hint" + ), + ["a.yaml:3:2: error: bad value", "a.yaml:3:2: note: a hint"], + ), + # No problem or context: the whole error text is used, note included + ( + yaml.MarkedYAMLError(problem_mark=_mark(2, 1), note="a hint"), + [ + 'a.yaml:3:2: error: in "a.yaml", line 3, column 2', + "a.yaml:3:2: note: a hint", + ], + ), + ], +) +def test_marked_yaml_error_note( + capsys: pytest.CaptureFixture[str], + error: yaml.MarkedYAMLError, + expected: list[str], +) -> None: + _print_marked_yaml_error(error) + assert capsys.readouterr().out.splitlines() == expected + + +def test_error_without_range_gives_config_path( + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = Path("main.yaml") + res = Config() + res.add_error(vol.Invalid("bad value", ["sensor", 0, "name"])) + _print_line_errors(res) + assert capsys.readouterr().out.splitlines() == [ + "main.yaml: error: bad value.", + "main.yaml: note: In: sensor->0->name", + ] + + +def test_load_error_with_unprintable_cause( + capsys: pytest.CaptureFixture[str], +) -> None: + class Unprintable(Exception): + def __str__(self) -> str: + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + + CORE.config_path = Path("main.yaml") + _print_line_load_error(InvalidYAMLError(Unprintable())) + assert capsys.readouterr().out.splitlines() == ["main.yaml: error: Unprintable()"] diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 37d8e7486f4..3b8330f1591 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -115,6 +115,7 @@ from esphome.const import ( PLATFORM_HOST, PLATFORM_NRF52, PLATFORM_RP2, + ErrorFormat, Toolchain, ) from esphome.core import CORE, EsphomeError @@ -6462,6 +6463,62 @@ def test_parse_args_logs_states() -> None: assert args.states is True +@pytest.mark.parametrize( + ("argv", "env_value", "expected"), + [ + ([], None, ErrorFormat.YAML), + (["--error-format", "line"], None, ErrorFormat.LINE), + ([], "line", ErrorFormat.LINE), + (["--error-format", "yaml"], "line", ErrorFormat.YAML), + (["--error-format", "LINE"], None, ErrorFormat.LINE), + ([], "Line", ErrorFormat.LINE), + # The command line wins, so a bad environment value is not an error + (["--error-format", "line"], "bogus", ErrorFormat.LINE), + ], +) +def test_parse_args_error_format( + monkeypatch: pytest.MonkeyPatch, + argv: list[str], + env_value: str | None, + expected: ErrorFormat, +) -> None: + """--error-format overrides ESPHOME_ERROR_FORMAT, which overrides the default.""" + if env_value is None: + monkeypatch.delenv("ESPHOME_ERROR_FORMAT", raising=False) + else: + monkeypatch.setenv("ESPHOME_ERROR_FORMAT", env_value) + + args = parse_args(["esphome", *argv, "config", "device.yaml"]) + assert args.error_format is expected + + +@pytest.mark.parametrize( + ("argv", "env_value", "expected_name"), + [ + ([], "bogus", "ESPHOME_ERROR_FORMAT"), + (["--error-format", "bogus"], None, "--error-format"), + ], +) +def test_parse_args_error_format_invalid( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], + env_value: str | None, + expected_name: str, +) -> None: + """A bad value is reported against the setting that supplied it.""" + if env_value is None: + monkeypatch.delenv("ESPHOME_ERROR_FORMAT", raising=False) + else: + monkeypatch.setenv("ESPHOME_ERROR_FORMAT", env_value) + + with pytest.raises(SystemExit): + parse_args(["esphome", *argv, "config", "device.yaml"]) + err = capsys.readouterr().err + assert expected_name in err + assert "invalid choice: 'bogus' (choose from yaml, line)" in err + + @pytest.mark.parametrize("command", ["logs", "run"]) @pytest.mark.parametrize( ("env_value", "expected"), diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 7d401eaa653..178fd840203 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -192,3 +192,75 @@ def test_invalid_stays_plain() -> None: def test_format_unexpected_error_without_traceback() -> None: message = vscode._format_unexpected_error(ValueError("boom")) assert message == "Unexpected error while validating: ValueError: boom" + + +def test_reports_error_with_exception_message(): + source_path = str(Path("dir_path", "x.yaml")) + output_lines = _run_repl_test( + [ + _validate(source_path), + # read_file x.yaml + _file_response("""esphome: + name: test1 +host: +sensor: + - platform: template + name: foo + update_interval: ms # no number, so the validator raises Invalid(ValueError) +"""), + ] + ) + + error = json.loads(output_lines[-1]) + validation_error = error["validation_errors"][0] + assert validation_error["message"] == "could not convert string to float: ''." + assert validation_error["range"]["start_line"] == 6 + + +def test_unknown_action_range_is_the_key(): + source_path = str(Path("dir_path", "x.yaml")) + output_lines = _run_repl_test( + [ + _validate(source_path), + # read_file x.yaml + _file_response("""esphome: + name: test1 +host: +button: + - platform: template + on_press: + - loggr.log: "msg" +"""), + ] + ) + + error = json.loads(output_lines[-1]) + validation_error = error["validation_errors"][0] + assert ( + validation_error["message"] + == "Unable to find action with the name 'loggr.log'." + ) + assert validation_error["range"]["start_line"] == 6 + assert validation_error["range"]["start_col"] == 8 + + +def test_error_kept_when_range_lookup_fails(): + source_path = str(Path("dir_path", "x.yaml")) + with patch.object(vscode, "get_invalid_range", side_effect=RuntimeError("boom")): + output_lines = _run_repl_test( + [ + _validate(source_path), + # read_file x.yaml + _file_response("""esphome: + name: test1 +host: +logger: + levl: DEBUG +"""), + ] + ) + + error = json.loads(output_lines[-1]) + (validation_error,) = error["validation_errors"] + assert validation_error["range"] is None + assert "extra keys not allowed" in validation_error["message"] From e6da33d3f011996889385474a8103a2c3f8e1147 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 16:49:54 -1000 Subject: [PATCH 026/651] [const] Move the verify_ssl constant to the shared constants (#20501) --- esphome/components/audio_http/media_source.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/http_request/__init__.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 3c23517b92f..6c491379c89 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -3,6 +3,7 @@ from pathlib import Path import esphome.codegen as cg from esphome.components import audio, media_source, psram +from esphome.components.const import CONF_VERIFY_SSL import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM import esphome.final_validate as fv @@ -16,7 +17,6 @@ AUTO_LOAD = ["audio"] CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer" CONF_CA_CERTIFICATE_PATH = "ca_certificate_path" CONF_HTTP_REQUEST = "http_request" -CONF_VERIFY_SSL = "verify_ssl" audio_http_ns = cg.esphome_ns.namespace("audio_http") AudioHTTPMediaSource = audio_http_ns.class_( diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index eb999a05ff4..7160ef5f3a4 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -58,6 +58,7 @@ CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" +CONF_VERIFY_SSL = "verify_ssl" CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" CONF_VOLUME_INITIAL = "volume_initial" diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 92359714025..ad51d5f39be 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -4,7 +4,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32 -from esphome.components.const import CONF_REQUEST_HEADERS +from esphome.components.const import CONF_REQUEST_HEADERS, CONF_VERIFY_SSL from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -53,7 +53,6 @@ HttpRequestResponseTrigger = http_request_ns.class_( CONF_HTTP_REQUEST_ID = "http_request_id" CONF_USERAGENT = "useragent" -CONF_VERIFY_SSL = "verify_ssl" CONF_FOLLOW_REDIRECTS = "follow_redirects" CONF_REDIRECT_LIMIT = "redirect_limit" CONF_BUFFER_SIZE_RX = "buffer_size_rx" From 70e4463644fd35c5aba9e0f833b9cc944507d7c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 16:56:35 -1000 Subject: [PATCH 027/651] [const] Move the adc_time and adc_averaging constants to the shared constants (#20503) --- esphome/components/const/__init__.py | 2 ++ esphome/components/ina226/sensor.py | 3 +-- esphome/components/ina2xx_base/__init__.py | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 7160ef5f3a4..51a5e012102 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -8,6 +8,8 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" +CONF_ADC_AVERAGING = "adc_averaging" +CONF_ADC_TIME = "adc_time" CONF_ALLOWED_IPS = "allowed_ips" CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index af1793450c9..53427fc63e1 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -2,6 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, @@ -24,8 +25,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] -CONF_ADC_AVERAGING = "adc_averaging" -CONF_ADC_TIME = "adc_time" ina226_ns = cg.esphome_ns.namespace("ina226") INA226Component = ina226_ns.class_( diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 7fdfd90bb6f..c6239ef6d2e 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -2,7 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import sensor -from esphome.components.const import UNIT_AMPERE_HOUR +from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME, UNIT_AMPERE_HOUR import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, @@ -36,9 +36,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DOMAIN = "ina2xx_base" -CONF_ADC_AVERAGING = "adc_averaging" CONF_ADC_RANGE = "adc_range" -CONF_ADC_TIME = "adc_time" CONF_CHARGE = "charge" CONF_CHARGE_COULOMBS = "charge_coulombs" CONF_ENERGY_JOULES = "energy_joules" From 377a43c5be9889abce9d1c724713a38f15f62030 Mon Sep 17 00:00:00 2001 From: longerCZE <72692179+longerCZE@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:17:22 +0200 Subject: [PATCH 028/651] [toshiba] Add 'seiya' model support (#17830) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Signed-off-by: Petr Helebrant --- esphome/components/toshiba/climate.py | 1 + esphome/components/toshiba/toshiba.cpp | 285 ++++++++++++------ esphome/components/toshiba/toshiba.h | 20 +- tests/components/toshiba/__init__.py | 15 + tests/components/toshiba/benchmark.yaml | 4 + tests/components/toshiba/common_seiya.yaml | 6 + .../toshiba/test-seiya.esp32-idf.yaml | 4 + .../toshiba/test-seiya.esp8266-ard.yaml | 4 + tests/components/toshiba/toshiba_tests.cpp | 165 ++++++++++ 9 files changed, 411 insertions(+), 93 deletions(-) create mode 100644 tests/components/toshiba/__init__.py create mode 100644 tests/components/toshiba/benchmark.yaml create mode 100644 tests/components/toshiba/common_seiya.yaml create mode 100644 tests/components/toshiba/test-seiya.esp32-idf.yaml create mode 100644 tests/components/toshiba/test-seiya.esp8266-ard.yaml create mode 100644 tests/components/toshiba/toshiba_tests.cpp diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index e5f8544f2fe..962e6449f4a 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -16,6 +16,7 @@ MODELS = { "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, "RAS-2819T": Model.MODEL_RAS_2819T, + "SEIYA": Model.MODEL_SEIYA, } CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend( diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index f9f09a98bc4..c75496ad4ff 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1,5 +1,6 @@ #include "toshiba.h" #include "esphome/components/remote_base/toshiba_ac_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include @@ -163,6 +164,41 @@ const uint8_t RAS_2819T_AUTO_DRY_FAN_BYTE = 0x65; const uint8_t RAS_2819T_AUTO_DRY_SUFFIX = 0x3A; const uint8_t RAS_2819T_HEAT_SUFFIX = 0x3B; +// Bit timings differ per protocol family; the frame layout is shared +struct ToshibaTimings { + uint16_t header_mark; + uint16_t header_space; + uint16_t bit_mark; + uint16_t zero_space; + uint16_t one_space; + uint16_t gap_space; + bool gap_after_last; // Seiya ends on a bare mark and only puts the gap between repeats +}; +// Kept in flash on ESP8266, where plain const objects referenced through a function would land in RAM +static const ToshibaTimings TOSHIBA_TIMINGS PROGMEM = {TOSHIBA_HEADER_MARK, + TOSHIBA_HEADER_SPACE, + TOSHIBA_BIT_MARK, + TOSHIBA_ZERO_SPACE, + TOSHIBA_ONE_SPACE, + TOSHIBA_GAP_SPACE, + true}; +static const ToshibaTimings SEIYA_TIMINGS PROGMEM = {4630, 4450, 625, 490, 1570, 5830, false}; + +static ToshibaTimings timings_for(Model model) { + ToshibaTimings timings; + progmem_memcpy(&timings, model == MODEL_SEIYA ? &SEIYA_TIMINGS : &TOSHIBA_TIMINGS, sizeof(timings)); + return timings; +} + +// Seiya is the generic frame plus two bytes: a feature marker and a one-shot swing command +static constexpr uint8_t SEIYA_MESSAGE_LENGTH = 11; +static constexpr uint8_t SEIYA_FEATURE_MARKER = 0x21; +static constexpr uint8_t SEIYA_SWING_BOTH = 0x01; +static constexpr uint8_t SEIYA_SWING_OMNI = 0x02; // no ESPHome equivalent; treated as BOTH +static constexpr uint8_t SEIYA_SWING_VERTICAL = 0x08; +static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09; +static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame + // RAS-2819T temperature codes for 18-30°C static const uint8_t RAS_2819T_TEMP_CODES[] = { 0x10, // 18°C @@ -404,12 +440,15 @@ void ToshibaClimate::setup() { this->maximum_temperature_ = this->temperature_max_(); this->swing_modes_ = this->toshiba_swing_modes_(); - // Ensure swing mode is always initialized to a valid value + // Ensure swing mode is always initialized to a valid value. OFF also stands for "not yet + // commanded" on models that cannot switch the swing off, such as Seiya. if (this->swing_modes_.empty() || !this->swing_modes_.count(this->swing_mode)) { - // No swing support for this model or current swing mode not supported, reset to OFF this->swing_mode = climate::CLIMATE_SWING_OFF; } + // A restored swing mode is already in effect, so it must not go out as a fresh swing command + this->last_swing_mode_ = this->swing_mode; + // Ensure mode is valid - ESPHome should only use standard climate modes if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_HEAT && this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT_COOL && @@ -451,8 +490,9 @@ void ToshibaClimate::transmit_state() { } void ToshibaClimate::transmit_generic_() { + const bool seiya = this->model_ == MODEL_SEIYA; uint8_t message[16] = {0}; - uint8_t message_length = 9; + const uint8_t message_length = seiya ? SEIYA_MESSAGE_LENGTH : 9; // Header message[0] = 0xf2; @@ -473,6 +513,40 @@ void ToshibaClimate::transmit_generic_() { message[5] = (temperature - static_cast(TOSHIBA_GENERIC_TEMP_C_MIN)) << 4; // Mode and fan + message[6] = this->encode_mode_fan_(); + + // Zero + message[7] = 0x00; + + if (seiya) { + // Captured OFF frames carry no fan bits + if (this->mode == climate::CLIMATE_MODE_OFF) { + message[6] = TOSHIBA_MODE_OFF; + } + message[8] = SEIYA_FEATURE_MARKER; + // Swing is a momentary command, sent only when the mode changes, like the remote does + message[9] = SEIYA_SWING_NEUTRAL; + if (this->swing_mode != this->last_swing_mode_) { + message[9] = this->seiya_swing_code_(); + this->last_swing_mode_ = this->swing_mode; + } + } + + // The last byte is the xor of all bytes from [4] + for (uint8_t i = 4; i < message_length - 1; i++) { + message[message_length - 1] ^= message[i]; + } + + // Transmit + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + this->encode_(data, message, message_length, 1); + + transmit.perform(); +} + +uint8_t ToshibaClimate::encode_mode_fan_() const { uint8_t mode; switch (this->mode) { case climate::CLIMATE_MODE_OFF: @@ -523,27 +597,20 @@ void ToshibaClimate::transmit_generic_() { fan = TOSHIBA_FAN_SPEED_AUTO; break; } - message[6] = fan | mode; + return fan | mode; +} - // Zero - message[7] = 0x00; - - // If timers bit in the command is set, two extra bytes are added here - - // If power bit is set in the command, one extra byte is added here - - // The last byte is the xor of all bytes from [4] - for (uint8_t i = 4; i < 8; i++) { - message[8] ^= message[i]; +uint8_t ToshibaClimate::seiya_swing_code_() const { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + return SEIYA_SWING_VERTICAL; + case climate::CLIMATE_SWING_HORIZONTAL: + return SEIYA_SWING_HORIZONTAL; + case climate::CLIMATE_SWING_BOTH: + return SEIYA_SWING_BOTH; + default: + return SEIYA_SWING_NEUTRAL; } - - // Transmit - auto transmit = this->transmitter_->transmit(); - auto *data = transmit.get_data(); - - this->encode_(data, message, message_length, 1); - - transmit.perform(); } void ToshibaClimate::transmit_rac_pt1411hwru_() { @@ -1085,14 +1152,16 @@ bool ToshibaClimate::process_ras_2819t_command_(const remote_base::ToshibaAcData bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { // Try modern ToshibaAcProtocol decoder first (handles RAS-2819T and potentially others) - remote_base::ToshibaAcProtocol toshiba_protocol; - auto decode_result = toshiba_protocol.decode(data); + if (this->model_ != MODEL_SEIYA) { + remote_base::ToshibaAcProtocol toshiba_protocol; + auto decode_result = toshiba_protocol.decode(data); - if (decode_result.has_value()) { - auto toshiba_data = decode_result.value(); - // Validate and process RAS-2819T commands - if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) { - return this->process_ras_2819t_command_(toshiba_data); + if (decode_result.has_value()) { + auto toshiba_data = decode_result.value(); + // Validate and process RAS-2819T commands + if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) { + return this->process_ras_2819t_command_(toshiba_data); + } } } @@ -1101,7 +1170,8 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { uint8_t message_length = TOSHIBA_HEADER_LENGTH, temperature_code = 0; // Validate header - if (!data.expect_item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE)) { + const ToshibaTimings timings = timings_for(this->model_); + if (!data.expect_item(timings.header_mark, timings.header_space)) { return false; } // Read incoming bits into buffer @@ -1117,6 +1187,10 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } else { // First checksum was valid so continue receiving the remaining bits + // The shortest frame is 9 bytes (length byte 3); longer ones must still fit the buffer + if (message[2] < 3 || message[2] > sizeof(message) - TOSHIBA_HEADER_LENGTH - 2) { + return false; + } message_length = message[2] + 2; } // Decode the remaining bytes @@ -1276,59 +1350,11 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - // Get the mode - switch (message[6] & 0x0F) { - case TOSHIBA_MODE_OFF: - this->mode = climate::CLIMATE_MODE_OFF; - break; - - case TOSHIBA_MODE_COOL: - this->mode = climate::CLIMATE_MODE_COOL; - break; - - case TOSHIBA_MODE_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - - case TOSHIBA_MODE_FAN_ONLY: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - - case TOSHIBA_MODE_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - - case TOSHIBA_MODE_AUTO: - default: - this->mode = climate::CLIMATE_MODE_HEAT_COOL; + this->decode_mode_fan_temperature_(message); + // The marker byte tells a Seiya frame from another checksum-valid 11 byte frame within receiver tolerance + if (this->model_ == MODEL_SEIYA && message_length == SEIYA_MESSAGE_LENGTH && message[8] == SEIYA_FEATURE_MARKER) { + this->seiya_decode_swing_(message[9]); } - - // Get the fan mode - switch (message[6] & 0xF0) { - case TOSHIBA_FAN_SPEED_QUIET: - this->fan_mode = climate::CLIMATE_FAN_QUIET; - break; - - case TOSHIBA_FAN_SPEED_1: - this->fan_mode = climate::CLIMATE_FAN_LOW; - break; - - case TOSHIBA_FAN_SPEED_3: - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - break; - - case TOSHIBA_FAN_SPEED_5: - this->fan_mode = climate::CLIMATE_FAN_HIGH; - break; - - case TOSHIBA_FAN_SPEED_AUTO: - default: - this->fan_mode = climate::CLIMATE_FAN_AUTO; - break; - } - - // Get the target temperature - this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; } this->publish_state(); @@ -1337,31 +1363,36 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { void ToshibaClimate::encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, const uint8_t nbytes, const uint8_t repeat) { + const ToshibaTimings timings = timings_for(this->model_); data->set_carrier_frequency(TOSHIBA_CARRIER_FREQUENCY); for (uint8_t copy = 0; copy <= repeat; copy++) { - data->item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE); + data->item(timings.header_mark, timings.header_space); for (uint8_t byte = 0; byte < nbytes; byte++) { for (uint8_t bit = 0; bit < 8; bit++) { - data->mark(TOSHIBA_BIT_MARK); + data->mark(timings.bit_mark); if (message[byte] & (1 << (7 - bit))) { - data->space(TOSHIBA_ONE_SPACE); + data->space(timings.one_space); } else { - data->space(TOSHIBA_ZERO_SPACE); + data->space(timings.zero_space); } } } - data->item(TOSHIBA_BIT_MARK, TOSHIBA_GAP_SPACE); + data->mark(timings.bit_mark); + if (copy < repeat || timings.gap_after_last) { + data->space(timings.gap_space); + } } } bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *message, const uint8_t nbytes) { + const ToshibaTimings timings = timings_for(this->model_); for (uint8_t byte = 0; byte < nbytes; byte++) { for (uint8_t bit = 0; bit < 8; bit++) { - if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ONE_SPACE)) { + if (data->expect_item(timings.bit_mark, timings.one_space)) { message[byte] |= 1 << (7 - bit); - } else if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ZERO_SPACE)) { + } else if (data->expect_item(timings.bit_mark, timings.zero_space)) { message[byte] &= static_cast(~(1 << (7 - bit))); } else { return false; @@ -1371,4 +1402,80 @@ bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *mess return true; } +void ToshibaClimate::decode_mode_fan_temperature_(const uint8_t *message) { + switch (message[6] & 0x0F) { + case TOSHIBA_MODE_OFF: + this->mode = climate::CLIMATE_MODE_OFF; + break; + + case TOSHIBA_MODE_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + + case TOSHIBA_MODE_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + + case TOSHIBA_MODE_FAN_ONLY: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + + case TOSHIBA_MODE_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + + case TOSHIBA_MODE_AUTO: + default: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + } + + // Speeds 2 and 4 are not exposed; report the nearest mode + switch (message[6] & 0xF0) { + case TOSHIBA_FAN_SPEED_QUIET: + this->fan_mode = climate::CLIMATE_FAN_QUIET; + break; + + case TOSHIBA_FAN_SPEED_1: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + + case TOSHIBA_FAN_SPEED_2: + case TOSHIBA_FAN_SPEED_3: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + + case TOSHIBA_FAN_SPEED_4: + case TOSHIBA_FAN_SPEED_5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + + case TOSHIBA_FAN_SPEED_AUTO: + default: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; +} + +void ToshibaClimate::seiya_decode_swing_(uint8_t code) { + // A momentary command: the neutral value on an ordinary update means no change, not swing off + switch (code) { + case SEIYA_SWING_BOTH: + case SEIYA_SWING_OMNI: + this->swing_mode = climate::CLIMATE_SWING_BOTH; + break; + case SEIYA_SWING_VERTICAL: + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + break; + case SEIYA_SWING_HORIZONTAL: + this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + break; + default: + return; + } + // Keep the tracker in sync so the received command is not sent back out + this->last_swing_mode_ = this->swing_mode; +} + } // namespace esphome::toshiba diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index a853730f31a..f291077e525 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -11,6 +11,7 @@ enum Model { MODEL_RAC_PT1411HWRU_C = 1, // Temperature range is from 16 to 30 MODEL_RAC_PT1411HWRU_F = 2, // Temperature range is from 16 to 30 MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30 + MODEL_SEIYA = 4, // Seiya family (RAS-B13E2KVG-E etc.), generic frame with a swing byte }; // Supported temperature ranges @@ -50,7 +51,7 @@ class ToshibaClimate final : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; private: - // RAS-2819T state tracking for swing mode optimization + // Last swing mode sent or received, for models whose swing is a one-shot command climate::ClimateSwingMode last_swing_mode_{climate::CLIMATE_SWING_OFF}; climate::ClimateMode last_mode_{climate::CLIMATE_MODE_OFF}; optional last_fan_mode_{}; @@ -71,13 +72,24 @@ class ToshibaClimate final : public climate_ir::ClimateIR { return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } climate::ClimateSwingModeMask toshiba_swing_modes_() { - return (this->model_ == MODEL_GENERIC) - ? climate::ClimateSwingModeMask() - : climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; + if (this->model_ == MODEL_GENERIC) + return climate::ClimateSwingModeMask(); + if (this->model_ == MODEL_SEIYA) { + // No captured code stops the swing, so OFF is not offered; the remote only selects a direction + return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}; + } + return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; } void encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, uint8_t nbytes, uint8_t repeat); bool decode_(remote_base::RemoteReceiveData *data, uint8_t *message, uint8_t nbytes); + // Shared by every model that uses the F2 0D frame + uint8_t encode_mode_fan_() const; + void decode_mode_fan_temperature_(const uint8_t *message); + uint8_t seiya_swing_code_() const; + void seiya_decode_swing_(uint8_t code); + Model model_; }; diff --git a/tests/components/toshiba/__init__.py b/tests/components/toshiba/__init__.py new file mode 100644 index 00000000000..8a083978ece --- /dev/null +++ b/tests/components/toshiba/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. Otherwise the + # transmitter's code generation runs and the host build cannot link it. + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config): + # on_receive decodes the generic frame through the shared protocol class + cg.add_define("USE_REMOTE_PROTOCOL_TOSHIBA_AC") + + manifest.to_code = to_code_testing diff --git a/tests/components/toshiba/benchmark.yaml b/tests/components/toshiba/benchmark.yaml new file mode 100644 index 00000000000..ad4129e4979 --- /dev/null +++ b/tests/components/toshiba/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The toshiba, climate_ir and remote_transmitter sources come in through the +# dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/toshiba/common_seiya.yaml b/tests/components/toshiba/common_seiya.yaml new file mode 100644 index 00000000000..8cfd65301c4 --- /dev/null +++ b/tests/components/toshiba/common_seiya.yaml @@ -0,0 +1,6 @@ +climate: + - platform: toshiba + name: "Seiya Climate" + model: seiya + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/toshiba/test-seiya.esp32-idf.yaml b/tests/components/toshiba/test-seiya.esp32-idf.yaml new file mode 100644 index 00000000000..cc4956ffee5 --- /dev/null +++ b/tests/components/toshiba/test-seiya.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + toshiba: !include common_seiya.yaml diff --git a/tests/components/toshiba/test-seiya.esp8266-ard.yaml b/tests/components/toshiba/test-seiya.esp8266-ard.yaml new file mode 100644 index 00000000000..4664f0913b9 --- /dev/null +++ b/tests/components/toshiba/test-seiya.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + toshiba: !include common_seiya.yaml diff --git a/tests/components/toshiba/toshiba_tests.cpp b/tests/components/toshiba/toshiba_tests.cpp new file mode 100644 index 00000000000..528af888544 --- /dev/null +++ b/tests/components/toshiba/toshiba_tests.cpp @@ -0,0 +1,165 @@ +#include +#include +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/toshiba/toshiba.h" + +namespace esphome::toshiba::testing { + +struct Timings { + uint32_t header_mark; + uint32_t header_space; + uint32_t bit_mark; + uint32_t zero_space; + uint32_t one_space; + uint32_t gap_space; + bool gap_after_last; +}; + +static constexpr Timings GENERIC{4380, 4370, 540, 540, 1620, 5480, true}; +static constexpr Timings SEIYA{4630, 4450, 625, 490, 1570, 5830, false}; + +/// The waveform the component must produce for a frame: two copies, MSB first +static remote_base::RawTimings waveform(const std::vector &bytes, const Timings &t) { + remote_base::RawTimings raw; + for (int copy = 0; copy < 2; copy++) { + raw.push_back(t.header_mark); + raw.push_back(-static_cast(t.header_space)); + for (uint8_t byte : bytes) { + for (int bit = 7; bit >= 0; bit--) { + raw.push_back(t.bit_mark); + raw.push_back(-static_cast((byte >> bit) & 1 ? t.one_space : t.zero_space)); + } + } + raw.push_back(t.bit_mark); + if (copy == 0 || t.gap_after_last) { + raw.push_back(-static_cast(t.gap_space)); + } + } + return raw; +} + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +struct Fixture { + CapturingTransmitter transmitter; + ToshibaClimate climate; + + explicit Fixture(Model model) { + this->climate.set_model(model); + this->climate.set_transmitter(&this->transmitter); + this->climate.setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + optional swing = {}) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + if (swing.has_value()) { + call.set_swing_mode(*swing); + } + call.perform(); + } + + const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); } +}; + +/// Decodes the first copy of a frame back into bytes so a mismatch is readable +static std::string frame_bytes(const remote_base::RawTimings &raw, size_t nbytes) { + std::string out; + for (size_t byte = 0; byte < nbytes; byte++) { + unsigned value = 0; + for (size_t bit = 0; bit < 8; bit++) { + const size_t space = 3 + 2 * (byte * 8 + bit); + value = (value << 1) | (space < raw.size() && -raw[space] > 1000 ? 1 : 0); + } + char buf[4]; + snprintf(buf, sizeof(buf), "%02X ", value); + out += buf; + } + return out; +} + +// Reports the frame bytes instead of gtest's truncated vector dump +static void expect_frame(const remote_base::RawTimings &actual, const remote_base::RawTimings &expected, + size_t nbytes) { + EXPECT_EQ(actual, expected) << "actual " << frame_bytes(actual, nbytes) << "\nexpected " + << frame_bytes(expected, nbytes); +} + +TEST(ToshibaSeiya, EncodesCapturedFrames) { + Fixture f(MODEL_SEIYA); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x70, 0x01, 0x00, 0x21, 0x17, 0x46}, SEIYA), 11); + + f.command(climate::CLIMATE_MODE_OFF, 23, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x60, 0x07, 0x00, 0x21, 0x17, 0x50}, SEIYA), 11); + + f.command(climate::CLIMATE_MODE_HEAT, 22, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x03, 0x00, 0x21, 0x17, 0x64}, SEIYA), 11); + + // The swing code goes out once, when the swing mode changes + f.command(climate::CLIMATE_MODE_DRY, 22, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x08, 0x7A}, SEIYA), 11); + f.command(climate::CLIMATE_MODE_DRY, 23, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x60, 0x02, 0x00, 0x21, 0x17, 0x55}, SEIYA), 11); +} + +TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) { + Fixture f(MODEL_GENERIC); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x01, 0x00, 0x70}, GENERIC), 9); + + f.command(climate::CLIMATE_MODE_HEAT, 20, climate::CLIMATE_FAN_HIGH); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9); +} + +static bool receive(ToshibaClimate &climate, const std::vector &bytes, const Timings &t) { + const remote_base::RawTimings raw = waveform(bytes, t); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(climate).on_receive(data); +} + +TEST(ToshibaSeiya, DecodesStateAndKeepsSwingOnNeutralByte) { + Fixture f(MODEL_SEIYA); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + + // An ordinary update carries the neutral swing byte and must not touch the swing mode + EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x17, 0x65}, SEIYA)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_DRY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + // A direction code switches the swing mode + EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x09, 0x7B}, SEIYA)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_HORIZONTAL); + + // Without the feature marker the swing byte is not a Seiya swing command + EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x00, 0x01, 0x52}, SEIYA)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_HORIZONTAL); +} + +TEST(ToshibaSeiya, RejectsBadLengthByte) { + Fixture f(MODEL_SEIYA); + // Length byte 0x01 passes the header checksum but describes a frame shorter than any Toshiba frame + EXPECT_FALSE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x01, 0x50, 0x02}, SEIYA)); +} + +} // namespace esphome::toshiba::testing From fdf17523079b1a3beddaa4e5b5d04b40e4564913 Mon Sep 17 00:00:00 2001 From: RUEMUE Date: Sun, 11 Oct 2026 05:35:42 +0200 Subject: [PATCH 029/651] [toshiba] Add RAS-B10N3KV2 model support (#17550) Co-authored-by: Claude Fable 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Petr Helebrant Co-authored-by: longerCZE <72692179+longerCZE@users.noreply.github.com> Co-authored-by: J. Nick Koston Signed-off-by: Petr Helebrant --- esphome/components/toshiba/climate.py | 1 + esphome/components/toshiba/toshiba.cpp | 15 ++++++++++++++- esphome/components/toshiba/toshiba.h | 3 ++- tests/components/toshiba/common.yaml | 4 ++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 962e6449f4a..69fbb210305 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -16,6 +16,7 @@ MODELS = { "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, "RAS-2819T": Model.MODEL_RAS_2819T, + "RAS-B10N3KV2": Model.MODEL_RAS_B10N3KV2, "SEIYA": Model.MODEL_SEIYA, } diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index c75496ad4ff..57361a4a587 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -183,10 +183,23 @@ static const ToshibaTimings TOSHIBA_TIMINGS PROGMEM = {TOSHIBA_HEADER_MARK, TOSHIBA_GAP_SPACE, true}; static const ToshibaTimings SEIYA_TIMINGS PROGMEM = {4630, 4450, 625, 490, 1570, 5830, false}; +// The RAS-B13 remote family does not accept the generic timings; these are IRremoteESP8266's TOSHIBA_AC values +static const ToshibaTimings RAS_B10N3KV2_TIMINGS PROGMEM = {4400, 4300, 580, 490, 1600, 7400, true}; static ToshibaTimings timings_for(Model model) { + const ToshibaTimings *source = &TOSHIBA_TIMINGS; + switch (model) { + case MODEL_SEIYA: + source = &SEIYA_TIMINGS; + break; + case MODEL_RAS_B10N3KV2: + source = &RAS_B10N3KV2_TIMINGS; + break; + default: + break; + } ToshibaTimings timings; - progmem_memcpy(&timings, model == MODEL_SEIYA ? &SEIYA_TIMINGS : &TOSHIBA_TIMINGS, sizeof(timings)); + progmem_memcpy(&timings, source, sizeof(timings)); return timings; } diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index f291077e525..799f690f54c 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -12,6 +12,7 @@ enum Model { MODEL_RAC_PT1411HWRU_F = 2, // Temperature range is from 16 to 30 MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30 MODEL_SEIYA = 4, // Seiya family (RAS-B13E2KVG-E etc.), generic frame with a swing byte + MODEL_RAS_B10N3KV2 = 5, // RAS-B13 remote family, generic frame with IRremoteESP8266's TOSHIBA_AC timings }; // Supported temperature ranges @@ -72,7 +73,7 @@ class ToshibaClimate final : public climate_ir::ClimateIR { return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } climate::ClimateSwingModeMask toshiba_swing_modes_() { - if (this->model_ == MODEL_GENERIC) + if (this->model_ == MODEL_GENERIC || this->model_ == MODEL_RAS_B10N3KV2) return climate::ClimateSwingModeMask(); if (this->model_ == MODEL_SEIYA) { // No captured code stops the swing, so OFF is not offered; the remote only selects a direction diff --git a/tests/components/toshiba/common.yaml b/tests/components/toshiba/common.yaml index ba96c0628d8..2b37e989465 100644 --- a/tests/components/toshiba/common.yaml +++ b/tests/components/toshiba/common.yaml @@ -2,3 +2,7 @@ climate: - platform: toshiba name: Toshiba Climate transmitter_id: xmitr + - platform: toshiba + name: RAS-B10N3KV2 Climate + model: RAS-B10N3KV2 + transmitter_id: xmitr From 6bc19d2a1aafc38f0650d70d4ab0b22dc4b3aac9 Mon Sep 17 00:00:00 2001 From: Mathew Winstone Date: Sat, 10 Oct 2026 23:45:03 -0400 Subject: [PATCH 030/651] [espnow] Add support for ESPNow with ESP8266 (#14407) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/espnow/__init__.py | 31 +++- esphome/components/espnow/automation.h | 4 +- .../components/espnow/espnow_component.cpp | 167 +++++++----------- esphome/components/espnow/espnow_component.h | 35 ++-- esphome/components/espnow/espnow_err.h | 19 +- esphome/components/espnow/espnow_packet.h | 70 ++------ esphome/components/espnow/espnow_platform.h | 34 ++++ .../espnow/espnow_platform_esp32.cpp | 96 ++++++++++ .../espnow/espnow_platform_esp8266.cpp | 105 +++++++++++ esphome/components/espnow/espnow_types.h | 77 ++++++++ .../packet_transport/espnow_transport.cpp | 14 +- .../packet_transport/espnow_transport.h | 5 +- esphome/core/event_pool.h | 3 +- tests/components/espnow/common-esp32.yaml | 9 + tests/components/espnow/common.yaml | 11 +- .../espnow/test-wifi.esp8266-ard.yaml | 2 + tests/components/espnow/test.esp32-idf.yaml | 4 +- tests/components/espnow/test.esp8266-ard.yaml | 2 + tests/unit_tests/components/test_espnow.py | 53 +++++- 19 files changed, 523 insertions(+), 218 deletions(-) create mode 100644 esphome/components/espnow/espnow_platform.h create mode 100644 esphome/components/espnow/espnow_platform_esp32.cpp create mode 100644 esphome/components/espnow/espnow_platform_esp8266.cpp create mode 100644 esphome/components/espnow/espnow_types.h create mode 100644 tests/components/espnow/common-esp32.yaml create mode 100644 tests/components/espnow/test-wifi.esp8266-ard.yaml create mode 100644 tests/components/espnow/test.esp8266-ard.yaml diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index aef8fd17b56..e5abb16b1c3 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -17,6 +17,8 @@ from esphome.const import ( CONF_ON_ERROR, CONF_TRIGGER_ID, CONF_WIFI, + PLATFORM_ESP32, + PLATFORM_ESP8266, ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, TemplateArgsType @@ -24,7 +26,14 @@ import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] -AUTO_LOAD = ["network"] + + +def AUTO_LOAD() -> list[str]: + # network initialises esp_netif and the default event loop on the ESP32. The ESP8266 needs neither, and + # loading it there would pull in mdns, which requires wifi + return ["network"] if CORE.is_esp32 else [] + + DOMAIN = "espnow" peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) @@ -121,15 +130,21 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ADDRESS): cv.mac_address, } ), - cv.Optional(CONF_ON_BROADCAST): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastTrigger), - cv.Optional(CONF_ADDRESS): cv.mac_address, - } + # The ESP8266 SDK does not report the destination address, so broadcasts are indistinguishable there + cv.Optional(CONF_ON_BROADCAST): cv.All( + cv.only_on_esp32, + automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + OnBroadcastTrigger + ), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), ), }, ).extend(cv.COMPONENT_SCHEMA), - cv.only_on_esp32, + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), ) @@ -138,6 +153,8 @@ def _validate_variant(config: ConfigType) -> ConfigType: # ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that # supplies the esp_now_* symbols. Fail here with a clear message instead of # letting the build reach an "undefined reference to esp_now_*" link error. + if not CORE.is_esp32: + return config variant = get_esp32_variant() if wifi.variant_has_wifi(variant): return config diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 851b8345c94..88e240a646a 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "espnow_component.h" @@ -142,4 +142,4 @@ class OnBroadcastTrigger final : public Trigger #include +#include +#include #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include -#include -#include -#include -#include -#include -#include -#include - #ifdef USE_WIFI #include "esphome/components/wifi/wifi_component.h" #endif @@ -49,7 +43,7 @@ static const LogString *espnow_error_to_str(esp_err_t error) { case ESP_ERR_ESPNOW_INTERNAL: return LOG_STR("Internal Error"); case ESP_ERR_ESPNOW_NO_MEM: - return LOG_STR("Our of memory"); + return LOG_STR("Out of memory"); case ESP_ERR_ESPNOW_NOT_FOUND: return LOG_STR("Peer not found"); case ESP_ERR_ESPNOW_IF: @@ -57,96 +51,91 @@ static const LogString *espnow_error_to_str(esp_err_t error) { case ESP_OK: return LOG_STR("OK"); case ESP_NOW_SEND_FAIL: + case ESP_FAIL: return LOG_STR("Failed"); default: return LOG_STR("Unknown Error"); } } -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) -void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) -#else -void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) -#endif -{ +// The analyzer traces a leak on the failed-push path, which cannot happen: the +// pools are sized to the queue capacity (SIZE-1), so allocate() returns nullptr +// before push() can find the ring full. +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void ESPNowComponent::send_reported(const uint8_t *mac_addr, esp_now_send_status_t status) { // Allocate an event from the pool - ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + ESPNowPacket *packet = this->receive_packet_pool_.allocate(); if (packet == nullptr) { // No events available - queue is full or we're out of memory - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } -// Load new packet data (replaces previous packet) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - packet->load_sent_data(info->des_addr, status); -#else packet->load_sent_data(mac_addr, status); -#endif - // Push the packet to the queue - global_esp_now->receive_packet_queue_.push(packet); // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. + this->receive_packet_queue_.push(packet); // Re-enable and wake the main loop to process the ESP-NOW send event - global_esp_now->enable_loop_soon_any_context(); + this->enable_loop_soon_any_context(); } -void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { +void ESPNowComponent::packet_received(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, int size, + int8_t rssi, uint32_t timestamp) { // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), - // but the receive buffer only fits v2 frames with ``max_payload_size``; copying a + // but the receive buffer only fits frames up to ``max_payload_size``; copying a // larger frame would overflow packet_.receive.data. - if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + if (size < 0 || static_cast(size) > ESPNOW_MAX_DATA_LEN) { + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } // Allocate an event from the pool - ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + ESPNowPacket *packet = this->receive_packet_pool_.allocate(); if (packet == nullptr) { // No events available - queue is full or we're out of memory - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } - // Load new packet data (replaces previous packet) - packet->load_received_data(info, data, size); + packet->load_received_data(src_addr, des_addr, data, size, rssi, timestamp); - // Push the packet to the queue - global_esp_now->receive_packet_queue_.push(packet); // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. + this->receive_packet_queue_.push(packet); // Re-enable and wake the main loop to process the ESP-NOW receive event - global_esp_now->enable_loop_soon_any_context(); + this->enable_loop_soon_any_context(); } +// NOLINTEND(clang-analyzer-unix.Malloc) ESPNowComponent::ESPNowComponent() { global_esp_now = this; } void ESPNowComponent::dump_config() { ESP_LOGCONFIG(TAG, "espnow:"); // Only report driver details once enabled; with enable_on_boot: false the - // Wi-Fi driver is not initialized yet and esp_now_get_version() would crash, + // Wi-Fi driver is not initialized yet and the version call would crash, // and after a failed enable_() the values would be meaningless. if (this->state_ != ESPNOW_STATE_ENABLED) { // OFF here means enable_() failed; the core logs the FAILED marker separately ESP_LOGCONFIG(TAG, " %s", this->is_disabled() ? LOG_STR_LITERAL("Disabled") : LOG_STR_LITERAL("Not enabled")); return; } - uint32_t version = 0; - esp_now_get_version(&version); char own_addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(this->own_address_, own_addr_buf); ESP_LOGCONFIG(TAG, " Own address: %s\n" - " Version: v%" PRIu32 "\n" " Wi-Fi channel: %d", - own_addr_buf, version, this->wifi_channel_); + own_addr_buf, this->wifi_channel_); + uint32_t version = platform::get_version(); + if (version != 0) { + ESP_LOGCONFIG(TAG, " Version: v%" PRIu32, version); + } #ifdef USE_WIFI ESP_LOGCONFIG(TAG, " Wi-Fi enabled: %s", YESNO(this->is_wifi_enabled())); #endif @@ -183,6 +172,10 @@ void ESPNowComponent::on_wifi_connect_state(StringRef ssid, std::spanwifi_channel_ != old_channel) { ESP_LOGI(TAG, "WiFi channel changed from %d to %d", old_channel, this->wifi_channel_); } + // Peers added while the station was scanning were bound to the channel of that moment, so rebind on every connect + if (this->state_ == ESPNOW_STATE_ENABLED) { + platform::rebind_peers(); + } } #endif @@ -198,41 +191,19 @@ void ESPNowComponent::enable() { void ESPNowComponent::enable_() { if (!this->is_wifi_enabled()) { - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - - ESP_ERROR_CHECK(esp_wifi_init(&cfg)); - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); - ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); - ESP_ERROR_CHECK(esp_wifi_start()); - ESP_ERROR_CHECK(esp_wifi_disconnect()); - + platform::init_radio(); this->apply_wifi_channel(); } this->get_wifi_channel(); - esp_err_t err = esp_now_init(); + esp_err_t err = platform::init(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_init failed: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Init failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } - err = esp_now_register_recv_cb(on_data_received); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - err = esp_now_register_send_cb(on_send_report); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); + platform::read_mac(this->own_address_); this->state_ = ESPNOW_STATE_ENABLED; @@ -248,12 +219,9 @@ void ESPNowComponent::disable() { ESP_LOGD(TAG, "Disabling"); this->state_ = ESPNOW_STATE_DISABLED; - esp_now_unregister_recv_cb(); - esp_now_unregister_send_cb(); - - esp_err_t err = esp_now_deinit(); + esp_err_t err = platform::deinit(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_deinit failed! 0x%x", err); + ESP_LOGE(TAG, "Deinit failed: %s", esp_err_to_name(err)); } } @@ -271,9 +239,10 @@ void ESPNowComponent::apply_wifi_channel() { } ESP_LOGI(TAG, "Channel set to %d.", this->wifi_channel_); - esp_wifi_set_promiscuous(true); - esp_wifi_set_channel(this->wifi_channel_, WIFI_SECOND_CHAN_NONE); - esp_wifi_set_promiscuous(false); + platform::set_channel(this->wifi_channel_); + if (this->state_ == ESPNOW_STATE_ENABLED) { + platform::rebind_peers(); + } } void ESPNowComponent::loop() { @@ -283,7 +252,7 @@ void ESPNowComponent::loop() { switch (packet->type_) { case ESPNowPacket::RECEIVED: { const ESPNowRecvInfo info = packet->get_receive_info(); - if (!esp_now_is_peer_exist(info.src_addr)) { + if (!platform::peer_exists(info.src_addr)) { bool handled = false; for (auto *handler : this->unknown_peer_handlers_) { if (handler->on_unknown_peer(info, packet->packet_.receive.data, packet->packet_.receive.size)) { @@ -296,7 +265,7 @@ void ESPNowComponent::loop() { } } // Intentionally left as if instead of else in case the peer is added above - if (esp_now_is_peer_exist(info.src_addr)) { + if (platform::peer_exists(info.src_addr)) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char src_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char dst_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -309,7 +278,8 @@ void ESPNowComponent::loop() { format_hex_pretty_to(hex_buf, packet->packet_.receive.data, std::min(packet->packet_.receive.size, ESP_NOW_MAX_DATA_LEN))); #endif - if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + // Without a destination address every packet goes to the receive handlers + if (ESPNOW_REPORTS_DESTINATION && memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { for (auto *handler : this->broadcast_handlers_) { if (handler->on_broadcast(info, packet->packet_.receive.data, packet->packet_.receive.size)) break; // If a handler returns true, stop processing further handlers @@ -374,24 +344,25 @@ void ESPNowComponent::loop() { } uint8_t ESPNowComponent::get_wifi_channel() { - wifi_second_chan_t dummy; - esp_wifi_get_channel(&this->wifi_channel_, &dummy); + this->wifi_channel_ = platform::get_channel(); return this->wifi_channel_; } +// Same analyzer false positive as above: the send pool is sized to the send queue's capacity +// NOLINTBEGIN(clang-analyzer-unix.Malloc) esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payload, size_t size, - const send_callback_t &callback) { + send_callback_t callback) { if (this->state_ != ESPNOW_STATE_ENABLED) { return ESP_ERR_ESPNOW_NOT_INIT; } else if (this->is_failed()) { return ESP_ERR_ESPNOW_FAILED; - } else if (peer_address == 0ULL) { + } else if (peer_address == nullptr) { return ESP_ERR_ESPNOW_PEER_NOT_SET; } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { return ESP_ERR_ESPNOW_OWN_ADDRESS; } else if (size > ESPNOW_MAX_DATA_LEN) { return ESP_ERR_ESPNOW_DATA_SIZE; - } else if (!esp_now_is_peer_exist(peer_address)) { + } else if (!platform::peer_exists(peer_address)) { if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { esp_err_t err = this->add_peer(peer_address); if (err != ESP_OK) { @@ -410,7 +381,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl return ESP_ERR_ESPNOW_NO_MEM; } // Load the packet data - packet->load_data(peer_address, payload, size, callback); + packet->load_data(peer_address, payload, size, std::move(callback)); // Push the packet to the send queue this->send_packet_queue_.push(packet); // Loop may be disabled while idle; re-enable it to send the packet @@ -418,6 +389,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl this->enable_loop_soon_any_context(); return ESP_OK; } +// NOLINTEND(clang-analyzer-unix.Malloc) void ESPNowComponent::send_() { ESPNowSendPacket *packet = this->send_packet_queue_.pop(); @@ -426,7 +398,7 @@ void ESPNowComponent::send_() { } this->current_send_packet_ = packet; - esp_err_t err = esp_now_send(packet->address_, packet->data_, packet->size_); + esp_err_t err = platform::send(packet->address_, packet->data_, packet->size_); if (err != ESP_OK) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(packet->address_, addr_buf); @@ -451,13 +423,8 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { return ESP_ERR_INVALID_MAC; } - if (!esp_now_is_peer_exist(peer)) { - esp_now_peer_info_t peer_info = {}; - memset(&peer_info, 0, sizeof(esp_now_peer_info_t)); - peer_info.ifidx = WIFI_IF_STA; - memcpy(peer_info.peer_addr, peer, ESP_NOW_ETH_ALEN); - esp_err_t err = esp_now_add_peer(&peer_info); - + if (!platform::peer_exists(peer)) { + esp_err_t err = platform::add_peer(peer); if (err != ESP_OK) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); @@ -486,8 +453,8 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { return ESP_ERR_ESPNOW_NOT_INIT; } - if (esp_now_is_peer_exist(peer)) { - esp_err_t err = esp_now_del_peer(peer); + if (platform::peer_exists(peer)) { + esp_err_t err = platform::del_peer(peer); if (err != ESP_OK) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); @@ -507,4 +474,4 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index 375bd4faefe..b912233eed0 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -4,21 +4,17 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/event_pool.h" #include "esphome/core/lock_free_queue.h" #include "espnow_packet.h" +#include "espnow_types.h" #if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) #include "esphome/components/wifi/wifi_component.h" #endif -#include - -#include -#include - #include #include #include @@ -27,10 +23,6 @@ namespace esphome::espnow { -// Maximum size of the ESPNow event queue - must be power of 2 for lock-free queue -static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; -static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; - using peer_address_t = std::array; enum class ESPNowTriggers : uint8_t { @@ -152,12 +144,10 @@ class ESPNowComponent final : public Component { /// @param payload Data payload to send /// @param callback Callback to call when the send operation is complete /// @return ESP_OK on success, or an error code on failure - esp_err_t send(const uint8_t *peer_address, const std::vector &payload, - const send_callback_t &callback = nullptr) { - return this->send(peer_address, payload.data(), payload.size(), callback); + esp_err_t send(const uint8_t *peer_address, const std::vector &payload, send_callback_t callback = nullptr) { + return this->send(peer_address, payload.data(), payload.size(), std::move(callback)); } - esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, - const send_callback_t &callback = nullptr); + esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, send_callback_t callback = nullptr); void register_receive_handler(ESPNowReceivedPacketHandler *handler) { this->receive_handlers_.push_back(handler); } void register_unknown_peer_handler(ESPNowUnknownPeerHandler *handler) { @@ -165,14 +155,13 @@ class ESPNowComponent final : public Component { } void register_broadcast_handler(ESPNowBroadcastHandler *handler) { this->broadcast_handlers_.push_back(handler); } - protected: - friend void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - friend void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status); -#else - friend void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status); -#endif + // Entry points for the radio driver callbacks in espnow_platform_*.cpp; they run outside the main loop and + // only queue the event + void packet_received(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, int size, int8_t rssi, + uint32_t timestamp); + void send_reported(const uint8_t *mac_addr, esp_now_send_status_t status); + protected: void enable_(); void send_(); @@ -205,4 +194,4 @@ extern ESPNowComponent *global_esp_now; // NOLINT(cppcoreguidelines-avoid-non-c } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_err.h b/esphome/components/espnow/espnow_err.h index ceda1b7683e..8db38b800ef 100644 --- a/esphome/components/espnow/espnow_err.h +++ b/esphome/components/espnow/espnow_err.h @@ -1,19 +1,18 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) -#include -#include +#include "espnow_types.h" namespace esphome::espnow { -static const esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); -static const esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); -static const esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); -static const esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); -static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); -static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); +static constexpr esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); +static constexpr esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); +static constexpr esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); +static constexpr esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); +static constexpr esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); +static constexpr esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index fb125864fb6..d9f4a73f931 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -1,19 +1,17 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "espnow_err.h" +#include "espnow_types.h" #include #include #include #include +#include #include -#include -#include -#include - namespace esphome::espnow { static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; @@ -36,16 +34,10 @@ static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN, "espnow max_payload_size beyond 250 bytes requires an ESP-IDF with ESP-NOW v2 support (5.4+)"); #endif -struct WifiPacketRxControl { - int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm - uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or - // light sleep is not enabled -}; - struct ESPNowRecvInfo { uint8_t src_addr[ESP_NOW_ETH_ALEN]; /**< Source address of ESPNOW packet */ uint8_t des_addr[ESP_NOW_ETH_ALEN]; /**< Destination address of ESPNOW packet */ - wifi_pkt_rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ + rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ }; using send_callback_t = std::function; @@ -58,34 +50,28 @@ class ESPNowPacket { SENT, }; - // Constructor for received data - ESPNowPacket(const esp_now_recv_info_t *info, const uint8_t *data, int size) { - this->init_received_data_(info, data, size); - }; - -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - // Constructor for sent data - ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { - this->init_sent_data_(info->src_addr, status); - } -#else - // Constructor for sent data - ESPNowPacket(const uint8_t *mac_addr, esp_now_send_status_t status) { this->init_sent_data_(mac_addr, status); } -#endif - // Default constructor for pre-allocation in pool ESPNowPacket() {} void release() {} - void load_received_data(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + void load_received_data(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, uint16_t size, + int8_t rssi, uint32_t timestamp) { this->type_ = RECEIVED; - this->init_received_data_(info, data, size); + memcpy(this->packet_.receive.info.src_addr, src_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.info.des_addr, des_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.data, data, size); + this->packet_.receive.size = size; + + this->packet_.receive.rx_ctrl.rssi = rssi; + this->packet_.receive.rx_ctrl.timestamp = timestamp; + this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); } void load_sent_data(const uint8_t *mac_addr, esp_now_send_status_t status) { this->type_ = SENT; - this->init_sent_data_(mac_addr, status); + memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); + this->packet_.sent.status = status; } // Disable copy to prevent double-delete @@ -112,24 +98,6 @@ class ESPNowPacket { esp_now_packet_type_t type() const { return this->type_; } const ESPNowRecvInfo &get_receive_info() const { return this->packet_.receive.info; } - - private: - void init_received_data_(const esp_now_recv_info_t *info, const uint8_t *data, int size) { - memcpy(this->packet_.receive.info.src_addr, info->src_addr, ESP_NOW_ETH_ALEN); - memcpy(this->packet_.receive.info.des_addr, info->des_addr, ESP_NOW_ETH_ALEN); - memcpy(this->packet_.receive.data, data, size); - this->packet_.receive.size = size; - - this->packet_.receive.rx_ctrl.rssi = info->rx_ctrl->rssi; - this->packet_.receive.rx_ctrl.timestamp = info->rx_ctrl->timestamp; - - this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); - } - - void init_sent_data_(const uint8_t *mac_addr, esp_now_send_status_t status) { - memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); - this->packet_.sent.status = status; - } }; class ESPNowSendPacket { @@ -151,9 +119,9 @@ class ESPNowSendPacket { ESPNowSendPacket(const ESPNowSendPacket &) = delete; ESPNowSendPacket &operator=(const ESPNowSendPacket &) = delete; - void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback) { + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, send_callback_t &&callback) { this->init_data_(peer_address, payload, size); - this->callback_ = callback; + this->callback_ = std::move(callback); } void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size) { @@ -180,4 +148,4 @@ class ESPNowSendPacket { } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_platform.h b/esphome/components/espnow/espnow_platform.h new file mode 100644 index 00000000000..bc19f34a7cd --- /dev/null +++ b/esphome/components/espnow/espnow_platform.h @@ -0,0 +1,34 @@ +#pragma once + +#if defined(USE_ESP32) || defined(USE_ESP8266) + +#include "espnow_types.h" + +#include + +// The radio and ESP-NOW driver calls that differ between ESP-IDF and the ESP8266 NONOS SDK. Each platform +// implements the whole set in its own source file, so the component itself carries no platform checks. +namespace esphome::espnow::platform { + +// Bring up the station interface when no wifi component owns the radio +void init_radio(); +void set_channel(uint8_t channel); +uint8_t get_channel(); +void read_mac(uint8_t *mac); + +// Start the driver and route its callbacks to the component +esp_err_t init(); +esp_err_t deinit(); +uint32_t get_version(); // 0 where the SDK has no version call + +bool peer_exists(const uint8_t *mac); +esp_err_t add_peer(const uint8_t *mac); +esp_err_t del_peer(const uint8_t *mac); +// Move the registered peers to the radio's current channel; only the NONOS SDK binds peers to a channel +void rebind_peers(); + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size); + +} // namespace esphome::espnow::platform + +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_platform_esp32.cpp b/esphome/components/espnow/espnow_platform_esp32.cpp new file mode 100644 index 00000000000..b45c09b072e --- /dev/null +++ b/esphome/components/espnow/espnow_platform_esp32.cpp @@ -0,0 +1,96 @@ +#include "espnow_platform.h" + +#ifdef USE_ESP32 + +#include "espnow_component.h" + +#include +#include +#include + +#include + +namespace esphome::espnow::platform { + +static void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + global_esp_now->packet_received(info->src_addr, info->des_addr, data, size, info->rx_ctrl->rssi, + info->rx_ctrl->timestamp); +} + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +static void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) { + global_esp_now->send_reported(info->des_addr, status); +} +#else +static void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) { + global_esp_now->send_reported(mac_addr, status); +} +#endif + +void init_radio() { + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_ERROR_CHECK(esp_wifi_disconnect()); +} + +void set_channel(uint8_t channel) { + esp_wifi_set_promiscuous(true); + esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + esp_wifi_set_promiscuous(false); +} + +uint8_t get_channel() { + uint8_t channel = 0; + wifi_second_chan_t second; + esp_wifi_get_channel(&channel, &second); + return channel; +} + +void read_mac(uint8_t *mac) { esp_wifi_get_mac(WIFI_IF_STA, mac); } + +esp_err_t init() { + esp_err_t err = esp_now_init(); + if (err == ESP_OK) { + err = esp_now_register_recv_cb(on_data_received); + } + if (err == ESP_OK) { + err = esp_now_register_send_cb(on_send_report); + } + return err; +} + +esp_err_t deinit() { + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + return esp_now_deinit(); +} + +uint32_t get_version() { + uint32_t version = 0; + esp_now_get_version(&version); + return version; +} + +bool peer_exists(const uint8_t *mac) { return esp_now_is_peer_exist(mac); } + +esp_err_t add_peer(const uint8_t *mac) { + esp_now_peer_info_t peer_info = {}; + peer_info.ifidx = WIFI_IF_STA; + memcpy(peer_info.peer_addr, mac, ESP_NOW_ETH_ALEN); + return esp_now_add_peer(&peer_info); +} + +esp_err_t del_peer(const uint8_t *mac) { return esp_now_del_peer(mac); } + +// Peers are added on channel 0, which ESP-IDF treats as whatever channel the radio is on +void rebind_peers() {} + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size) { return esp_now_send(mac, data, size); } + +} // namespace esphome::espnow::platform + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_platform_esp8266.cpp b/esphome/components/espnow/espnow_platform_esp8266.cpp new file mode 100644 index 00000000000..736e8788375 --- /dev/null +++ b/esphome/components/espnow/espnow_platform_esp8266.cpp @@ -0,0 +1,105 @@ +#include "espnow_platform.h" + +#ifdef USE_ESP8266 + +#include "espnow_component.h" + +namespace esphome::espnow::platform { + +// The SDK reports 0 for success and a non-zero value for any failure +static esp_err_t sdk_result(int result) { return result == 0 ? ESP_OK : ESP_FAIL; } + +// Stands in for the destination address, which the SDK does not report +static const uint8_t UNKNOWN_ADDR[ESP_NOW_ETH_ALEN] = {0}; + +static void on_data_received(uint8_t *mac_addr, uint8_t *data, uint8_t size) { + global_esp_now->packet_received(mac_addr, UNKNOWN_ADDR, data, size, 0, 0); +} + +static void on_send_report(uint8_t *mac_addr, uint8_t status) { + global_esp_now->send_reported(mac_addr, status == 0 ? ESP_NOW_SEND_SUCCESS : ESP_NOW_SEND_FAIL); +} + +void init_radio() { + // The NONOS SDK only delivers broadcast frames to ESP-NOW while the station is associated or the + // soft-AP interface is up. Without Wi-Fi, run a hidden, open soft-AP that accepts no clients so + // broadcasts (for example from a WizMote style remote) are received. + softap_config ap{}; + ap.channel = 1; + ap.authmode = AUTH_OPEN; + ap.ssid_hidden = 1; + ap.max_connection = 0; + ap.beacon_interval = 60000; + wifi_set_opmode_current(STATIONAP_MODE); + wifi_softap_set_config_current(&ap); + wifi_softap_dhcps_stop(); + wifi_set_sleep_type(NONE_SLEEP_T); + wifi_station_disconnect(); +} + +void set_channel(uint8_t channel) { + wifi_promiscuous_enable(true); + wifi_set_channel(channel); + wifi_promiscuous_enable(false); + // Keep the soft-AP from init_radio() on the same channel so the SDK never retunes the radio to its own + softap_config ap{}; + if (wifi_get_opmode() == STATIONAP_MODE && wifi_softap_get_config(&ap) && ap.channel != channel) { + ap.channel = channel; + wifi_softap_set_config_current(&ap); + } +} + +uint8_t get_channel() { return wifi_get_channel(); } + +void read_mac(uint8_t *mac) { + // Frames leave from the station interface unless the device runs as a soft-AP only + wifi_get_macaddr(wifi_get_opmode() == SOFTAP_MODE ? SOFTAP_IF : STATION_IF, mac); +} + +esp_err_t init() { + int result = esp_now_init(); + if (result == 0) { + // CONTROLLER sends from the station interface; the other roles send from the soft-AP MAC whenever + // that interface is up, so peers would no longer recognise the sender. + result = esp_now_set_self_role(ESP_NOW_ROLE_CONTROLLER); + } + if (result == 0) { + result = esp_now_register_recv_cb(on_data_received); + } + if (result == 0) { + result = esp_now_register_send_cb(on_send_report); + } + return sdk_result(result); +} + +esp_err_t deinit() { + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + return sdk_result(esp_now_deinit()); +} + +uint32_t get_version() { return 0; } + +// The SDK returns 1 when the peer exists, 0 when it does not and a negative value on error +bool peer_exists(const uint8_t *mac) { return esp_now_is_peer_exist(const_cast(mac)) > 0; } + +esp_err_t add_peer(const uint8_t *mac) { + return sdk_result(esp_now_add_peer(const_cast(mac), ESP_NOW_ROLE_COMBO, wifi_get_channel(), nullptr, 0)); +} + +esp_err_t del_peer(const uint8_t *mac) { return sdk_result(esp_now_del_peer(const_cast(mac))); } + +void rebind_peers() { + const uint8_t channel = wifi_get_channel(); + for (uint8_t *mac = esp_now_fetch_peer(true); mac != nullptr; mac = esp_now_fetch_peer(false)) { + esp_now_set_peer_channel(mac, channel); + } +} + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size) { + return sdk_result(esp_now_send(const_cast(mac), const_cast(data), size)); +} + +} // namespace esphome::espnow::platform + +#endif // USE_ESP8266 diff --git a/esphome/components/espnow/espnow_types.h b/esphome/components/espnow/espnow_types.h new file mode 100644 index 00000000000..4b9c65fecf3 --- /dev/null +++ b/esphome/components/espnow/espnow_types.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include + +#if defined(USE_ESP32) + +#include +#include + +#elif defined(USE_ESP8266) + +extern "C" { +#include +#include +#include +} + +#endif + +namespace esphome::espnow { + +struct WifiPacketRxControl { + int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm + uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or + // light sleep is not enabled +}; + +#if defined(USE_ESP32) + +// Handlers read rx_ctrl through the ESP-IDF type so existing lambdas keep compiling +using rx_ctrl_t = wifi_pkt_rx_ctrl_t; +static constexpr bool ESPNOW_REPORTS_DESTINATION = true; + +// Maximum size of the ESPNow event queues - must be a power of 2 for the lock-free queue +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; + +#elif defined(USE_ESP8266) + +// The NONOS SDK has no esp_err_t vocabulary. These are the ESP-IDF names and values the shared espnow code +// uses, so it reads the same on both platforms. +using esp_err_t = int32_t; + +static constexpr esp_err_t ESP_OK = 0; +static constexpr esp_err_t ESP_FAIL = -1; +static constexpr esp_err_t ESP_ERR_INVALID_MAC = 0x10B; +static constexpr esp_err_t ESP_ERR_ESPNOW_BASE = 0x3064; +static constexpr esp_err_t ESP_ERR_ESPNOW_NOT_INIT = ESP_ERR_ESPNOW_BASE + 1; +static constexpr esp_err_t ESP_ERR_ESPNOW_ARG = ESP_ERR_ESPNOW_BASE + 2; +static constexpr esp_err_t ESP_ERR_ESPNOW_NO_MEM = ESP_ERR_ESPNOW_BASE + 3; +static constexpr esp_err_t ESP_ERR_ESPNOW_NOT_FOUND = ESP_ERR_ESPNOW_BASE + 5; +static constexpr esp_err_t ESP_ERR_ESPNOW_INTERNAL = ESP_ERR_ESPNOW_BASE + 6; +static constexpr esp_err_t ESP_ERR_ESPNOW_IF = ESP_ERR_ESPNOW_BASE + 8; + +inline const char *esp_err_to_name(esp_err_t err) { return err == ESP_OK ? "ESP_OK" : "ESP_FAIL"; } + +static constexpr uint8_t ESP_NOW_ETH_ALEN = 6; +static constexpr uint8_t ESP_NOW_MAX_DATA_LEN = 250; + +// NOLINTNEXTLINE(readability-identifier-naming) +enum esp_now_send_status_t : uint8_t { + ESP_NOW_SEND_SUCCESS = 0, + ESP_NOW_SEND_FAIL = 1, +}; + +using rx_ctrl_t = WifiPacketRxControl; +// The SDK reports neither the destination address nor the signal strength of a received frame +static constexpr bool ESPNOW_REPORTS_DESTINATION = false; + +// Half the ESP32 depth: the heap is far smaller and only one send is ever in flight +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 8; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 8; + +#endif + +} // namespace esphome::espnow diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 7053229db42..93bc181a185 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -1,10 +1,12 @@ #include "espnow_transport.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/application.h" #include "esphome/core/log.h" +#include + namespace esphome::espnow { ESPHOME_LOG_TAG(TAG, "espnow.transport"); @@ -64,9 +66,7 @@ bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data return false; } - this->packet_buffer_.resize(size); - memcpy(this->packet_buffer_.data(), data, size); - this->process_(this->packet_buffer_); + this->process_(std::span(data, size)); return false; // Allow other handlers to run } @@ -79,12 +79,10 @@ bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *da return false; } - this->packet_buffer_.resize(size); - memcpy(this->packet_buffer_.data(), data, size); - this->process_(this->packet_buffer_); + this->process_(std::span(data, size)); return false; // Allow other handlers to run } } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 51069b64153..79938f8df2e 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -2,7 +2,7 @@ #include "../espnow_component.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/component.h" #include "esphome/components/packet_transport/packet_transport.h" @@ -33,9 +33,8 @@ class ESPNowTransport final : public packet_transport::PacketTransport, bool should_send() override; peer_address_t peer_address_{{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}; - std::vector packet_buffer_; }; } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index b53b8064a3d..77b47228a97 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -1,6 +1,7 @@ #pragma once -#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) || \ + defined(USE_ESP8266) #include #include diff --git a/tests/components/espnow/common-esp32.yaml b/tests/components/espnow/common-esp32.yaml new file mode 100644 index 00000000000..a97a5ef8aeb --- /dev/null +++ b/tests/components/espnow/common-esp32.yaml @@ -0,0 +1,9 @@ +espnow: + max_payload_size: 1470 + on_broadcast: + - logger.log: + format: "Broadcast from: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 973781fb68b..54b089116f7 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -2,7 +2,6 @@ espnow: id: espnow_component auto_add_peer: false channel: 1 - max_payload_size: 1470 peers: - 11:22:33:44:55:66 on_receive: @@ -50,13 +49,6 @@ espnow: - espnow.set_channel: 6 - espnow.set_channel: channel: !lambda 'return 6;' - on_broadcast: - - logger.log: - format: "Broadcast from: %s = '%s' RSSI: %d" - args: - - format_mac_address_pretty(info.src_addr).c_str() - - format_hex_pretty(data, size).c_str() - - info.rx_ctrl->rssi on_unknown_peer: - logger.log: format: "Unknown peer: %s = '%s' RSSI: %d" @@ -80,8 +72,9 @@ packet_transport: key: "0123456789abcdef0123456789abcdef" sensor: - - platform: internal_temperature + - platform: template id: espnow_temp_sensor + lambda: return 21.5; - platform: packet_transport transport_id: transport1 diff --git a/tests/components/espnow/test-wifi.esp8266-ard.yaml b/tests/components/espnow/test-wifi.esp8266-ard.yaml new file mode 100644 index 00000000000..c45547cd533 --- /dev/null +++ b/tests/components/espnow/test-wifi.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + espnow: !include common-wifi.yaml diff --git a/tests/components/espnow/test.esp32-idf.yaml b/tests/components/espnow/test.esp32-idf.yaml index b47e39c3898..1b3ba941b6b 100644 --- a/tests/components/espnow/test.esp32-idf.yaml +++ b/tests/components/espnow/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + espnow: !include common.yaml + espnow_esp32: !include common-esp32.yaml diff --git a/tests/components/espnow/test.esp8266-ard.yaml b/tests/components/espnow/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ea7d1f197e1 --- /dev/null +++ b/tests/components/espnow/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + espnow: !include common.yaml diff --git a/tests/unit_tests/components/test_espnow.py b/tests/unit_tests/components/test_espnow.py index 21305c2b332..762e660139c 100644 --- a/tests/unit_tests/components/test_espnow.py +++ b/tests/unit_tests/components/test_espnow.py @@ -7,16 +7,29 @@ from esphome.components.esp32.const import ( VARIANT_ESP32H2, VARIANT_ESP32P4, ) -from esphome.components.espnow import _validate_variant +from esphome.components.espnow import CONFIG_SCHEMA, _validate_variant import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType def _run( - monkeypatch, variant: str, full_config: dict, config: ConfigType + monkeypatch, + variant: str, + full_config: dict, + config: ConfigType, + platform: str = PLATFORM_ESP32, ) -> ConfigType: monkeypatch.setattr("esphome.components.espnow.get_esp32_variant", lambda: variant) + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: platform}) token = fv.full_config.set(full_config) try: return _validate_variant(config) @@ -46,3 +59,39 @@ def test_p4_with_esp32_hosted_passes(monkeypatch) -> None: """The P4 with esp32_hosted present validates; config passes through.""" config = {"id": "espnow"} assert _run(monkeypatch, VARIANT_ESP32P4, {"esp32_hosted": {}}, config) is config + + +def test_esp8266_skips_variant_check(monkeypatch) -> None: + """The ESP8266 has one Wi-Fi radio and no variants, so the check does not run.""" + config = {"id": "espnow"} + assert _run(monkeypatch, "", {}, config, platform=PLATFORM_ESP8266) is config + + +def _esp8266_arduino(monkeypatch) -> None: + monkeypatch.setitem( + CORE.data, + KEY_CORE, + {KEY_TARGET_PLATFORM: PLATFORM_ESP8266, KEY_TARGET_FRAMEWORK: "arduino"}, + ) + + +def test_esp8266_accepts_the_v1_payload_size(monkeypatch) -> None: + _esp8266_arduino(monkeypatch) + assert ( + CONFIG_SCHEMA({"channel": 1, "max_payload_size": 250})["max_payload_size"] + == 250 + ) + + +def test_esp8266_rejects_v2_payload_sizes(monkeypatch) -> None: + """The NONOS SDK only speaks ESP-NOW v1, so larger frames are refused at validation.""" + _esp8266_arduino(monkeypatch) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"channel": 1, "max_payload_size": 251}) + + +def test_esp8266_rejects_on_broadcast(monkeypatch) -> None: + """The NONOS SDK does not report a destination address, so broadcasts cannot be told apart.""" + _esp8266_arduino(monkeypatch) + with pytest.raises(cv.Invalid, match="only available on"): + CONFIG_SCHEMA({"channel": 1, "on_broadcast": []}) From 254690100c4768e592017c47550fbf8dbcce69ef Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Sat, 10 Oct 2026 20:52:24 -0700 Subject: [PATCH 031/651] [hub75] add set_rotation and get_rotation methods (#13921) Co-authored-by: J. Nick Koston --- esphome/components/display/display.h | 4 +- esphome/components/hub75/display.py | 6 +-- esphome/components/hub75/hub75.cpp | 40 +++++++++++++++++++ esphome/components/hub75/hub75_component.h | 5 ++- .../hub75/test.esp32-s3-idf-rotate.yaml | 7 ++++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index cc75eb70b8f..74b5bcedd94 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -294,7 +294,7 @@ using display_writer_t = DisplayWriter; prefix type "\n" \ "%s Rotations: %d °\n" \ "%s Dimensions: %dpx x %dpx", \ - prefix, log_display_obj->rotation_, prefix, log_display_obj->get_width(), \ + prefix, log_display_obj->get_rotation(), prefix, log_display_obj->get_width(), \ log_display_obj->get_height()); \ } @@ -710,7 +710,7 @@ class Display : public PollingComponent { // Internal method to set display auto clearing. void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } - DisplayRotation get_rotation() const { return this->rotation_; } + virtual DisplayRotation get_rotation() const { return this->rotation_; } /** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays, * returns the type the display is currently configured to. diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 08d068d0f03..823928176a1 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -601,10 +601,8 @@ async def to_code(config: ConfigType) -> None: pins_struct = _build_pins_struct(pin_expressions, e_pin_num) hub75_config = _build_config_struct(config, pins_struct, min_refresh) - # Rotation is handled by the hub75 driver (config_.rotation already set above). - # Force rotation to 0 for ESPHome's Display base class to avoid double-rotation. - if CONF_ROTATION in config: - config[CONF_ROTATION] = 0 + # The driver rotates the panel itself (config_.rotation above), so the base class must not rotate too + config.pop(CONF_ROTATION, None) # Create display and register var = cg.new_Pvariable(config[CONF_ID], hub75_config) diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index 674e23cb344..226b59a2912 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -202,6 +202,46 @@ void HUB75Display::set_brightness(uint8_t brightness) { } } +static Hub75Rotation to_hub75_rotation(display::DisplayRotation rotation) { + switch (rotation) { + case display::DisplayRotation::DISPLAY_ROTATION_90_DEGREES: + return Hub75Rotation::ROTATE_90; + case display::DisplayRotation::DISPLAY_ROTATION_180_DEGREES: + return Hub75Rotation::ROTATE_180; + case display::DisplayRotation::DISPLAY_ROTATION_270_DEGREES: + return Hub75Rotation::ROTATE_270; + default: + return Hub75Rotation::ROTATE_0; + } +} + +static display::DisplayRotation to_display_rotation(Hub75Rotation rotation) { + switch (rotation) { + case Hub75Rotation::ROTATE_90: + return display::DisplayRotation::DISPLAY_ROTATION_90_DEGREES; + case Hub75Rotation::ROTATE_180: + return display::DisplayRotation::DISPLAY_ROTATION_180_DEGREES; + case Hub75Rotation::ROTATE_270: + return display::DisplayRotation::DISPLAY_ROTATION_270_DEGREES; + default: + return display::DisplayRotation::DISPLAY_ROTATION_0_DEGREES; + } +} + +// Before setup() creates the driver the configuration holds the rotation, afterwards the driver does +void HUB75Display::set_rotation(display::DisplayRotation rotation) { + const Hub75Rotation hub75_rotation = to_hub75_rotation(rotation); + if (this->driver_ == nullptr) { + this->config_.rotation = hub75_rotation; + return; + } + this->driver_->set_rotation(hub75_rotation); +} + +display::DisplayRotation HUB75Display::get_rotation() const { + return to_display_rotation(this->driver_ == nullptr ? this->config_.rotation : this->driver_->get_rotation()); +} + } // namespace esphome::hub75 #endif diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index dbadc56c9df..122e8960fb0 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -36,6 +36,9 @@ class HUB75Display final : public display::Display { // Brightness control (runtime mutable) void set_brightness(uint8_t brightness); + void set_rotation(display::DisplayRotation rotation) override; + display::DisplayRotation get_rotation() const override; + protected: // Display internal methods int get_width_internal() override { return this->driver_ != nullptr ? this->driver_->get_width() : 0; } @@ -43,7 +46,7 @@ class HUB75Display final : public display::Display { // Member variables Hub75Driver *driver_{nullptr}; - Hub75Config config_; // Immutable configuration + Hub75Config config_; // Fixed after setup(); only the rotation can change before the driver exists // Runtime state (mutable) uint8_t brightness_{128}; diff --git a/tests/components/hub75/test.esp32-s3-idf-rotate.yaml b/tests/components/hub75/test.esp32-s3-idf-rotate.yaml index 9855fcb4e69..1a08549a2fe 100644 --- a/tests/components/hub75/test.esp32-s3-idf-rotate.yaml +++ b/tests/components/hub75/test.esp32-s3-idf-rotate.yaml @@ -37,3 +37,10 @@ display: } <<: !include common.yaml + +script: + - id: rotate_hub75 + then: + - lambda: |- + id(my_hub75).set_rotation(display::DISPLAY_ROTATION_180_DEGREES); + ESP_LOGD("test", "rotation now %d", static_cast(id(my_hub75).get_rotation())); From cb54f6d6cc3ac77e023c36a2f3664ba64a79af57 Mon Sep 17 00:00:00 2001 From: opcod3 Date: Sun, 11 Oct 2026 11:13:21 +0200 Subject: [PATCH 032/651] [daikin] Add presets and half-degree temperature steps (#17891) Co-authored-by: James Nimmo Co-authored-by: J. Nick Koston --- esphome/components/daikin/daikin.cpp | 130 ++++- esphome/components/daikin/daikin.h | 40 +- tests/components/daikin/__init__.py | 8 + tests/components/daikin/benchmark.yaml | 4 + tests/components/daikin/common.yaml | 11 +- .../daikin/daikin_regression_tests.cpp | 477 ++++++++++++++++++ tests/components/daikin/daikin_tests.cpp | 440 ++++++++++++++++ tests/components/daikin/test.esp32-idf.yaml | 4 + tests/components/daikin/test.esp8266-ard.yaml | 4 +- 9 files changed, 1084 insertions(+), 34 deletions(-) create mode 100644 tests/components/daikin/__init__.py create mode 100644 tests/components/daikin/benchmark.yaml create mode 100644 tests/components/daikin/daikin_regression_tests.cpp create mode 100644 tests/components/daikin/daikin_tests.cpp create mode 100644 tests/components/daikin/test.esp32-idf.yaml diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 7a1310b9ac1..0194b34db09 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -1,25 +1,47 @@ #include "daikin.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" namespace esphome::daikin { ESPHOME_LOG_TAG(TAG, "daikin.climate"); +static constexpr uint32_t BOOST_TIMEOUT_ID = 0; + +static uint8_t sum_bytes(const uint8_t *data, size_t len) { + uint8_t sum = 0; + for (size_t i = 0; i < len; i++) { + sum += data[i]; + } + return sum; +} + +void DaikinClimate::setup() { + climate_ir::ClimateIR::setup(); + // A boost preset restored from flash gets its timer too + this->update_boost_timer_(false); +} + +void DaikinClimate::control(const climate::ClimateCall &call) { + const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST; + climate_ir::ClimateIR::control(call); + this->update_boost_timer_(was_boost); +} + void DaikinClimate::transmit_state() { - uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7, 0x11, 0xDA, 0x27, 0x00, + uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0x00, 0x11, 0xDA, 0x27, 0x00, 0x42, 0x49, 0x05, 0xA2, 0x11, 0xDA, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00}; + this->set_presets_(remote_state); remote_state[21] = this->operation_mode_(); remote_state[22] = this->temperature_(); uint16_t fan_speed = this->fan_speed_(); remote_state[24] = fan_speed >> 8; remote_state[25] = fan_speed & 0xff; - // Calculate checksum - for (int i = 16; i < 34; i++) { - remote_state[34] += remote_state[i]; - } + remote_state[7] = sum_bytes(remote_state, 7); + remote_state[34] = sum_bytes(remote_state + 16, 18); auto transmit = this->transmitter_->transmit(); auto *data = transmit.get_data(); @@ -136,18 +158,52 @@ uint8_t DaikinClimate::temperature_() const { case climate::CLIMATE_MODE_DRY: return 0xc0; default: - uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX)); - return temperature << 1; + return static_cast( + roundf(clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX) * 2.0f)); } } -bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; +void DaikinClimate::set_presets_(uint8_t *remote_state) const { + switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { + case climate::CLIMATE_PRESET_BOOST: + remote_state[29] = DAIKIN_PRESET_POWERFUL_ON; + break; + case climate::CLIMATE_PRESET_SLEEP: + remote_state[29] = DAIKIN_PRESET_QUIET_ON; + break; + case climate::CLIMATE_PRESET_ECO: + remote_state[32] = DAIKIN_PRESET_ECONO_ON; + break; + case climate::CLIMATE_PRESET_ACTIVITY: + remote_state[32] = DAIKIN_PRESET_SENSOR_ON; + break; + case climate::CLIMATE_PRESET_COMFORT: + remote_state[6] = DAIKIN_PRESET_COMFORT_ON; // the only preset carried by the first frame + break; + default: + break; } - if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) - return false; +} + +void DaikinClimate::update_boost_timer_(bool was_boost) { + const bool is_boost = this->preset == climate::CLIMATE_PRESET_BOOST; + if (is_boost && !was_boost) { + this->set_timeout(BOOST_TIMEOUT_ID, DAIKIN_BOOST_DURATION_MS, [this]() { this->cancel_boost_mode_(); }); + } else if (was_boost && !is_boost) { + this->cancel_timeout(BOOST_TIMEOUT_ID); + } +} + +void DaikinClimate::cancel_boost_mode_() { + if (this->preset != climate::CLIMATE_PRESET_BOOST) + return; + ESP_LOGD(TAG, "Boost ended"); + this->preset = climate::CLIMATE_PRESET_NONE; + this->publish_state(); +} + +bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { + const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST; uint8_t mode = frame[5]; // Temperature is given in degrees celcius * 2 // only update for states that use the temperature @@ -206,16 +262,41 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { this->fan_mode = climate::CLIMATE_FAN_QUIET; break; } + + // Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid + // A comfort frame whose own state frame never came must not attach to a later transmission + const uint32_t now = App.get_loop_component_start_time(); + const PendingComfort comfort = + now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS ? this->pending_comfort_ : PendingComfort::UNKNOWN; + this->pending_comfort_ = PendingComfort::UNKNOWN; + if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) { + this->preset = climate::CLIMATE_PRESET_BOOST; + } else if (frame[13] & DAIKIN_PRESET_QUIET_ON) { + this->preset = climate::CLIMATE_PRESET_SLEEP; + } else if (frame[16] & DAIKIN_PRESET_ECONO_ON) { + this->preset = climate::CLIMATE_PRESET_ECO; + } else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) { + this->preset = climate::CLIMATE_PRESET_ACTIVITY; + } else if (comfort == PendingComfort::ON) { + this->preset = climate::CLIMATE_PRESET_COMFORT; + } else if (comfort == PendingComfort::OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { + // Without a comfort frame in this transmission a current comfort preset is left alone + this->preset = climate::CLIMATE_PRESET_NONE; + } + this->update_boost_timer_(was_boost); + this->publish_state(); return true; } bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE] = {}; + uint8_t frame[DAIKIN_STATE_FRAME_SIZE] = {}; if (!data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) { return false; } - for (uint8_t pos = 0; pos < DAIKIN_STATE_FRAME_SIZE; pos++) { + + uint8_t frame_size = DAIKIN_STATE_FRAME_SIZE; + for (uint8_t pos = 0; pos < frame_size; pos++) { uint8_t byte = 0; for (int8_t bit = 0; bit < 8; bit++) { if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) { @@ -224,7 +305,7 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } } - state_frame[pos] = byte; + frame[pos] = byte; if (pos == 0) { // frame header if (byte != 0x11) @@ -243,11 +324,24 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } else if (pos == 4) { // frame type - if (byte != 0x00) + if (byte == DAIKIN_FRAME_TYPE_COMFORT) { + frame_size = DAIKIN_COMFORT_FRAME_SIZE; + } else if (byte != DAIKIN_FRAME_TYPE_STATE) { return false; + } } } - return this->parse_state_frame_(state_frame); + if (frame[frame_size - 1] != sum_bytes(frame, frame_size - 1)) { + return false; + } + + if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) { + // Nothing changes yet: the state frame that follows applies it once it has passed its own checksum + this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::ON : PendingComfort::OFF; + this->pending_comfort_at_ = App.get_loop_component_start_time(); + return true; + } + return this->parse_state_frame_(frame); } } // namespace esphome::daikin diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index b7fa9923076..7f8638101d5 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -27,6 +27,17 @@ const uint8_t DAIKIN_FAN_3 = 0x50; const uint8_t DAIKIN_FAN_4 = 0x60; const uint8_t DAIKIN_FAN_5 = 0x70; +// Presets: bit flags in byte 6 (comfort), byte 29 (powerful, quiet) and byte 32 (sensor, econo) +const uint8_t DAIKIN_PRESET_COMFORT_ON = 0x10; +const uint8_t DAIKIN_PRESET_POWERFUL_ON = 0x01; +const uint8_t DAIKIN_PRESET_QUIET_ON = 0x20; +const uint8_t DAIKIN_PRESET_ECONO_ON = 0x04; +const uint8_t DAIKIN_PRESET_SENSOR_ON = 0x02; +// The unit leaves powerful mode by itself after 20 minutes; mirror that in the entity +const uint32_t DAIKIN_BOOST_DURATION_MS = 20 * 60 * 1000; +// The three frames of one transmission arrive well inside this window +const uint32_t DAIKIN_TRANSMISSION_MS = 1000; + // IR Transmission const uint32_t DAIKIN_IR_FREQUENCY = 38000; const uint32_t DAIKIN_HEADER_MARK = 3360; @@ -36,27 +47,44 @@ const uint32_t DAIKIN_ONE_SPACE = 1370; const uint32_t DAIKIN_ZERO_SPACE = 360; const uint32_t DAIKIN_MESSAGE_SPACE = 32300; -// State Frame size +// Frame types (byte 4) and sizes +const uint8_t DAIKIN_FRAME_TYPE_STATE = 0x00; +const uint8_t DAIKIN_FRAME_TYPE_COMFORT = 0xC5; const uint8_t DAIKIN_STATE_FRAME_SIZE = 19; +const uint8_t DAIKIN_COMFORT_FRAME_SIZE = 8; class DaikinClimate final : public climate_ir::ClimateIR { public: DaikinClimate() - : climate_ir::ClimateIR(DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, - climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}, - {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, - climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {} + : climate_ir::ClimateIR( + DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 0.5f, true, true, + {climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}, + {climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_COMFORT, + climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_ACTIVITY, climate::CLIMATE_PRESET_NONE}) {} protected: + void setup() override; + void control(const climate::ClimateCall &call) override; // Transmit via IR the state of this climate controller. void transmit_state() override; uint8_t operation_mode_() const; uint16_t fan_speed_() const; uint8_t temperature_() const; + void set_presets_(uint8_t *remote_state) const; + /// Arms or cancels the boost timer when the preset moves into or out of boost. + void update_boost_timer_(bool was_boost); + void cancel_boost_mode_(); // Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); + + /// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame + enum class PendingComfort : uint8_t { UNKNOWN, OFF, ON }; + PendingComfort pending_comfort_{PendingComfort::UNKNOWN}; + uint32_t pending_comfort_at_{0}; }; } // namespace esphome::daikin diff --git a/tests/components/daikin/__init__.py b/tests/components/daikin/__init__.py new file mode 100644 index 00000000000..c0e24478320 --- /dev/null +++ b/tests/components/daikin/__init__.py @@ -0,0 +1,8 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. Otherwise the + # transmitter's code generation runs and the host build cannot link it. + manifest.auto_load = ["climate_ir", "climate"] diff --git a/tests/components/daikin/benchmark.yaml b/tests/components/daikin/benchmark.yaml new file mode 100644 index 00000000000..640cc34b136 --- /dev/null +++ b/tests/components/daikin/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The daikin, climate_ir and remote_transmitter sources come in through the +# dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/daikin/common.yaml b/tests/components/daikin/common.yaml index fd738416862..acca04faf4a 100644 --- a/tests/components/daikin/common.yaml +++ b/tests/components/daikin/common.yaml @@ -1,9 +1,4 @@ climate: - - platform: heatpumpir - protocol: daikin - horizontal_default: middle - vertical_default: middle - name: HeatpumpIR Climate - min_temperature: 18 - max_temperature: 30 - transmitter_id: xmitr + - platform: daikin + name: Daikin AC + receiver_id: rcvr diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp new file mode 100644 index 00000000000..fd695b9ca48 --- /dev/null +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -0,0 +1,477 @@ +// Regression tests for the behaviour the daikin climate had before presets were added. +// Every expected byte is worked out by hand from the original daikin.cpp, and this file +// only uses the API that both the original and the current component provide. +#include +#include +#include +#include +#include + +#include "esphome/components/daikin/daikin.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome::daikin::regression { + +static constexpr size_t MESSAGE_SIZE = 35; +using Message = std::array; + +static constexpr int32_t HEADER_MARK = 3360; +static constexpr int32_t HEADER_SPACE = 1760; +static constexpr int32_t BIT_MARK = 520; +static constexpr int32_t ONE_SPACE = 1370; +static constexpr int32_t ZERO_SPACE = 360; +static constexpr int32_t MESSAGE_SPACE = 32300; +static constexpr uint32_t CARRIER = 38000; + +// Sum of the constant bytes 16..33 of the third frame (11 DA 27 00 00 .. 06 60 00 00 C0 00 00), +// 0x238 truncated to 8 bits. Byte 34 is this plus bytes 21, 22, 24 and 25. +static constexpr uint8_t FRAME3_CONSTANT_SUM = 0x38; + +struct Capture { + remote_base::RawTimings raw; + uint32_t carrier; +}; + +class RecordingTransmitter : public remote_base::RemoteTransmitterBase { + public: + RecordingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector sent; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->sent.push_back({this->temp_.get_data(), this->temp_.get_carrier_frequency()}); + } +}; + +static void read_bytes(const remote_base::RawTimings &raw, size_t &pos, uint8_t *out, size_t count) { + for (size_t byte = 0; byte < count; byte++) { + uint8_t value = 0; + for (int bit = 0; bit < 8; bit++) { + EXPECT_EQ(raw[pos], BIT_MARK) << "bit mark at " << pos; + const int32_t space = -raw[pos + 1]; + if (space == ONE_SPACE) { + value |= 1 << bit; + } else { + EXPECT_EQ(space, ZERO_SPACE) << "bit space at " << pos + 1; + } + pos += 2; + } + out[byte] = value; + } +} + +static void expect_pair(const remote_base::RawTimings &raw, size_t &pos, int32_t mark, int32_t space) { + EXPECT_EQ(raw[pos], mark) << "mark at " << pos; + EXPECT_EQ(raw[pos + 1], -space) << "space at " << pos + 1; + pos += 2; +} + +/// Decodes the three frames (8, 8 and 19 bytes) and checks every header, bit and gap timing +static Message decode(const remote_base::RawTimings &raw) { + Message msg{}; + // 3 headers + 3 trailers of two timings each, plus two timings per bit + constexpr size_t expected_size = 3 * 4 + 2 * 8 * MESSAGE_SIZE; + EXPECT_EQ(raw.size(), expected_size); + if (raw.size() != expected_size) + return msg; + size_t pos = 0; + expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE); + read_bytes(raw, pos, msg.data(), 8); + expect_pair(raw, pos, BIT_MARK, MESSAGE_SPACE); + expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE); + read_bytes(raw, pos, msg.data() + 8, 8); + expect_pair(raw, pos, BIT_MARK, MESSAGE_SPACE); + expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE); + read_bytes(raw, pos, msg.data() + 16, 19); + expect_pair(raw, pos, BIT_MARK, 0); + return msg; +} + +/// Receiver timings for a 19-byte frame +static remote_base::RawTimings timings_for(const std::vector &bytes) { + remote_base::RawTimings raw; + raw.push_back(HEADER_MARK); + raw.push_back(-HEADER_SPACE); + for (uint8_t byte : bytes) { + for (int bit = 0; bit < 8; bit++) { + raw.push_back(BIT_MARK); + raw.push_back(-((byte >> bit) & 1 ? ONE_SPACE : ZERO_SPACE)); + } + } + raw.push_back(BIT_MARK); + raw.push_back(-MESSAGE_SPACE); + return raw; +} + +/// A 19-byte state frame (type 0x00) with its checksum in byte 18 +static std::vector state_frame(uint8_t mode, uint8_t temperature, uint8_t fan, uint8_t swing) { + std::vector f{0x11, 0xDA, 0x27, 0x00, 0x00, mode, temperature, 0x00, fan, swing, + 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00}; + uint8_t total = 0; + for (size_t i = 0; i < 18; i++) + total += f[i]; + f[18] = total; + return f; +} + +struct Rig { + RecordingTransmitter transmitter; + DaikinClimate climate; + + Rig() { + // All instances share one preference key; clear it so setup starts from defaults + if (global_preferences != nullptr) + global_preferences->reset(); + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_daikin_regression"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + Message send(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + climate::ClimateSwingMode swing) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.set_swing_mode(swing); + call.perform(); + EXPECT_FALSE(this->transmitter.sent.empty()); + if (this->transmitter.sent.empty()) + return Message{}; + return decode(this->transmitter.sent.back().raw); + } + + bool receive(const std::vector &bytes) { + const remote_base::RawTimings raw = timings_for(bytes); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + return static_cast(this->climate).on_receive(data); + } +}; + +/// Checks every byte that the original encoder never changes, and both checksums +static void expect_fixed_bytes(const Message &m) { + const uint8_t frame1[8] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7}; + const uint8_t frame2[8] = {0x11, 0xDA, 0x27, 0x00, 0x42, 0x49, 0x05, 0xA2}; + for (size_t i = 0; i < 8; i++) { + EXPECT_EQ(m[i], frame1[i]) << "byte " << i; + EXPECT_EQ(m[8 + i], frame2[i]) << "byte " << 8 + i; + } + EXPECT_EQ(m[16], 0x11); + EXPECT_EQ(m[17], 0xDA); + EXPECT_EQ(m[18], 0x27); + EXPECT_EQ(m[19], 0x00); + EXPECT_EQ(m[20], 0x00); + EXPECT_EQ(m[23], 0x00); + EXPECT_EQ(m[26], 0x00); + EXPECT_EQ(m[27], 0x06); + EXPECT_EQ(m[28], 0x60); + EXPECT_EQ(m[29], 0x00); + EXPECT_EQ(m[30], 0x00); + EXPECT_EQ(m[31], 0xC0); + EXPECT_EQ(m[32], 0x00); + EXPECT_EQ(m[33], 0x00); + // 0x11 + 0xDA + 0x27 + 0xC5 = 0x1D7 + EXPECT_EQ(m[7], 0xD7); + const uint8_t expected34 = FRAME3_CONSTANT_SUM + m[21] + m[22] + m[24] + m[25]; + EXPECT_EQ(m[34], expected34); +} + +struct ModeCase { + climate::ClimateMode mode; + uint8_t byte21; + uint8_t byte22; + uint8_t byte34; +}; + +// At 24 degrees, fan auto (0xA0, 0x00), swing off: +// OFF 0x00, 24 << 1 = 0x30, 0x38 + 0x00 + 0x30 + 0xA0 = 0x108 -> 0x08 +// COOL 0x31, 0x30, 0x38 + 0x31 + 0x30 + 0xA0 = 0x139 -> 0x39 +// HEAT 0x41, 0x30, 0x38 + 0x41 + 0x30 + 0xA0 = 0x149 -> 0x49 +// DRY 0x21, 0xC0, 0x38 + 0x21 + 0xC0 + 0xA0 = 0x1B9 -> 0xB9 +// FAN_ONLY 0x61, 0x32, 0x38 + 0x61 + 0x32 + 0xA0 = 0x16B -> 0x6B +// HEAT_COOL 0x01, 0x30, 0x38 + 0x01 + 0x30 + 0xA0 = 0x109 -> 0x09 +static const ModeCase MODE_CASES[] = { + {climate::CLIMATE_MODE_OFF, 0x00, 0x30, 0x08}, {climate::CLIMATE_MODE_COOL, 0x31, 0x30, 0x39}, + {climate::CLIMATE_MODE_HEAT, 0x41, 0x30, 0x49}, {climate::CLIMATE_MODE_DRY, 0x21, 0xC0, 0xB9}, + {climate::CLIMATE_MODE_FAN_ONLY, 0x61, 0x32, 0x6B}, {climate::CLIMATE_MODE_HEAT_COOL, 0x01, 0x30, 0x09}, +}; + +TEST(DaikinRegression, EncodesEveryMode) { + for (const auto &c : MODE_CASES) { + Rig r; + const Message m = r.send(c.mode, 24.0f, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + SCOPED_TRACE(static_cast(c.mode)); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], c.byte21); + EXPECT_EQ(m[22], c.byte22); + EXPECT_EQ(m[24], 0xA0); + EXPECT_EQ(m[25], 0x00); + EXPECT_EQ(m[34], c.byte34); + } +} + +struct FanCase { + climate::ClimateFanMode fan; + uint8_t b24; + uint8_t byte34; +}; + +// Cool at 24 (0x31, 0x30), swing off: b34 = 0x38 + 0x31 + 0x30 + b24 = 0x99 + b24 +static const FanCase FAN_CASES[] = { + {climate::CLIMATE_FAN_QUIET, 0xB0, 0x49}, // 0x99 + 0xB0 = 0x149 + {climate::CLIMATE_FAN_AUTO, 0xA0, 0x39}, // 0x99 + 0xA0 = 0x139 + {climate::CLIMATE_FAN_LOW, 0x30, 0xC9}, // 0x99 + 0x30 + {climate::CLIMATE_FAN_MEDIUM, 0x50, 0xE9}, // 0x99 + 0x50 + {climate::CLIMATE_FAN_HIGH, 0x70, 0x09}, // 0x99 + 0x70 = 0x109 +}; + +TEST(DaikinRegression, EncodesEveryFanMode) { + for (const auto &c : FAN_CASES) { + Rig r; + const Message m = r.send(climate::CLIMATE_MODE_COOL, 24.0f, c.fan, climate::CLIMATE_SWING_OFF); + SCOPED_TRACE(static_cast(c.fan)); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x31); + EXPECT_EQ(m[22], 0x30); + EXPECT_EQ(m[24], c.b24); + EXPECT_EQ(m[25], 0x00); + EXPECT_EQ(m[34], c.byte34); + } +} + +struct SwingCase { + climate::ClimateSwingMode swing; + uint8_t b24; + uint8_t b25; + uint8_t byte34; +}; + +// Heat at 22 (0x41, 0x2C), fan medium (0x50): b34 = 0x38 + 0x41 + 0x2C + b24 + b25 = 0xA5 + ... +static const SwingCase SWING_CASES[] = { + {climate::CLIMATE_SWING_OFF, 0x50, 0x00, 0xF5}, // 0xA5 + 0x50 + {climate::CLIMATE_SWING_VERTICAL, 0x5F, 0x00, 0x04}, // 0xA5 + 0x5F = 0x104 + {climate::CLIMATE_SWING_HORIZONTAL, 0x50, 0x0F, 0x04}, // 0xA5 + 0x50 + 0x0F = 0x104 + {climate::CLIMATE_SWING_BOTH, 0x5F, 0x0F, 0x13}, // 0xA5 + 0x5F + 0x0F = 0x113 +}; + +TEST(DaikinRegression, EncodesEverySwingMode) { + for (const auto &c : SWING_CASES) { + Rig r; + const Message m = r.send(climate::CLIMATE_MODE_HEAT, 22.0f, climate::CLIMATE_FAN_MEDIUM, c.swing); + SCOPED_TRACE(static_cast(c.swing)); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x41); + EXPECT_EQ(m[22], 0x2C); + EXPECT_EQ(m[24], c.b24); + EXPECT_EQ(m[25], c.b25); + EXPECT_EQ(m[34], c.byte34); + } +} + +TEST(DaikinRegression, EncodesSwingWithQuietAndHighFan) { + { + // Dry, quiet, vertical: 0x21, 0xC0, 0xB0 | 0x0F = 0xBF; 0x38 + 0x21 + 0xC0 + 0xBF = 0x1D8 -> 0xD8 + Rig r; + const Message m = + r.send(climate::CLIMATE_MODE_DRY, 24.0f, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_SWING_VERTICAL); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x21); + EXPECT_EQ(m[22], 0xC0); + EXPECT_EQ(m[24], 0xBF); + EXPECT_EQ(m[25], 0x00); + EXPECT_EQ(m[34], 0xD8); + } + { + // Fan only, low, horizontal: 0x61, 0x32, 0x30, 0x0F; 0x38 + 0x61 + 0x32 + 0x30 + 0x0F = 0x10A -> 0x0A + Rig r; + const Message m = + r.send(climate::CLIMATE_MODE_FAN_ONLY, 24.0f, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_HORIZONTAL); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x61); + EXPECT_EQ(m[22], 0x32); + EXPECT_EQ(m[24], 0x30); + EXPECT_EQ(m[25], 0x0F); + EXPECT_EQ(m[34], 0x0A); + } + { + // Heat at 30, high, both: 0x41, 0x3C, 0x7F, 0x0F; 0x38 + 0x41 + 0x3C + 0x7F + 0x0F = 0x143 -> 0x43 + Rig r; + const Message m = r.send(climate::CLIMATE_MODE_HEAT, 30.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_BOTH); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x41); + EXPECT_EQ(m[22], 0x3C); + EXPECT_EQ(m[24], 0x7F); + EXPECT_EQ(m[25], 0x0F); + EXPECT_EQ(m[34], 0x43); + } +} + +struct TemperatureCase { + float target; + uint8_t byte22; +}; + +// Clamped to 10..30, rounded, then doubled. The call layer does not clamp, so 5 and 35 reach the encoder. +static const TemperatureCase TEMPERATURE_CASES[] = { + {5.0f, 0x14}, {10.0f, 0x14}, {11.0f, 0x16}, {18.0f, 0x24}, {21.0f, 0x2A}, + {24.0f, 0x30}, {29.0f, 0x3A}, {30.0f, 0x3C}, {35.0f, 0x3C}, +}; + +TEST(DaikinRegression, EncodesWholeDegreesWithClamp) { + for (const auto &c : TEMPERATURE_CASES) { + for (auto mode : {climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL}) { + Rig r; + const Message m = r.send(mode, c.target, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + SCOPED_TRACE(c.target); + SCOPED_TRACE(static_cast(mode)); + expect_fixed_bytes(m); + EXPECT_EQ(m[22], c.byte22); + } + } +} + +TEST(DaikinRegression, HeatCoolAt18Checksum) { + // 0x01, 18 << 1 = 0x24, medium 0x50: 0x38 + 0x01 + 0x24 + 0x50 = 0xAD + Rig r; + const Message m = + r.send(climate::CLIMATE_MODE_HEAT_COOL, 18.0f, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x01); + EXPECT_EQ(m[22], 0x24); + EXPECT_EQ(m[24], 0x50); + EXPECT_EQ(m[34], 0xAD); +} + +TEST(DaikinRegression, FrameTimingsAndCarrier) { + Rig r; + r.send(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + ASSERT_EQ(r.transmitter.sent.size(), 1u); + const Capture &c = r.transmitter.sent.back(); + EXPECT_EQ(c.carrier, CARRIER); + ASSERT_EQ(c.raw.size(), 572u); + // Header, then 64 bits of frame 1, its trailer and the 32300 gap, then frame 2's header + EXPECT_EQ(c.raw[0], 3360); + EXPECT_EQ(c.raw[1], -1760); + EXPECT_EQ(c.raw[2 + 128], 520); + EXPECT_EQ(c.raw[2 + 128 + 1], -32300); + EXPECT_EQ(c.raw[2 + 128 + 2], 3360); + EXPECT_EQ(c.raw[2 + 128 + 3], -1760); + // Frame 2's trailer and gap, then frame 3's header + EXPECT_EQ(c.raw[134 + 128], 520); + EXPECT_EQ(c.raw[134 + 128 + 1], -32300); + EXPECT_EQ(c.raw[134 + 128 + 2], 3360); + EXPECT_EQ(c.raw[134 + 128 + 3], -1760); + // Frame 3 ends with a bit mark and a zero space + EXPECT_EQ(c.raw[570], 520); + EXPECT_EQ(c.raw[571], 0); + // Byte 0 is 0x11, LSB first: bit 0 is a one (1370), bit 1 a zero (360) + EXPECT_EQ(c.raw[2], 520); + EXPECT_EQ(c.raw[3], -1370); + EXPECT_EQ(c.raw[4], 520); + EXPECT_EQ(c.raw[5], -360); +} + +struct ReceiveCase { + float target; + uint8_t mode_byte; + uint8_t temperature; + uint8_t fan_byte; + uint8_t swing_byte; + climate::ClimateMode mode; + climate::ClimateFanMode fan; + climate::ClimateSwingMode swing; +}; + +// Byte 5 mode (low bit power), byte 6 temperature doubled, byte 8 fan high nibble and vertical swing +// low nibble, byte 9 horizontal swing low nibble. Fan nibbles: 3 and 4 low, 5 medium, 6 and 7 high, +// A auto, B quiet. +static const ReceiveCase RECEIVE_CASES[] = { + {24.0f, 0x31, 0x30, 0xA0, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF}, + {22.0f, 0x41, 0x2C, 0x3F, 0x00, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_FAN_LOW, + climate::CLIMATE_SWING_VERTICAL}, + {20.0f, 0x01, 0x28, 0x50, 0x0F, climate::CLIMATE_MODE_HEAT_COOL, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_SWING_HORIZONTAL}, + {10.0f, 0x31, 0x14, 0x7F, 0x0F, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_BOTH}, + {30.0f, 0x41, 0x3C, 0xB0, 0x00, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_SWING_OFF}, + {18.0f, 0x31, 0x24, 0x40, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF}, + {18.0f, 0x31, 0x24, 0x60, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF}, +}; + +TEST(DaikinRegression, DecodesStateFrames) { + for (const auto &c : RECEIVE_CASES) { + Rig r; + SCOPED_TRACE(static_cast(c.mode_byte)); + SCOPED_TRACE(static_cast(c.fan_byte)); + EXPECT_TRUE(r.receive(state_frame(c.mode_byte, c.temperature, c.fan_byte, c.swing_byte))); + EXPECT_EQ(r.climate.mode, c.mode); + EXPECT_FLOAT_EQ(r.climate.target_temperature, c.target); + EXPECT_EQ(r.climate.fan_mode, c.fan); + EXPECT_EQ(r.climate.swing_mode, c.swing); + } +} + +TEST(DaikinRegression, DecodesDryAndFanOnlyWithoutTouchingTemperature) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + ASSERT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + // Dry carries 0xC0 in the temperature byte; the target stays at 24 + EXPECT_TRUE(r.receive(state_frame(0x21, 0xC0, 0x7F, 0x0F))); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_DRY); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(r.climate.swing_mode, climate::CLIMATE_SWING_BOTH); + // Fan only carries 0x32; the target still stays at 24 + EXPECT_TRUE(r.receive(state_frame(0x61, 0x32, 0xBF, 0x00))); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_QUIET); + EXPECT_EQ(r.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(DaikinRegression, DecodesPowerOffAsOff) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + ASSERT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL); + // Cool with the power bit clear, at 26: mode becomes off and the temperature is not applied + EXPECT_TRUE(r.receive(state_frame(0x30, 0x34, 0x50, 0x00))); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM); +} + +TEST(DaikinRegression, RejectsBadChecksum) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + auto frame = state_frame(0x41, 0x2C, 0x50, 0x00); + frame[18] ^= 0x01; + EXPECT_FALSE(r.receive(frame)); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_AUTO); +} + +TEST(DaikinRegression, RejectsWrongHeaderByte) { + for (size_t index = 0; index < 5; index++) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + auto frame = state_frame(0x41, 0x2C, 0x50, 0x00); + frame[index] ^= 0x01; + // Recompute the checksum so only the header byte is wrong + uint8_t total = 0; + for (size_t i = 0; i < 18; i++) + total += frame[i]; + frame[18] = total; + SCOPED_TRACE(index); + EXPECT_FALSE(r.receive(frame)); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + } +} + +} // namespace esphome::daikin::regression diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp new file mode 100644 index 00000000000..5a71cf41b7f --- /dev/null +++ b/tests/components/daikin/daikin_tests.cpp @@ -0,0 +1,440 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/daikin/daikin.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome::daikin::testing { + +static constexpr size_t MESSAGE_SIZE = 35; +using Message = std::array; +static constexpr uint32_t BOOST_TIMEOUT_ID = 0; + +// The host scheduler reads the real clock, so the 20 minute boost timer cannot elapse in a test. +// Explicit instantiation skips access checks, which lets the test run the timer's callback itself. +template struct BoostCallback { + friend void run_boost_callback(DaikinClimate &climate) { (climate.*M)(); } +}; +void run_boost_callback(DaikinClimate &climate); +template struct BoostCallback<&DaikinClimate::cancel_boost_mode_>; + +static uint8_t sum(const uint8_t *data, size_t len) { + uint8_t total = 0; + for (size_t i = 0; i < len; i++) + total += data[i]; + return total; +} + +static std::string hex(const uint8_t *data, size_t len) { + std::string out; + for (size_t i = 0; i < len; i++) { + char buf[4]; + snprintf(buf, sizeof(buf), "%02X ", data[i]); + out += buf; + } + return out; +} + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +/// Reads `count` LSB-first bytes starting at raw[pos], advancing pos past them +static void read_bytes(const remote_base::RawTimings &raw, size_t &pos, uint8_t *out, size_t count) { + for (size_t byte = 0; byte < count; byte++) { + uint8_t value = 0; + for (int bit = 0; bit < 8; bit++) { + EXPECT_EQ(raw[pos], static_cast(DAIKIN_BIT_MARK)) << "bit mark at " << pos; + const int32_t space = -raw[pos + 1]; + if (space == static_cast(DAIKIN_ONE_SPACE)) { + value |= 1 << bit; + } else { + EXPECT_EQ(space, static_cast(DAIKIN_ZERO_SPACE)) << "bit space at " << pos + 1; + } + pos += 2; + } + out[byte] = value; + } +} + +static void expect_header(const remote_base::RawTimings &raw, size_t &pos) { + EXPECT_EQ(raw[pos], static_cast(DAIKIN_HEADER_MARK)) << "header mark at " << pos; + EXPECT_EQ(raw[pos + 1], -static_cast(DAIKIN_HEADER_SPACE)) << "header space at " << pos + 1; + pos += 2; +} + +static void expect_trailer(const remote_base::RawTimings &raw, size_t &pos, uint32_t space) { + EXPECT_EQ(raw[pos], static_cast(DAIKIN_BIT_MARK)) << "trailer mark at " << pos; + EXPECT_EQ(raw[pos + 1], -static_cast(space)) << "trailer space at " << pos + 1; + pos += 2; +} + +/// Decodes a captured transmission back into its 35 bytes, checking headers and frame gaps +static Message decode(const remote_base::RawTimings &raw) { + Message msg{}; + // Three frames of 8, 8 and 19 bytes, each a header, two timings per bit and a trailer + const size_t expected_size = 3 * 4 + 2 * 8 * MESSAGE_SIZE; + EXPECT_EQ(raw.size(), expected_size); + if (raw.size() != expected_size) + return msg; + size_t pos = 0; + expect_header(raw, pos); + read_bytes(raw, pos, msg.data(), 8); + expect_trailer(raw, pos, DAIKIN_MESSAGE_SPACE); + expect_header(raw, pos); + read_bytes(raw, pos, msg.data() + 8, 8); + expect_trailer(raw, pos, DAIKIN_MESSAGE_SPACE); + expect_header(raw, pos); + read_bytes(raw, pos, msg.data() + 16, 19); + expect_trailer(raw, pos, 0); + return msg; +} + +/// Timings for one received frame (8 or 19 bytes) as the receiver would deliver it +static remote_base::RawTimings frame_timings(const std::vector &bytes) { + remote_base::RawTimings raw; + raw.push_back(DAIKIN_HEADER_MARK); + raw.push_back(-static_cast(DAIKIN_HEADER_SPACE)); + for (uint8_t byte : bytes) { + for (int bit = 0; bit < 8; bit++) { + raw.push_back(DAIKIN_BIT_MARK); + raw.push_back(-static_cast((byte >> bit) & 1 ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE)); + } + } + raw.push_back(DAIKIN_BIT_MARK); + raw.push_back(-static_cast(DAIKIN_MESSAGE_SPACE)); + return raw; +} + +/// The 0xC5 frame, with a valid checksum unless one is given +static std::vector comfort_frame(uint8_t comfort) { + std::vector f{0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, comfort, 0x00}; + f[7] = sum(f.data(), 7); + return f; +} + +/// The 19-byte state frame: cool at `temperature` (half degrees), fan auto, no swing +static std::vector state_frame(uint8_t byte13, uint8_t byte16, uint8_t temperature = 44) { + std::vector f{0x11, 0xDA, 0x27, 0x00, 0x00, DAIKIN_MODE_ON | DAIKIN_MODE_COOL, + temperature, 0x00, 0xA0, 0x00, 0x00, 0x06, + 0x60, byte13, 0x00, 0xC0, byte16, 0x00, + 0x00}; + f[18] = sum(f.data(), 18); + return f; +} + +struct Fixture { + CapturingTransmitter transmitter; + DaikinClimate climate; + + /// Pass keep_preferences to restore what an earlier fixture saved + explicit Fixture(bool keep_preferences = false) { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (!keep_preferences && global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does; the boost timer needs its scheduler + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_daikin"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + ~Fixture() { + // Drop a pending boost timer while the climate is alive; App outlives each test + App.scheduler.cancel_timeout(&this->climate, BOOST_TIMEOUT_ID); + App.scheduler.call(millis()); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimatePreset preset) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_preset(preset); + call.perform(); + } + + Message last_message() const { return decode(this->transmitter.frames.back()); } + + bool receive(const std::vector &bytes) { + const remote_base::RawTimings raw = frame_timings(bytes); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(data); + } + + /// Fires the boost timer as the scheduler would if it is pending; returns whether it was + bool fire_boost_timer() { + if (!App.scheduler.cancel_timeout(&this->climate, BOOST_TIMEOUT_ID)) + return false; + run_boost_callback(this->climate); + return true; + } +}; + +static void expect_checksums(const Message &m) { + EXPECT_EQ(m[7], sum(m.data(), 7)) << hex(m.data(), MESSAGE_SIZE); + EXPECT_EQ(m[34], sum(m.data() + 16, 18)) << hex(m.data(), MESSAGE_SIZE); +} + +TEST(Daikin, EncodesHalfDegree) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.5f, climate::CLIMATE_PRESET_NONE); + ASSERT_EQ(f.transmitter.frames.size(), 1u); + const Message m = f.last_message(); + EXPECT_EQ(m[16 + 5], DAIKIN_MODE_ON | DAIKIN_MODE_COOL); + EXPECT_EQ(m[22], 45); + EXPECT_EQ(m[6], 0x00); + EXPECT_EQ(m[29], 0x00); + EXPECT_EQ(m[32], 0x00); + expect_checksums(m); +} + +TEST(Daikin, EncodesEachPresetInItsOwnByte) { + struct Case { + climate::ClimatePreset preset; + uint8_t byte6, byte29, byte32; + }; + const Case cases[] = { + {climate::CLIMATE_PRESET_NONE, 0x00, 0x00, 0x00}, {climate::CLIMATE_PRESET_BOOST, 0x00, 0x01, 0x00}, + {climate::CLIMATE_PRESET_SLEEP, 0x00, 0x20, 0x00}, {climate::CLIMATE_PRESET_ECO, 0x00, 0x00, 0x04}, + {climate::CLIMATE_PRESET_ACTIVITY, 0x00, 0x00, 0x02}, {climate::CLIMATE_PRESET_COMFORT, 0x10, 0x00, 0x00}, + }; + Fixture f; + uint8_t plain_first_checksum = 0; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.preset)); + f.command(climate::CLIMATE_MODE_COOL, 22.5f, c.preset); + const Message m = f.last_message(); + EXPECT_EQ(m[6], c.byte6); + EXPECT_EQ(m[29], c.byte29); + EXPECT_EQ(m[32], c.byte32); + EXPECT_EQ(m[22], 45); + expect_checksums(m); + if (c.preset == climate::CLIMATE_PRESET_NONE) { + plain_first_checksum = m[7]; + } else if (c.preset == climate::CLIMATE_PRESET_COMFORT) { + EXPECT_NE(m[7], plain_first_checksum); + EXPECT_EQ(static_cast(m[7] - plain_first_checksum), 0x10); + } else { + EXPECT_EQ(m[7], plain_first_checksum); + } + } +} + +TEST(Daikin, ReceivedComfortFrameSetsComfort) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); +} + +TEST(Daikin, ReceivedPowerfulGivesBoost) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST); +} + +TEST(Daikin, ReceivedStateWithoutBitsClearsEco) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x04))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, ReceivedOtherPresets) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x20, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x02))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ACTIVITY); +} + +TEST(Daikin, ReceivedHalfDegree) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00, 45))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.5f); +} + +TEST(Daikin, ReceivedFrameRoundTripsTransmittedState) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.5f, climate::CLIMATE_PRESET_COMFORT); + const Message m = f.last_message(); + f.command(climate::CLIMATE_MODE_HEAT, 18.0f, climate::CLIMATE_PRESET_NONE); + EXPECT_TRUE(f.receive(std::vector(m.begin(), m.begin() + 8))); + EXPECT_TRUE(f.receive(std::vector(m.begin() + 16, m.end()))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.5f); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, BoostTimerCancelledWhenRemoteLeavesBoost) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x04))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + // No boost timer may remain to end a boost that is already over + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); +} + +TEST(Daikin, BoostTimerCancelledWhenEntityLeavesBoost) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_SLEEP); + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP); +} + +TEST(Daikin, ReceivedBoostEndsWhenTimerFires) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_TRUE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, RepeatedBoostFrameKeepsOneTimer) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_TRUE(f.fire_boost_timer()); + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, BoostToComfortTimerDoesNotChangePreset) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + // The state frame saw boost as the previous preset and cancelled the timer + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, ComfortFrameAloneChangesNothing) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x00, 0x04))); + ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + + // The comfort frame is only recorded until a valid state frame follows + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + std::vector bad = state_frame(0x00, 0x00); + bad[18] ^= 0x01; + EXPECT_FALSE(f.receive(bad)); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, LostStateFrameKeepsComfortUntilNextTransmission) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + + // Comfort off arrives but its state frame is lost: nothing changes + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + + // The next transmission says comfort off again and its state frame arrives + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, PendingComfortExpiresAfterOneSecond) { + Fixture f; + // LoopBlockingGuard publishes the dispatch time the component reads as the loop clock + { LoopBlockingGuard clock(&f.climate, LOG_STR("test"), 1000); } + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + // The state frame of that transmission is lost; the next one arrives after the window + { LoopBlockingGuard clock(&f.climate, LOG_STR("test"), 1000 + DAIKIN_TRANSMISSION_MS + 1); } + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, StateFrameWithoutComfortFrameKeepsComfort) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + // A state frame whose 0xC5 frame was lost leaves a current comfort preset alone + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, RestoredBoostArmsTheTimer) { + { + Fixture first; + first.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + ASSERT_EQ(first.climate.preset, climate::CLIMATE_PRESET_BOOST); + } + Fixture restored(true); + EXPECT_EQ(restored.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_EQ(restored.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_TRUE(App.scheduler.cancel_timeout(&restored.climate, BOOST_TIMEOUT_ID)); +} + +TEST(Daikin, FreshClimateHasNoBoostTimer) { + { + Fixture first; + first.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + } + // A fixture that clears the saved state restores nothing and arms no timer + Fixture fresh; + EXPECT_NE(fresh.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_FALSE(App.scheduler.cancel_timeout(&fresh.climate, BOOST_TIMEOUT_ID)); +} + +TEST(Daikin, RejectsBadStateChecksum) { + Fixture f; + f.command(climate::CLIMATE_MODE_HEAT, 20.0f, climate::CLIMATE_PRESET_SLEEP); + std::vector bad = state_frame(0x01, 0x00, 45); + bad[18] ^= 0x01; + EXPECT_FALSE(f.receive(bad)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20.0f); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP); + EXPECT_FALSE(f.fire_boost_timer()); +} + +TEST(Daikin, RejectsBadComfortChecksum) { + Fixture f; + std::vector bad = comfort_frame(0x10); + bad[7] ^= 0x01; + EXPECT_FALSE(f.receive(bad)); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +} // namespace esphome::daikin::testing diff --git a/tests/components/daikin/test.esp32-idf.yaml b/tests/components/daikin/test.esp32-idf.yaml new file mode 100644 index 00000000000..62ac9ac7ab9 --- /dev/null +++ b/tests/components/daikin/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + daikin: !include common.yaml diff --git a/tests/components/daikin/test.esp8266-ard.yaml b/tests/components/daikin/test.esp8266-ard.yaml index 4bed2f03e55..6d9ed4c4360 100644 --- a/tests/components/daikin/test.esp8266-ard.yaml +++ b/tests/components/daikin/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + daikin: !include common.yaml From 84694b602a74cf190fac54370e3dec4a3e116228 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 02:14:38 -0700 Subject: [PATCH 033/651] [ld2412] Fix crash when only some gate thresholds are configured (#18194) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: J. Nick Koston --- esphome/components/ld2412/ld2412.cpp | 117 +++-- esphome/components/ld2412/ld2412.h | 27 +- .../ld2412/number/gate_threshold_number.cpp | 2 +- .../uart_mock_ld2412_footer_in_payload.yaml | 74 +++ ...uart_mock_ld2412_requery_after_giveup.yaml | 61 +++ .../uart_mock_ld2412_truncated_answer.yaml | 72 +++ tests/integration/test_uart_mock_ld2412.py | 425 +++++++++++++++--- 7 files changed, 690 insertions(+), 88 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index bd43d06cc27..c6137f29f7c 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -10,6 +10,9 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include +#include + namespace esphome::ld2412 { ESPHOME_LOG_TAG(TAG, "ld2412"); @@ -73,8 +76,10 @@ enum PeriodicDataValue : uint8_t { }; enum AckData : uint8_t { + FRAME_LENGTH = 4, // little-endian count of the bytes between it and the footer COMMAND = 6, COMMAND_STATUS = 7, + ACK_PAYLOAD = 10, }; // Memory-efficient lookup tables @@ -191,6 +196,9 @@ static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0x static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5}; // MAC address the module uses when Bluetooth is disabled static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE; +// The length field counts the command word, the status word and the gates +static constexpr uint16_t GATE_SENS_ACK_LENGTH = GATE_SENS_ACK_SIZE - COMMAND - HEADER_FOOTER_SIZE; static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) { return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; @@ -240,10 +248,10 @@ void LD2412Component::dump_config() { LOG_NUMBER(" ", "MaxDistanceGate", this->max_distance_gate_number_); LOG_NUMBER(" ", "MinDistanceGate", this->min_distance_gate_number_); LOG_NUMBER(" ", "Timeout", this->timeout_number_); - for (number::Number *n : this->gate_move_threshold_numbers_) { + for (number::Number *n : this->gate_move_thresholds_.numbers) { LOG_NUMBER(" ", "Move Thresholds", n); } - for (number::Number *n : this->gate_still_threshold_numbers_) { + for (number::Number *n : this->gate_still_thresholds_.numbers) { LOG_NUMBER(" ", "Still Thresholds", n); } #endif @@ -460,6 +468,13 @@ void set_number_value(number::Number *n, float value) { n->publish_state(value); } } + +// NaN marks a value the module never received +static inline bool has_value(number::Number *n) { return n != nullptr && n->has_state() && !std::isnan(n->state); } + +// Timeout ids 0 (still) and 1 (motion) for a write waiting on a re-query +static constexpr uint32_t GATE_QUERY_TIMEOUT_ID = 0; +static constexpr uint32_t GATE_QUERY_TIMEOUT_MS = 2000; #endif bool LD2412Component::handle_ack_data_() { @@ -595,19 +610,34 @@ bool LD2412Component::handle_ack_data_() { ESP_LOGV(TAG, "Handled set light control command"); break; - case CMD_QUERY_MOTION_GATE_SENS: { -#ifdef USE_NUMBER - for (size_t i = 0; i < this->gate_move_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { - set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[10 + i]); - } -#endif - break; - } - + case CMD_QUERY_MOTION_GATE_SENS: case CMD_QUERY_STATIC_GATE_SENS: { #ifdef USE_NUMBER - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { - set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[10 + i]); + const uint16_t declared = encode_uint16(this->buffer_data_[FRAME_LENGTH + 1], this->buffer_data_[FRAME_LENGTH]); + if (declared != GATE_SENS_ACK_LENGTH || this->buffer_pos_ > GATE_SENS_ACK_SIZE) { + // A truncated answer merged with the next frame, or a length this driver does not know + ESP_LOGW(TAG, "Dropping gate threshold answer of %u bytes", this->buffer_pos_); + return true; + } + if (this->buffer_pos_ < GATE_SENS_ACK_SIZE) { + return false; // the footer pattern appeared inside the payload, keep reading + } + const bool motion = this->buffer_data_[COMMAND] == CMD_QUERY_MOTION_GATE_SENS; + GateThresholds &group = this->gate_thresholds_(motion); + // True only while a held-back write waits for this answer + const bool pending = this->cancel_timeout(GATE_QUERY_TIMEOUT_ID + motion); + for (size_t i = 0; i < TOTAL_GATES; i++) { + group.last_read[i] = this->buffer_data_[ACK_PAYLOAD + i]; + // The pending write's user values must survive the answer + if (!pending || !has_value(group.numbers[i])) { + set_number_value(group.numbers[i], group.last_read[i]); + } + } + group.read = true; + if (pending) { + this->set_config_mode_(true); + this->send_gate_thresholds_(motion); + this->set_config_mode_(false); } #endif break; @@ -747,6 +777,11 @@ void LD2412Component::set_engineering_mode(bool enable) { void LD2412Component::factory_reset() { this->set_config_mode_(true); this->send_command_(CMD_FACTORY_RESET, nullptr, 0); +#ifdef USE_NUMBER + // Reset discards the thresholds; the next write must wait for the re-read + this->gate_move_thresholds_.read = false; + this->gate_still_thresholds_.read = false; +#endif this->set_timeout(2000, [this]() { this->restart_and_read_all_info(); }); } @@ -805,24 +840,48 @@ void LD2412Component::set_basic_config() { } #ifdef USE_NUMBER -void LD2412Component::set_gate_threshold() { - if (this->gate_move_threshold_numbers_.empty() && this->gate_still_threshold_numbers_.empty()) { - return; // No gate threshold numbers set; nothing to do here +void LD2412Component::send_gate_thresholds_(bool motion) { + GateThresholds &group = this->gate_thresholds_(motion); + const uint8_t command = motion ? CMD_MOTION_GATE_SENS : CMD_STATIC_GATE_SENS; + uint8_t value[TOTAL_GATES]; + uint8_t configured = 0; + for (uint8_t i = 0; i < TOTAL_GATES; i++) { + number::Number *n = group.numbers[i]; + if (has_value(n)) { + value[i] = static_cast(n->state); + configured++; + } else { + value[i] = group.last_read[i]; + } } - uint8_t value[TOTAL_GATES] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + if (configured < TOTAL_GATES && !group.read) { + // Unread gates would go out as zero, which is maximum sensitivity + ESP_LOGW(TAG, "Command %02X held back until the module reports its gate thresholds", command); + this->send_command_(motion ? CMD_QUERY_MOTION_GATE_SENS : CMD_QUERY_STATIC_GATE_SENS, nullptr, 0); + this->set_timeout(GATE_QUERY_TIMEOUT_ID + motion, GATE_QUERY_TIMEOUT_MS, + [this, motion]() { this->gate_query_timeout_(motion); }); + return; + } + this->send_command_(command, value, sizeof(value)); +} + +void LD2412Component::gate_query_timeout_(bool motion) { + ESP_LOGE(TAG, "Command %02X not sent, the module did not report its gate thresholds", + motion ? CMD_MOTION_GATE_SENS : CMD_STATIC_GATE_SENS); + // The values on hand never reached the module or are stale, so forgetting them loses nothing + for (number::Number *n : this->gate_thresholds_(motion).numbers) { + if (n != nullptr) { + n->publish_state(NAN); + } + } +} + +void LD2412Component::set_gate_threshold(number::Number *n) { + // Found by pointer: 14 compares per write beat a flag per number + const auto &motion_numbers = this->gate_move_thresholds_.numbers; + const bool motion = std::find(motion_numbers.begin(), motion_numbers.end(), n) != motion_numbers.end(); this->set_config_mode_(true); - if (!this->gate_move_threshold_numbers_.empty()) { - for (size_t i = 0; i < this->gate_move_threshold_numbers_.size(); i++) { - value[i] = lowbyte(static_cast(this->gate_move_threshold_numbers_[i]->state)); - } - this->send_command_(CMD_MOTION_GATE_SENS, value, sizeof(value)); - } - if (!this->gate_still_threshold_numbers_.empty()) { - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size(); i++) { - value[i] = lowbyte(static_cast(this->gate_still_threshold_numbers_[i]->state)); - } - this->send_command_(CMD_STATIC_GATE_SENS, value, sizeof(value)); - } + this->send_gate_thresholds_(motion); this->set_config_mode_(false); } diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index a52402c2ea3..c427d024f39 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -85,11 +85,13 @@ class LD2412Component final : public Component, public uart::UARTDevice { void set_light_out_control(); void set_basic_config(); #ifdef USE_NUMBER - void set_gate_move_threshold_number(uint8_t gate, number::Number *n) { this->gate_move_threshold_numbers_[gate] = n; } - void set_gate_still_threshold_number(uint8_t gate, number::Number *n) { - this->gate_still_threshold_numbers_[gate] = n; + void set_gate_move_threshold_number(uint8_t gate, number::Number *n) { + this->gate_move_thresholds_.numbers[gate] = n; } - void set_gate_threshold(); + void set_gate_still_threshold_number(uint8_t gate, number::Number *n) { + this->gate_still_thresholds_.numbers[gate] = n; + } + void set_gate_threshold(number::Number *n); void get_gate_threshold(); #endif #ifdef USE_SENSOR @@ -118,6 +120,19 @@ class LD2412Component final : public Component, public uart::UARTDevice { void query_light_control_(); void restart_(); void query_dynamic_background_correction_(); +#ifdef USE_NUMBER + // One command carries all 14 gates, so unconfigured gates get the module's last value + struct GateThresholds { + std::array numbers{}; + std::array last_read{}; + bool read{false}; // answered since boot or the last factory reset + }; + GateThresholds &gate_thresholds_(bool motion) { + return motion ? this->gate_move_thresholds_ : this->gate_still_thresholds_; + } + void send_gate_thresholds_(bool motion); + void gate_query_timeout_(bool motion); +#endif uint8_t light_function_ = 0; uint8_t light_threshold_ = 0; @@ -129,8 +144,8 @@ class LD2412Component final : public Component, public uart::UARTDevice { bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; #ifdef USE_NUMBER - std::array gate_move_threshold_numbers_{}; - std::array gate_still_threshold_numbers_{}; + GateThresholds gate_move_thresholds_; + GateThresholds gate_still_thresholds_; #endif #ifdef USE_SENSOR std::array, TOTAL_GATES> gate_move_sensors_{}; diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index a0a525a8107..1af7b1e7f20 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2412 { void GateThresholdNumber::control(float value) { this->publish_state(value); - this->parent_->set_gate_threshold(); + this->parent_->set_gate_threshold(this); } } // namespace esphome::ld2412 diff --git a/tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml b/tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml new file mode 100644 index 00000000000..ae3c2e45d54 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml @@ -0,0 +1,74 @@ +esphome: + name: uart-mock-ld2412-footer + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + # Both answers hold the footer 04 03 02 01 inside the gate bytes, so the parser must read on to the real footer + responses: + # Gates 5 to 8 hold 04 03 02 01 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x13, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x12, 0x00, + 0x13, 0x01, + 0x00, 0x00, + 0x00, 0x23, 0x23, 0x23, 0x23, 0x04, 0x03, + 0x02, 0x01, 0x19, 0x19, 0x19, 0x19, 0x19, + 0x04, 0x03, 0x02, 0x01, + ] + # Gates 10 to 13 hold 04 03 02 01, right before the real footer + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x14, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x12, 0x00, + 0x14, 0x01, + 0x00, 0x00, + 0x00, 0x23, 0x23, 0x23, 0x23, 0x19, 0x19, + 0x19, 0x19, 0x19, 0x04, 0x03, 0x02, 0x01, + 0x04, 0x03, 0x02, 0x01, + ] + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +# Only 2 of the 14 gates have a number; gate 5 holds a footer byte in the motion answer +number: + - platform: ld2412 + ld2412_id: ld2412_dev + gate_0: + move_threshold: + name: "Gate 0 Move Threshold" + still_threshold: + name: "Gate 0 Still Threshold" + gate_5: + move_threshold: + name: "Gate 5 Move Threshold" + still_threshold: + name: "Gate 5 Still Threshold" + +button: + - platform: ld2412 + ld2412_id: ld2412_dev + factory_reset: + name: "Factory Reset" diff --git a/tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml b/tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml new file mode 100644 index 00000000000..82a3c01de70 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml @@ -0,0 +1,61 @@ +esphome: + name: uart-mock-ld2412-regiveup + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Only the third motion query is answered: the setup query and the first write's re-query go unanswered, +# so that write is given up. The still query is never answered +uart_mock: + id: mock_uart + baud_rate: 256000 + on_tx: + - then: + - lambda: |- + // The length and command word arrive as one write of their own + static const std::vector MOTION_QUERY = {0x02, 0x00, 0x13, 0x00}; + static int motion_queries = 0; + if (data != MOTION_QUERY || ++motion_queries != 3) { + return; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, // header + 0x12, 0x00, // length 18 + 0x13, 0x01, // answer to command 0x0013 + 0x00, 0x00, // status: success + 0x19, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, // gates 0 to 6 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, // gates 7 to 13 + 0x04, 0x03, 0x02, 0x01, // footer + }); + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +number: + - platform: ld2412 + ld2412_id: ld2412_dev + gate_0: + move_threshold: + name: "Gate 0 Move Threshold" + still_threshold: + name: "Gate 0 Still Threshold" + gate_5: + move_threshold: + name: "Gate 5 Move Threshold" + still_threshold: + name: "Gate 5 Still Threshold" diff --git a/tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml b/tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml new file mode 100644 index 00000000000..9950e78042a --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml @@ -0,0 +1,72 @@ +esphome: + name: uart-mock-ld2412-truncated + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + responses: + # The motion answer is two gate bytes short, so the parser reads on into the next acknowledgement + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x13, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, # motion header + 0x12, 0x00, # length 18, but only 16 bytes follow + 0x13, 0x01, # answer to command 0x0013 + 0x00, 0x00, # status: success + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, # gates 0 to 5 + 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, # gates 6 to 11, gates 12 and 13 are missing + 0x04, 0x03, 0x02, 0x01, # motion footer + 0xFD, 0xFC, 0xFB, 0xFA, # next frame: the config-mode end acknowledgement + 0x04, 0x00, # length 4 + 0xFE, 0x01, # answer to command 0x00FE + 0x00, 0x00, # status: success + 0x04, 0x03, 0x02, 0x01, # its footer; the merged 40-byte frame gets dropped + ] + # A complete answer after setup proves the parser recovered + injections: + - delay: 1500ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, # header + 0x12, 0x00, # length 18 + 0x13, 0x01, # answer to command 0x0013 + 0x00, 0x00, # status: success + 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, # gates 0 to 6 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, # gates 7 to 13 + 0x04, 0x03, 0x02, 0x01, # footer + ] + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +number: + - platform: ld2412 + ld2412_id: ld2412_dev + gate_0: + move_threshold: + name: "Gate 0 Move Threshold" + still_threshold: + name: "Gate 0 Still Threshold" + gate_5: + move_threshold: + name: "Gate 5 Move Threshold" + still_threshold: + name: "Gate 5 Still Threshold" diff --git a/tests/integration/test_uart_mock_ld2412.py b/tests/integration/test_uart_mock_ld2412.py index ea2ec38b2a3..e9a69ca870a 100644 --- a/tests/integration/test_uart_mock_ld2412.py +++ b/tests/integration/test_uart_mock_ld2412.py @@ -20,19 +20,120 @@ test_uart_mock_ld2412_engineering_truncated (truncated engineering mode): 2. Truncated engineering frame (24 bytes) is rejected — gate/light sensors must not receive garbage from stale buffer data or frame footer bytes 3. Recovery frame with different values proves the component survived + +The gate threshold tests (footer_in_payload, truncated_answer, requery_after_giveup) cover +answers that hold the frame footer or are truncated, writes that fill the gates without a +number from the module's values, and writes held back until the module reports them. """ from __future__ import annotations import asyncio -from pathlib import Path +from collections.abc import Callable +import math -from aioesphomeapi import ButtonInfo +from aioesphomeapi import ( + APIClient, + ButtonInfo, + EntityInfo, + EntityState, + NumberInfo, + NumberState, +) import pytest -from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .state_utils import ( + InitialStateHelper, + SensorStateCollector, + StateWaiter, + find_entity, + require_entity, +) from .types import APIClientConnectedFactory, RunCompiledFunction +# Length and command word of each frame as the uart mock logs it; the payload is logged on its own TX line +MOTION_COMMAND = "10:00:03:00" +STILL_COMMAND = "10:00:04:00" +MOTION_QUERY = "02:00:13:00" +STILL_QUERY = "02:00:14:00" +CONFIG_MODE_LEFT = "02:00:FE:00" +FACTORY_RESET = "02:00:A2:00" +# Logged in place of the motion command when the write is held back or given up +MOTION_HELD_BACK = "Command 03 held back" +MOTION_GIVEN_UP = "Command 03 not sent, the module did not report" + +THRESHOLD_NUMBERS = ( + "gate_0_move_threshold", + "gate_0_still_threshold", + "gate_5_move_threshold", + "gate_5_still_threshold", +) + + +def _is_tx(line: str) -> bool: + return "uart_mock" in line and "TX " in line + + +def _threshold_numbers(entities: list[EntityInfo]) -> dict[str, NumberInfo]: + """Return the gate 0 and gate 5 threshold numbers by name.""" + return { + name: require_entity(entities, name, NumberInfo) for name in THRESHOLD_NUMBERS + } + + +async def _wait_initial( + client: APIClient, + entities: list[EntityInfo], + on_state: Callable[[EntityState], None] = lambda s: None, +) -> InitialStateHelper: + """Subscribe to states and wait until every entity has sent its initial state.""" + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + return initial_state_helper + + +def _number_state_of(key: int) -> Callable[[EntityState], bool]: + """Return a predicate that matches a number state with a value for ``key``.""" + return lambda s: isinstance(s, NumberState) and not s.missing_state and s.key == key + + +def _is_nan_of(key: int) -> Callable[[EntityState], bool]: + """Return a predicate that matches the number ``key`` being marked unknown.""" + return lambda s: _number_state_of(key)(s) and math.isnan(s.state) + + +class _LogEvents: + """Record the TX lines and the log lines holding one of ``texts``, in the order they were logged.""" + + def __init__(self, *texts: str) -> None: + self._texts = texts + self.lines: list[str] = [] + self._awaited: dict[str, asyncio.Future[None]] = {} + + def on_line(self, line: str) -> None: + if _is_tx(line) or any(text in line for text in self._texts): + self.lines.append(line) + for text, future in self._awaited.items(): + if text in line and not future.done(): + future.set_result(None) + + def next_line_with(self, text: str) -> asyncio.Future[None]: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + self._awaited[text] = future + return future + + def index_after(self, start: int, text: str) -> int: + return next( + i for i, line in enumerate(self.lines) if i > start and text in line + ) + + def count(self, text: str) -> int: + return sum(text in line for line in self.lines) + @pytest.mark.asyncio async def test_uart_mock_ld2412( @@ -41,14 +142,6 @@ async def test_uart_mock_ld2412( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2412 data parsing with happy path, garbage, overflow, and recovery.""" - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - loop = asyncio.get_running_loop() # Track overflow warning in logs @@ -61,7 +154,7 @@ async def test_uart_mock_ld2412( if "Max command length exceeded" in line and not overflow_seen.done(): overflow_seen.set_result(True) # Capture all TX log lines from uart_mock - if "uart_mock" in line and "TX " in line: + if _is_tx(line): tx_log_lines.append(line) collector = SensorStateCollector( @@ -97,16 +190,7 @@ async def test_uart_mock_ld2412( entities, _ = await client.list_entities_services() collector.build_key_mapping(entities) - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(collector.on_state) - ) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + await _wait_initial(client, entities, collector.on_state) # Start the UART mock scenario now that we're subscribed start_btn = find_entity(entities, "start_scenario", ButtonInfo) @@ -176,12 +260,6 @@ async def test_uart_mock_ld2412_engineering( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2412 engineering mode with per-gate energy, light, and multi-byte distance.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) collector = SensorStateCollector( sensor_names=[ @@ -220,15 +298,7 @@ async def test_uart_mock_ld2412_engineering( entities, _ = await client.list_entities_services() collector.build_key_mapping(entities) - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(collector.on_state) - ) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + await _wait_initial(client, entities, collector.on_state) # Start the UART mock scenario now that we're subscribed start_btn = find_entity(entities, "start_scenario", ButtonInfo) @@ -288,12 +358,6 @@ async def test_uart_mock_ld2412_engineering_truncated( check but reads indices 17-45 from stale buffer data, publishing garbage values (e.g. frame footer bytes 0xF8=248 as gate energy). """ - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) loop = asyncio.get_running_loop() @@ -341,15 +405,7 @@ async def test_uart_mock_ld2412_engineering_truncated( entities, _ = await client.list_entities_services() collector.build_key_mapping(entities) - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(collector.on_state) - ) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + await _wait_initial(client, entities, collector.on_state) start_btn = find_entity(entities, "start_scenario", ButtonInfo) assert start_btn is not None, "Start Scenario button not found" @@ -397,3 +453,268 @@ async def test_uart_mock_ld2412_engineering_truncated( f"truncated frame likely leaked stale buffer data. " f"All values: {collector.sensor_states['gate_0_move_energy']}" ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_footer_in_payload( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test threshold answers that hold the frame footer 04 03 02 01, and the writes built from them.""" + # Gate 0 set to 77 (0x4D); the other 13 gates must match the motion answer byte for byte + expected_motion = "4D:23:23:23:23:04:03:02:01:19:19:19:19:19" + # Gate 5 set to 77, the rest matches the still answer + expected_still = "00:23:23:23:23:4D:19:19:19:19:04:03:02:01" + # Gate 0 set to 50 (0x32) after the reset + expected_motion_after_reset = "32:23:23:23:23:04:03:02:01:19:19:19:19:19" + events = _LogEvents(MOTION_HELD_BACK) + + async with ( + run_compiled(yaml_config, line_callback=events.on_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + initial_state_helper = await _wait_initial(client, entities) + numbers = _threshold_numbers(entities) + + # Gate 5 move is the first footer byte inside the motion answer + for name, value in { + "gate_0_move_threshold": 0.0, + "gate_5_move_threshold": 4.0, + "gate_0_still_threshold": 0.0, + "gate_5_still_threshold": 25.0, + }.items(): + state = initial_state_helper.initial_states[numbers[name].key] + assert state.state == pytest.approx(value), name + + motion_written = events.next_line_with(expected_motion) + client.number_command(numbers["gate_0_move_threshold"].key, 77.0) + try: + await asyncio.wait_for(motion_written, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for the motion threshold command.\n" + f" expected motion payload: {expected_motion}" + ) + + assert events.count(STILL_COMMAND) == 0, ( + "still threshold command was sent for a motion write" + ) + + still_written = events.next_line_with(expected_still) + client.number_command(numbers["gate_5_still_threshold"].key, 77.0) + try: + await asyncio.wait_for(still_written, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for the still threshold command.\n" + f" expected still payload: {expected_still}" + ) + + assert events.count(MOTION_COMMAND) == 1 + assert events.count(STILL_COMMAND) == 1 + assert events.count(MOTION_HELD_BACK) == 0 + + # The reset discards the module's thresholds, so the next write is held back and re-queried + reset_sent = events.next_line_with(FACTORY_RESET) + client.button_command(require_entity(entities, "factory_reset", ButtonInfo).key) + try: + await asyncio.wait_for(reset_sent, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for the factory reset command") + + held_back = events.next_line_with(MOTION_HELD_BACK) + written_after_reset = events.next_line_with(expected_motion_after_reset) + client.number_command(numbers["gate_0_move_threshold"].key, 50.0) + try: + await asyncio.wait_for( + asyncio.gather(held_back, written_after_reset), timeout=5.0 + ) + except TimeoutError: + pytest.fail( + "Timeout waiting for the write after the factory reset.\n" + f" expected motion payload: {expected_motion_after_reset}\n" + f" logged: {events.lines}" + ) + reset_at = events.index_after(-1, FACTORY_RESET) + warning_at = events.index_after(reset_at, MOTION_HELD_BACK) + query_at = events.index_after(warning_at, MOTION_QUERY) + events.index_after(query_at, expected_motion_after_reset) + + entities_after, _ = await client.list_entities_services() + assert len(entities_after) == len(entities) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_truncated_answer( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a threshold answer merged with the next frame is dropped and the parser recovers.""" + loop = asyncio.get_running_loop() + + dropped = loop.create_future() + warnings: list[str] = [] + + def line_callback(line: str) -> None: + if "[W][ld2412" in line: + warnings.append(line) + if "Dropping gate threshold answer" in line and not dropped.done(): + dropped.set_result(True) + + # Gate bytes of the truncated answer + merged_values = set(range(0x30, 0x3C)) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + numbers = _threshold_numbers(entities) + + states: dict[int, list[float]] = {} + waiter = StateWaiter() + gate_0_read = waiter.expect( + _number_state_of(numbers["gate_0_move_threshold"].key), + label="gate 0 move threshold", + ) + gate_5_read = waiter.expect( + _number_state_of(numbers["gate_5_move_threshold"].key), + label="gate 5 move threshold", + ) + + def record(state: EntityState) -> None: + if isinstance(state, NumberState) and not state.missing_state: + states.setdefault(state.key, []).append(state.state) + waiter.on_state(state) + + initial_state_helper = await _wait_initial(client, entities, record) + + # The late answer may already have been parsed when the client connected + for state in initial_state_helper.initial_states.values(): + record(state) + + try: + _, gate_0_state, gate_5_state = await asyncio.gather( + asyncio.wait_for(dropped, timeout=5.0), gate_0_read, gate_5_read + ) + except TimeoutError: + pytest.fail( + "Timeout waiting for the threshold answers.\n" + f" ld2412 warnings: {warnings}\n" + f" states: {states}" + ) + + assert gate_0_state.state == pytest.approx(10.0) + assert gate_5_state.state == pytest.approx(15.0) + + for number in numbers.values(): + for value in states.get(number.key, []): + assert int(value) not in merged_values, ( + f"{number.object_id} took {value} from the merged frame" + ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_requery_after_giveup( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a write held back until the module reports its thresholds: given up once, then sent.""" + # Gate 0 takes the module value 25 (0x19), gate 5 the user value 30 (0x1E) + expected_motion = "19:0B:0C:0D:0E:1E:10:11:12:13:14:15:16:17" + events = _LogEvents(MOTION_HELD_BACK, MOTION_GIVEN_UP) + + async with ( + run_compiled(yaml_config, line_callback=events.on_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + numbers = _threshold_numbers(entities) + gate_0_key = numbers["gate_0_move_threshold"].key + gate_5_key = numbers["gate_5_move_threshold"].key + + gate_5_values: list[float] = [] + waiter = StateWaiter() + + def on_state(state: EntityState) -> None: + if _number_state_of(gate_5_key)(state): + gate_5_values.append(state.state) + waiter.on_state(state) + + initial_state_helper = await _wait_initial(client, entities, on_state) + + for key in (gate_0_key, gate_5_key): + assert initial_state_helper.initial_states[key].missing_state + + held_back = events.next_line_with(MOTION_HELD_BACK) + config_mode_left = events.next_line_with(CONFIG_MODE_LEFT) + given_up = events.next_line_with(MOTION_GIVEN_UP) + unknown = [ + waiter.expect(_is_nan_of(key), label="NaN") + for key in (gate_0_key, gate_5_key) + ] + client.number_command(gate_5_key, 20.0) + try: + await asyncio.wait_for( + asyncio.gather(held_back, config_mode_left), timeout=5.0 + ) + except TimeoutError: + pytest.fail(f"Timeout waiting for the write to be handled: {events.lines}") + # The unanswered query times out after 2 s + try: + await asyncio.wait_for(given_up, timeout=3.0) + await asyncio.gather(*unknown) + except TimeoutError as err: + pytest.fail(f"Timeout waiting for the first write to be given up: {err}") + + warning_at = events.index_after(-1, MOTION_HELD_BACK) + left_at = events.index_after(warning_at, CONFIG_MODE_LEFT) + write_window = events.lines[warning_at:left_at] + assert any(MOTION_QUERY in line for line in write_window), ( + "motion thresholds were not queried again after the command was held back" + ) + assert not any(STILL_QUERY in line for line in write_window), ( + "still thresholds were queried for a motion write" + ) + assert events.count(MOTION_COMMAND) == 0, ( + "motion threshold command was sent without a value for the gates that have no number" + ) + + held_back_again = events.next_line_with(MOTION_HELD_BACK) + motion_written = events.next_line_with(expected_motion) + gate_0_refilled = waiter.expect( + lambda s: _number_state_of(gate_0_key)(s) and not math.isnan(s.state), + label="gate 0 move threshold from the module", + ) + gate_5_values.clear() + client.number_command(gate_5_key, 30.0) + try: + await asyncio.wait_for( + asyncio.gather(held_back_again, motion_written), timeout=5.0 + ) + gate_0_state = await gate_0_refilled + except TimeoutError as err: + pytest.fail( + f"Timeout waiting for the second write: {err}\n" + f" expected motion payload: {expected_motion}\n" + f" logged: {events.lines}" + ) + + warning_at = events.index_after(left_at, MOTION_HELD_BACK) + query_at = events.index_after(warning_at, MOTION_QUERY) + write_at = events.index_after(query_at, expected_motion) + assert MOTION_COMMAND in events.lines[write_at - 1] + + # One query each from setup, the given-up write and the second write + assert events.count(MOTION_QUERY) == 3 + assert events.count(STILL_COMMAND) == 0, ( + "still threshold command was sent for a motion write" + ) + + assert gate_0_state.state == pytest.approx(25.0) + assert gate_5_values + assert all(value == pytest.approx(30.0) for value in gate_5_values) From 681b48165432c78f61fe89f16f369d23f7557229 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 00:02:00 -1000 Subject: [PATCH 034/651] [tests] Reset shared App state before every host test (#20510) --- tests/components/main.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/main.cpp b/tests/components/main.cpp index aa0ceb5abe6..f1ceb732f91 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -1,6 +1,7 @@ #include #include "esphome/components/logger/logger.h" +#include "esphome/core/application.h" /* This special main.cpp replaces the default one. @@ -19,6 +20,11 @@ void original_setup() { // =========== AUTO GENERATED CODE END ============ } +// The loop clock and current component one test moves must not leak into the next, like CORE.reset() in Python +class ResetAppListener : public ::testing::EmptyTestEventListener { + void OnTestStart(const ::testing::TestInfo &) override { esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; } +}; + void setup() { // Log functions call global_logger->log_vprintf_() without a null check, // so we must set up a Logger before any test that triggers logging. @@ -27,6 +33,7 @@ void setup() { test_logger.pre_setup(); ::testing::InitGoogleTest(); + ::testing::UnitTest::GetInstance()->listeners().Append(new ResetAppListener); int exit_code = RUN_ALL_TESTS(); // A test folder that never reached the build would otherwise pass as an empty run if (::testing::UnitTest::GetInstance()->total_test_count() == 0) { From b6096c9437854e41f954a7c94745432e58dc2f6d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:04:57 +1100 Subject: [PATCH 035/651] [ci] Reset sdkconfig after ESP-IDF component discovery (#20511) Co-authored-by: Claude Opus 5.5 --- esphome/espidf/toolchain.py | 9 ++++++- tests/unit_tests/test_espidf_toolchain.py | 29 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 871e74d36d3..698080eebc2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -353,7 +353,11 @@ def _clean_foreign_target_tree() -> None: clean_build(clear_pio_cache=False) if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): return - # Restart sdkconfig from ESPHome's options + _reset_sdkconfig() + + +def _reset_sdkconfig() -> None: + """Restart the project sdkconfig from ESPHome's own options.""" internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal") if internal.is_file(): shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}")) @@ -656,6 +660,9 @@ def _configure_project(verbose: bool = False) -> int: if not discovered: _LOGGER.error("Component discovery found no built-in ESP-IDF components") return 1 + # The discovery configure builds every IDF component, and their Kconfig + # selects (e.g. esp-tls turning TLS on) would otherwise stay in sdkconfig + _reset_sdkconfig() if (rc := _write_project_and_reconfigure(discovered, verbose)) != 0: _LOGGER.error("Reconfigure with discovered components failed") return rc diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index c73858ed529..3cba1a7357d 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -608,6 +608,35 @@ def test_run_compile_cache_miss_discovers_and_saves(setup_core: Path) -> None: ] +def test_run_compile_discovery_resets_sdkconfig(setup_core: Path) -> None: + """Options the discovery configure forced on (e.g. esp-tls selecting TLS) + are dropped before the configure with the real component list.""" + _setup_build(setup_core) + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + internal.parent.mkdir(parents=True) + internal.write_text("CONFIG_MBEDTLS_TLS_ENABLED=n\n") + sdkconfig = CORE.relative_build_path("sdkconfig.test") + seen: list[str] = [] + + def record_write(minimal: bool = False, builtin_components=None) -> None: + if minimal: + sdkconfig.write_text("CONFIG_MBEDTLS_TLS_ENABLED=y\n") + else: + seen.append(sdkconfig.read_text()) + + with ( + patch.object(toolchain, "load_cached_builtin_components", return_value=None), + patch.object(toolchain, "save_cached_builtin_components"), + patch( + "esphome.build_gen.espidf.get_available_components", return_value=["lwip"] + ), + patch("esphome.build_gen.espidf.write_project", side_effect=record_write), + patch.object(toolchain, "run_reconfigure", return_value=0), + ): + assert toolchain._configure_project() == 0 + assert seen == ["CONFIG_MBEDTLS_TLS_ENABLED=n\n"] + + def test_run_compile_discovery_failure_stops_before_full_write( setup_core: Path, ) -> None: From 3e7fafb7051bc84860db9d6cadca22d591b3eea7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 00:09:55 -1000 Subject: [PATCH 036/651] [daikin] Prefix the PendingComfort enumerators (#20512) --- esphome/components/daikin/daikin.cpp | 14 ++++++++------ esphome/components/daikin/daikin.h | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 0194b34db09..8b8d16677a1 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -266,9 +266,10 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { // Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid // A comfort frame whose own state frame never came must not attach to a later transmission const uint32_t now = App.get_loop_component_start_time(); - const PendingComfort comfort = - now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS ? this->pending_comfort_ : PendingComfort::UNKNOWN; - this->pending_comfort_ = PendingComfort::UNKNOWN; + const PendingComfort comfort = now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS + ? this->pending_comfort_ + : PendingComfort::PENDING_COMFORT_UNKNOWN; + this->pending_comfort_ = PendingComfort::PENDING_COMFORT_UNKNOWN; if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) { this->preset = climate::CLIMATE_PRESET_BOOST; } else if (frame[13] & DAIKIN_PRESET_QUIET_ON) { @@ -277,9 +278,9 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { this->preset = climate::CLIMATE_PRESET_ECO; } else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) { this->preset = climate::CLIMATE_PRESET_ACTIVITY; - } else if (comfort == PendingComfort::ON) { + } else if (comfort == PendingComfort::PENDING_COMFORT_ON) { this->preset = climate::CLIMATE_PRESET_COMFORT; - } else if (comfort == PendingComfort::OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { + } else if (comfort == PendingComfort::PENDING_COMFORT_OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { // Without a comfort frame in this transmission a current comfort preset is left alone this->preset = climate::CLIMATE_PRESET_NONE; } @@ -337,7 +338,8 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) { // Nothing changes yet: the state frame that follows applies it once it has passed its own checksum - this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::ON : PendingComfort::OFF; + this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::PENDING_COMFORT_ON + : PendingComfort::PENDING_COMFORT_OFF; this->pending_comfort_at_ = App.get_loop_component_start_time(); return true; } diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index 7f8638101d5..3124ba0145e 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -82,8 +82,8 @@ class DaikinClimate final : public climate_ir::ClimateIR { bool parse_state_frame_(const uint8_t frame[]); /// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame - enum class PendingComfort : uint8_t { UNKNOWN, OFF, ON }; - PendingComfort pending_comfort_{PendingComfort::UNKNOWN}; + enum class PendingComfort : uint8_t { PENDING_COMFORT_UNKNOWN, PENDING_COMFORT_OFF, PENDING_COMFORT_ON }; + PendingComfort pending_comfort_{PendingComfort::PENDING_COMFORT_UNKNOWN}; uint32_t pending_comfort_at_{0}; }; From 2681126986199d41a9b0f3a64bb8f67c37d89c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:31:14 +0200 Subject: [PATCH 037/651] [teleinfo] Fix out-of-bounds read when splitting a frame into groups (#20513) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 6b68dacd31b..adf56707844 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -116,8 +116,7 @@ void TeleInfo::loop() { * 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd * */ - while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_index_ - 1))) && - ((buf_finger - buf_) < buf_index_)) { // NOLINT(clang-diagnostic-sign-compare) + while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { /* * Make sure timesamp is nullified between each tag as some tags don't * have a timestamp @@ -168,8 +167,8 @@ void TeleInfo::loop() { continue; } - /* Advance buf_finger to end of group */ - buf_finger += field_len + 1 + 1 + 1; + /* Resume after the group's 0xd; grp_end < buf_end, so this stays in bounds. */ + buf_finger = grp_end + 1; publish_value_(std::string(tag_), std::string(val_)); } From 1402cb7b64a1f1d3adfe4f62b04abdbfd321efb8 Mon Sep 17 00:00:00 2001 From: Georg Seema Date: Sun, 11 Oct 2026 13:32:39 +0300 Subject: [PATCH 038/651] [tuya] Add forced minute boundary time sync (#18603) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/tuya/__init__.py | 25 ++++++++-- esphome/components/tuya/tuya.cpp | 46 +++++++++++++++---- esphome/components/tuya/tuya.h | 7 ++- tests/component_tests/tuya/__init__.py | 0 tests/component_tests/tuya/test_init.py | 23 ++++++++++ .../tuya/test-force-time-sync.esp32-idf.yaml | 16 +++++++ 6 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 tests/component_tests/tuya/__init__.py create mode 100644 tests/component_tests/tuya/test_init.py create mode 100644 tests/components/tuya/test-force-time-sync.esp32-idf.yaml diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index bf392c78268..c05db51cdf9 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -4,6 +4,7 @@ from esphome.components import time, uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["uart"] DOMAIN = "tuya" @@ -13,6 +14,7 @@ CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS = "ignore_mcu_update_on_datapoints" CONF_ON_DATAPOINT_UPDATE = "on_datapoint_update" CONF_DATAPOINT_TYPE = "datapoint_type" CONF_STATUS_PIN = "status_pin" +CONF_FORCE_TIME_SYNC = "force_time_sync" tuya_ns = cg.esphome_ns.namespace("tuya") TuyaDatapointType = tuya_ns.enum("TuyaDatapointType", is_class=True) @@ -43,11 +45,23 @@ DATAPOINT_TYPES = { CONF_TUYA_ID = "tuya_id" -CONFIG_SCHEMA = ( + + +def _validate_time_options(config: ConfigType) -> ConfigType: + if config[CONF_FORCE_TIME_SYNC] and CONF_TIME_ID not in config: + raise cv.Invalid( + f"{CONF_FORCE_TIME_SYNC} requires {CONF_TIME_ID}", + path=[CONF_FORCE_TIME_SYNC], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Tuya), cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Optional(CONF_FORCE_TIME_SYNC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS): cv.ensure_list( cv.uint8_t ), @@ -63,7 +77,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + _validate_time_options, ) @@ -71,9 +86,11 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_TIME_ID in config: - time_ = await cg.get_variable(config[CONF_TIME_ID]) + if (time_id := config.get(CONF_TIME_ID)) is not None: + time_ = await cg.get_variable(time_id) cg.add(var.set_time_id(time_)) + if config[CONF_FORCE_TIME_SYNC]: + cg.add(var.set_force_time_sync(True)) if CONF_STATUS_PIN in config: status_pin_ = await cg.gpio_pin_expression(config[CONF_STATUS_PIN]) cg.add(var.set_status_pin(status_pin_)) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index b23dcc946bd..c6316f59fce 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -23,11 +23,13 @@ ESPHOME_LOG_TAG(TAG, "tuya"); static constexpr uint32_t HEARTBEAT_INTERVAL_ID = 0; static constexpr uint32_t WIFI_STATUS_INTERVAL_ID = 1; static constexpr uint32_t DATAPOINT_DUMP_TIMEOUT_ID = 2; +static constexpr uint32_t FORCE_TIME_SYNC_INTERVAL_ID = 3; static const int COMMAND_DELAY = 10; static const int RECEIVE_TIMEOUT = 300; static const int MAX_RETRIES = 5; // Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; +static constexpr uint32_t FORCE_TIME_SYNC_CHECK_INTERVAL = 1000; static bool network_is_connected() { #ifdef USE_NETWORK @@ -39,6 +41,13 @@ static bool network_is_connected() { void Tuya::setup() { this->set_interval(HEARTBEAT_INTERVAL_ID, 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); +#ifdef USE_TIME + if (this->force_time_sync_) { + this->register_local_time_sync_callback_(); + this->set_interval(FORCE_TIME_SYNC_INTERVAL_ID, FORCE_TIME_SYNC_CHECK_INTERVAL, + [this] { this->check_force_time_sync_(); }); + } +#endif if (this->status_pin_ != nullptr) { this->status_pin_->digital_write(false); } @@ -99,6 +108,11 @@ void Tuya::dump_config() { } LOG_PIN(" Status Pin: ", this->status_pin_); ESP_LOGCONFIG(TAG, " Product: '%s'", this->product_.c_str()); +#ifdef USE_TIME + if (this->time_id_ != nullptr) { + ESP_LOGCONFIG(TAG, " Force time sync: %s", YESNO(this->force_time_sync_)); + } +#endif } bool Tuya::validate_message_() { @@ -306,12 +320,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff #ifdef USE_TIME if (this->time_id_ != nullptr) { this->send_local_time_(); - - if (!this->time_sync_callback_registered_) { - // tuya mcu supports time, so we let them know when our time changed - this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); - this->time_sync_callback_registered_ = true; - } + this->register_local_time_sync_callback_(); } else #endif { @@ -616,7 +625,23 @@ void Tuya::send_wifi_status_() { } #ifdef USE_TIME -void Tuya::send_local_time_() { +void Tuya::register_local_time_sync_callback_() { + if (this->time_sync_callback_registered_) { + return; + } + this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); + this->time_sync_callback_registered_ = true; +} + +void Tuya::check_force_time_sync_() { + ESPTime now = this->time_id_->now(); + if (!now.is_valid() || now.minute == this->last_force_time_sync_minute_) + return; + this->last_force_time_sync_minute_ = now.minute; + this->send_local_time_(true); +} + +void Tuya::send_local_time_(bool periodic) { std::vector payload; ESPTime now = this->time_id_->now(); if (now.is_valid()) { @@ -631,7 +656,12 @@ void Tuya::send_local_time_() { if (day_of_week == 0) { day_of_week = 7; } - ESP_LOGD(TAG, "Sending local time"); + // The forced send repeats every minute, so it stays out of the debug log + if (periodic) { + ESP_LOGV(TAG, "Sending local time"); + } else { + ESP_LOGD(TAG, "Sending local time"); + } payload = std::vector{0x01, year, month, day_of_month, hour, minute, second, day_of_week}; } else { // By spec we need to notify MCU that the time was not obtained if this is a response to a query diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index f074d301629..1e2e12200dd 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -113,6 +113,7 @@ class Tuya final : public Component, public uart::UARTDevice { TuyaInitState get_init_state(); #ifdef USE_TIME void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; } + void set_force_time_sync(bool force_time_sync) { this->force_time_sync_ = force_time_sync; } #endif void add_ignore_mcu_update_on_datapoints(uint8_t ignore_mcu_update_on_datapoints) { this->ignore_mcu_update_on_datapoints_.push_back(ignore_mcu_update_on_datapoints); @@ -144,9 +145,13 @@ class Tuya final : public Component, public uart::UARTDevice { uint8_t get_wifi_rssi_(); #ifdef USE_TIME - void send_local_time_(); + void register_local_time_sync_callback_(); + void check_force_time_sync_(); + void send_local_time_(bool periodic = false); void send_gmt_time_(); time::RealTimeClock *time_id_{nullptr}; + bool force_time_sync_{false}; + uint8_t last_force_time_sync_minute_{0xFF}; // 0xFF: no minute seen yet bool time_sync_callback_registered_{false}; bool gmt_time_sync_callback_registered_{false}; #endif diff --git a/tests/component_tests/tuya/__init__.py b/tests/component_tests/tuya/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/tuya/test_init.py b/tests/component_tests/tuya/test_init.py new file mode 100644 index 00000000000..6984b9aa1b4 --- /dev/null +++ b/tests/component_tests/tuya/test_init.py @@ -0,0 +1,23 @@ +"""Tests for Tuya time synchronization configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.tuya import CONF_FORCE_TIME_SYNC, CONFIG_SCHEMA +from esphome.const import CONF_TIME_ID + + +def test_force_time_sync_accepts_time_id() -> None: + config = CONFIG_SCHEMA( + { + CONF_FORCE_TIME_SYNC: True, + CONF_TIME_ID: "tuya_time", + } + ) + + assert config[CONF_FORCE_TIME_SYNC] is True + + +def test_force_time_sync_requires_time_id() -> None: + with pytest.raises(cv.Invalid, match="force_time_sync requires time_id"): + CONFIG_SCHEMA({CONF_FORCE_TIME_SYNC: True}) diff --git a/tests/components/tuya/test-force-time-sync.esp32-idf.yaml b/tests/components/tuya/test-force-time-sync.esp32-idf.yaml new file mode 100644 index 00000000000..df0fe110b44 --- /dev/null +++ b/tests/components/tuya/test-force-time-sync.esp32-idf.yaml @@ -0,0 +1,16 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + status_pin: GPIO12 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + common: !include common.yaml + +time: + - platform: sntp + id: tuya_time + +tuya: + time_id: tuya_time + force_time_sync: true From 3df03e4be962687ddc6feeb1c8184534a0c526eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:31:07 +0200 Subject: [PATCH 039/651] [emontx] Read UART bytes with read_byte() (#20514) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/emontx.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index ae92b737316..17497984c39 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -18,7 +18,9 @@ void EmonTx::setup() { this->buffer_pos_ = 0; } void EmonTx::loop() { // Read all available data to prevent UART buffer overflow while (this->available() > 0) { - uint8_t received = this->read(); + uint8_t received; + if (!this->read_byte(&received)) + break; if (received == '\r') { continue; // Ignore CR From 6001cde2342aecea184d16824cf9b0472a46484a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:41:02 +0200 Subject: [PATCH 040/651] [emontx] Discard the rest of a line that overflows the buffer (#20515) Co-authored-by: Claude Opus 5.5 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/emontx/emontx.cpp | 13 +++-- esphome/components/emontx/emontx.h | 1 + tests/components/emontx/common.h | 45 +++++++++++++++ tests/components/emontx/emontx_test.cpp | 75 +++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 tests/components/emontx/common.h create mode 100644 tests/components/emontx/emontx_test.cpp diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 17497984c39..675744b5f58 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -22,11 +22,11 @@ void EmonTx::loop() { if (!this->read_byte(&received)) break; - if (received == '\r') { - continue; // Ignore CR - } else if (received == '\n') { + if (received == '\n') { // End of line - process the buffer - if (this->buffer_pos_ > 0) { + if (this->discarding_line_) { + this->discarding_line_ = false; + } else if (this->buffer_pos_ > 0) { // Null-terminate for safe logging and c_str() use size_t len = this->buffer_pos_; this->buffer_[len] = '\0'; @@ -44,9 +44,12 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } } + } else if (received == '\r' || this->discarding_line_) { + // Ignore CR, and the rest of a line that overflowed the buffer } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { - ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding buffer", MAX_LINE_LENGTH); + ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); this->buffer_pos_ = 0; + this->discarding_line_ = true; } else { this->buffer_[this->buffer_pos_++] = static_cast(received); } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index c56005e7ad8..f97c5aa6cb6 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -55,6 +55,7 @@ class EmonTx final : public Component, public uart::UARTDevice { LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; uint16_t buffer_pos_{0}; + bool discarding_line_{false}; // Set after an overflow until the end of that line std::array buffer_{}; }; diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h new file mode 100644 index 00000000000..4af5a98572d --- /dev/null +++ b/tests/components/emontx/common.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/emontx/emontx.h" + +namespace esphome::emontx::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(const char *data) { this->rx.insert(this->rx.end(), data, data + strlen(data)); } + void push_rx(size_t count, uint8_t byte) { this->rx.insert(this->rx.end(), count, byte); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +} // namespace esphome::emontx::testing diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp new file mode 100644 index 00000000000..373b54d7857 --- /dev/null +++ b/tests/components/emontx/emontx_test.cpp @@ -0,0 +1,75 @@ +#include + +#include +#include + +#include "esphome/components/emontx/emontx.h" +#include "common.h" + +namespace esphome::emontx::testing { + +namespace { +class EmonTxTest : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); }); + this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; }); + } + + MockUARTComponent uart_; + EmonTx sut_; + std::vector lines_; + int json_count_{0}; +}; +} // namespace + +TEST_F(EmonTxTest, LinePassedToDataCallbacks) { + this->uart_.push_rx("MSG:1\r\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "MSG:1"); + EXPECT_EQ(this->json_count_, 0); +} + +TEST_F(EmonTxTest, JsonLineParsed) { + this->uart_.push_rx("{\"P1\":100}\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->json_count_, 1); +} + +TEST_F(EmonTxTest, OverflowedLineDiscardedUntilLineFeed) { + // One byte more than the buffer holds, then more of the same line. + this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); + this->uart_.push_rx("tail\n"); + this->uart_.push_rx("MSG:2\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "MSG:2"); +} + +TEST_F(EmonTxTest, OverflowedLineTailNotParsedAsJson) { + // The tail after the overflow looks like a JSON line and must not be parsed. + this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); + this->uart_.push_rx("{\"P1\":100}\n"); + this->sut_.loop(); + + EXPECT_TRUE(this->lines_.empty()); + EXPECT_EQ(this->json_count_, 0); +} + +TEST_F(EmonTxTest, OverflowAcrossLoopCalls) { + this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); + this->sut_.loop(); + this->uart_.push_rx("tail\nMSG:3\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "MSG:3"); +} + +} // namespace esphome::emontx::testing From 493db0c2bb750a2fced3fadad0d0aabb0f0c87e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 02:45:19 -1000 Subject: [PATCH 041/651] [esp32] Add allow_insecure_tls() for components that skip certificate verification (#20506) --- esphome/components/esp32/__init__.py | 11 +++++++++++ esphome/components/http_request/__init__.py | 10 ++-------- esphome/components/nextion/display.py | 5 +---- tests/component_tests/esp32/test_esp32.py | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9bceaf5676d..422413a269f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2071,6 +2071,17 @@ def require_vfs_termios() -> None: CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True +def allow_insecure_tls() -> None: + """Let esp-tls connect without verifying the server certificate. + + The two sdkconfig flags are global, so a component asking for an unverified + connection switches them on for the whole build; they are never switched off + here because another component may have asked first. + """ + add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) + add_idf_sdkconfig_option("CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True) + + def require_certificate_bundle() -> None: """Enable the mbedTLS root certificate bundle for this build. diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index ad51d5f39be..a2c181a5ae0 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -226,14 +226,8 @@ async def to_code(config: ConfigType) -> None: # use_full_certificate_bundle: true esp32.require_certificate_bundle() - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_INSECURE", - not config.get(CONF_VERIFY_SSL), - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", - not config.get(CONF_VERIFY_SSL), - ) + else: + esp32.allow_insecure_tls() if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) if CORE.is_rp2 and CORE.using_arduino: diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 3a70be21caf..983a4777e81 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -261,10 +261,7 @@ async def to_code(config): if CORE.is_esp32: esp32.request_http_client() - esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True - ) + esp32.allow_insecure_tls() elif CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 9072a906293..bb751dd677c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -30,6 +30,7 @@ from esphome.components.esp32 import ( _reconcile_network_sdkconfig, _reconcile_vfs_fatfs_sdkconfig, _user_sdkconfig_wants_tls, + allow_insecure_tls, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -2400,3 +2401,20 @@ def test_nvs_cache_in_psram_explicit_true_on_valid_board_is_quiet( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is True assert "nvs_cache_in_psram" not in caplog.text + + +def test_allow_insecure_tls_only_switches_the_flags_on( + set_core_config: SetCoreConfigCallable, +) -> None: + """A component that verifies certificates must not undo another one's request.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: {}} + + allow_insecure_tls() + # A second caller, or a verifying component that no longer touches the flags, leaves them on + allow_insecure_tls() + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == { + "CONFIG_ESP_TLS_INSECURE": True, + "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY": True, + } From 7f7ae4c8fc7bd7a6d9640ea27b9e33b4332e7d37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 02:47:12 -1000 Subject: [PATCH 042/651] [uart_tcp] Prove the accept has run before checking what a new client receives (#20489) --- tests/integration/test_uart_tcp_bridge.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_uart_tcp_bridge.py b/tests/integration/test_uart_tcp_bridge.py index 33d32f4c07e..d2e1603bd15 100644 --- a/tests/integration/test_uart_tcp_bridge.py +++ b/tests/integration/test_uart_tcp_bridge.py @@ -84,13 +84,16 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 1) + # The accept log precedes the stale discard in the same loop pass, so + # prove the device is past it with a client to UART round trip before + # writing the bytes the client must see. + writer.write(b"down1") + await writer.drain() + assert await read_uart(5) == b"down1" os.write(controller_fd, b"live!") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live!", ( "First bytes to the client were not the live payload" ) - writer.write(b"down1") - await writer.drain() - assert await read_uart(5) == b"down1" # Drop the client; bytes while no client is connected are discarded # when the next one is accepted. @@ -101,13 +104,13 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 2) + writer.write(b"down2") + await writer.drain() + assert await read_uart(5) == b"down2" os.write(controller_fd, b"live2") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live2", ( "Second client received stale bytes from the gap" ) - writer.write(b"down2") - await writer.drain() - assert await read_uart(5) == b"down2" writer.close() # A peer outside the allow list is rejected and closed. From c5cbc4a4e080beb27a6eda36758df97f550d305d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:50:42 +0200 Subject: [PATCH 043/651] [emontx] Test lines of exactly MAX_LINE_LENGTH bytes (#20517) Co-authored-by: Claude Opus 5.5 --- tests/components/emontx/emontx_test.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 373b54d7857..20d3adf0dc0 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -41,6 +41,25 @@ TEST_F(EmonTxTest, JsonLineParsed) { EXPECT_EQ(this->json_count_, 1); } +// The CR check must come before the overflow check, or a full line ending in CRLF would overflow. +TEST_F(EmonTxTest, FullLengthLineWithCrLfDelivered) { + this->uart_.push_rx(MAX_LINE_LENGTH, 'a'); + this->uart_.push_rx("\r\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], std::string(MAX_LINE_LENGTH, 'a')); +} + +TEST_F(EmonTxTest, FullLengthLineWithLfDelivered) { + this->uart_.push_rx(MAX_LINE_LENGTH, 'a'); + this->uart_.push_rx("\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], std::string(MAX_LINE_LENGTH, 'a')); +} + TEST_F(EmonTxTest, OverflowedLineDiscardedUntilLineFeed) { // One byte more than the buffer holds, then more of the same line. this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); From 1312d5eec018cead1926f6e6e5c78cc3a016e365 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 11 Oct 2026 18:51:41 +0200 Subject: [PATCH 044/651] [modbus] Pass the request's address to client device callbacks (#20504) --- esphome/components/modbus/modbus.cpp | 10 +- esphome/components/modbus/modbus.h | 15 +++ .../modbus/modbus_client_hub_test.cpp | 109 ++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 80065546cf7..a79a7c3bd8c 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -950,7 +950,7 @@ bool ModbusDeviceCommand::sent() { // on_sent() is not a terminal, so nothing is consumed. if (this->device == nullptr) return false; - this->device->on_sent(this->frame.pdu()); + this->device->on_sent(this->frame.address(), this->frame.pdu()); return true; } @@ -958,7 +958,7 @@ bool ModbusDeviceCommand::notify_retired() { if (!this->decrement_pending()) return false; // nothing owed - stop the sweep draining this entry if (this->device != nullptr) - this->device->on_not_sent(this->frame.pdu()); + this->device->on_not_sent(this->frame.address(), this->frame.pdu()); return true; // consumed one debt (delivered, or silent when device-less) - keep draining to zero } @@ -969,7 +969,7 @@ bool ModbusDeviceCommand::response(std::span response_pdu) { this->decrement_pending(); if (this->device == nullptr) return false; - this->device->on_response(this->frame.pdu(), response_pdu); + this->device->on_response(this->frame.address(), this->frame.pdu(), response_pdu); return true; } @@ -979,7 +979,7 @@ bool ModbusDeviceCommand::error(ExceptionCode exception_code) { this->decrement_pending(); if (this->device == nullptr) return false; - this->device->on_error(this->frame.pdu(), exception_code); + this->device->on_error(this->frame.address(), this->frame.pdu(), exception_code); return true; } @@ -1004,7 +1004,7 @@ bool ModbusDeviceCommand::timed_out() { return false; // resolved, no one to tell // A cleared frame that timed out still honors a retry: the clear is address-scoped (any device may // call it) while the retry is the owning device's call via on_no_response - the bus obeys the owner. - if (this->device->on_no_response(this->frame.pdu())) + if (this->device->on_no_response(this->frame.address(), this->frame.pdu())) this->increment_pending(); // granted retry = re-request (capped) return true; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 298cd9f5278..011813a20d3 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -437,6 +437,21 @@ class ModbusClientDevice { return this->on_modbus_no_response(); #pragma GCC diagnostic pop } + /// Overloads of the hooks above with the device address of the request first, taken from the request frame, so it + /// stays correct after set_address(). The hub calls these; the defaults forward to the hooks above. + /// Override one form per hook: overriding an address form drops its forward, so the other form is no longer called. + virtual void on_response(uint8_t address, std::span request_pdu, + std::span response_pdu) { + this->on_response(request_pdu, response_pdu); + } + virtual void on_error(uint8_t address, std::span request_pdu, ExceptionCode exception_code) { + this->on_error(request_pdu, exception_code); + } + virtual void on_not_sent(uint8_t address, std::span request_pdu) { this->on_not_sent(request_pdu); } + virtual void on_sent(uint8_t address, std::span request_pdu) { this->on_sent(request_pdu); } + virtual bool on_no_response(uint8_t address, std::span request_pdu) { + return this->on_no_response(request_pdu); + } // Remove before 2027.2.0 ESPDEPRECATED("Override on_not_sent() instead. Removed in 2027.2.0", "2026.8.0") virtual void on_modbus_not_sent() {} diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 3bdfa094e0e..1e351b4971a 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -2436,4 +2436,113 @@ TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) { EXPECT_EQ(next->priority(), CommandPriority::WRITE); // 0x16 wins selection over the queued read EXPECT_EQ(next->frame.pdu()[0], 0x16); } +namespace { +// Overrides only the address overloads and records the address each one is given. +class AddressRecordingDevice : public ModbusClientDevice { + public: + AddressRecordingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(uint8_t address, std::span request_pdu) override { this->sent_ = address; } + void on_response(uint8_t address, std::span request_pdu, + std::span response_pdu) override { + this->response_ = address; + } + void on_error(uint8_t address, std::span request_pdu, ExceptionCode exception_code) override { + this->error_ = address; + } + bool on_no_response(uint8_t address, std::span request_pdu) override { + this->no_response_ = address; + return this->retry_; + } + void on_not_sent(uint8_t address, std::span request_pdu) override { this->not_sent_ = address; } + int sent_{-1}; + int response_{-1}; + int error_{-1}; + int no_response_{-1}; + int not_sent_{-1}; + bool retry_{false}; +}; +} // namespace + +// Each callback gets the address its request was sent to, even when the device has changed its own +// address before the outcome arrives. +TEST(ModbusClientHubAddress, CallbacksReportTheRequestAddressAfterSetAddress) { + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + const uint8_t exception_response[] = {0x83, 0x02}; + { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + AddressRecordingDevice device(&hub, 0x02); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.send_next_for_test(); + EXPECT_EQ(device.sent_, 0x02); + hub.receive_frame_for_test(0x02, ok_response); + EXPECT_EQ(device.response_, 0x02); + } + { + NoResponseProbeHub hub; + AddressRecordingDevice device(&hub, 0x03); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.force_send_next(); + hub.receive_frame_for_test(0x03, exception_response); + EXPECT_EQ(device.error_, 0x03); + } + { + NoResponseProbeHub hub; + AddressRecordingDevice device(&hub, 0x04); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_, 0x04); + } + { + NoResponseProbeHub hub; + AddressRecordingDevice device(&hub, 0x06); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.clear_tx_queue_for_address(0x06); + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_, 0x06); + } +} + +// A granted retry resends the same frame, so the retry's callbacks keep the original address too. +TEST(ModbusClientHubAddress, GrantedRetryKeepsTheRequestAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + AddressRecordingDevice device(&hub, 0x04); + device.retry_ = true; + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.send_next_for_test(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_, 0x04); + + device.sent_ = -1; + hub.send_next_for_test(); + EXPECT_EQ(device.sent_, 0x04); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x04, ok_response); + EXPECT_EQ(device.response_, 0x04); +} + +// A broadcast reports address 0 from on_sent, its only callback. +TEST(ModbusClientHubAddress, BroadcastReportsAddressZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + AddressRecordingDevice device(&hub, BROADCAST_ADDRESS); + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.queue_pdu(write)); + device.set_address(0x05); + hub.send_next_for_test(); + EXPECT_EQ(device.sent_, BROADCAST_ADDRESS); +} } // namespace esphome::modbus::testing From 5186728d53d0e930adc5db8f59ab7acedc18bdff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:52:41 +0200 Subject: [PATCH 045/651] [emontx] Pass on_json/on_data data without copies and reject deferring actions (#20516) --- esphome/components/emontx/__init__.py | 24 +++++++-- .../emontx/test_synchronous_automations.py | 54 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/emontx/test_synchronous_automations.py diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 0262f8681f1..971f9769246 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -44,13 +44,29 @@ def _get_data() -> EmonTxData: return CORE.data[DOMAIN] +def _synchronous_automation(value: ConfigType) -> ConfigType: + """Reject deferring actions: the received line and parsed JSON are only valid while the + automation runs, and DelayAction and friends keep the trigger arguments for later.""" + if automation.has_non_synchronous_actions(value): + raise cv.Invalid( + "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in on_json and " + "on_data: the received data is only valid while the automation runs. Copy what you need " + "into globals first, then defer in a separate script or automation." + ) + return value + + +def _synchronous_automation_schema() -> cv.All: + return cv.All(automation.validate_automation({}), _synchronous_automation) + + # Main configuration schema CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(EmonTx), - cv.Optional(CONF_ON_JSON): automation.validate_automation({}), - cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), + cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), } ) .extend(cv.COMPONENT_SCHEMA) @@ -104,10 +120,10 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_JSON, "add_on_json_callback", - [(cg.JsonObject, "json"), (cg.std_string, "raw_json")], + [(cg.JsonObject, "json"), (cg.StringRef, "raw_json")], ), automation.CallbackAutomation( - CONF_ON_DATA, "add_on_data_callback", [(cg.std_string, "data")] + CONF_ON_DATA, "add_on_data_callback", [(cg.StringRef, "data")] ), ) diff --git a/tests/component_tests/emontx/test_synchronous_automations.py b/tests/component_tests/emontx/test_synchronous_automations.py new file mode 100644 index 00000000000..c190c14ab19 --- /dev/null +++ b/tests/component_tests/emontx/test_synchronous_automations.py @@ -0,0 +1,54 @@ +"""Tests that emontx on_json/on_data automations reject deferring actions. + +The triggers pass views into the receive buffer and the parsed JSON document, which are only valid +while the automation runs, so the deferring-actions guard is a safety property. +""" + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.emontx import CONF_ON_JSON, CONFIG_SCHEMA +from esphome.const import CONF_ON_DATA +from esphome.core import Lambda +from esphome.types import ConfigType + +TRIGGER_KEYS = [CONF_ON_JSON, CONF_ON_DATA] + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _trigger_schema(trigger_key: str) -> Callable[[list[ConfigType]], list[ConfigType]]: + """The validator CONFIG_SCHEMA applies to this trigger key.""" + return CONFIG_SCHEMA.schema[cv.Optional(trigger_key)] + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_synchronous_automation_accepted(trigger_key: str) -> None: + _trigger_schema(trigger_key)([{"then": [SYNCHRONOUS_ACTION]}]) + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_deferring_action_rejected(trigger_key: str) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(trigger_key)([{"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}]) + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_nested_deferring_action_rejected(trigger_key: str) -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(trigger_key)([{"then": actions}]) + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_deferring_action_in_second_automation_rejected(trigger_key: str) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(trigger_key)( + [{"then": [SYNCHRONOUS_ACTION]}, {"then": [DEFERRING_ACTION]}] + ) From e9073f7298f70cf85efb095a7898a4672511b3e0 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:38:37 +0000 Subject: [PATCH 046/651] [systa_bus] Add text_sensor (#20518) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/systa_bus/__init__.py | 2 + .../components/systa_bus/sensor/__init__.py | 9 +++- esphome/components/systa_bus/systa_bus.h | 3 +- .../systa_bus/text_sensor/__init__.py | 45 +++++++++++++++++++ .../text_sensor/systa_bus_text_sensor.cpp | 27 +++++++++++ .../text_sensor/systa_bus_text_sensor.h | 20 +++++++++ esphome/core/defines.h | 2 +- tests/components/systa_bus/common.yaml | 6 +++ 8 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 esphome/components/systa_bus/text_sensor/__init__.py create mode 100644 esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp create mode 100644 esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py index 49ac214d400..ae44017112b 100644 --- a/esphome/components/systa_bus/__init__.py +++ b/esphome/components/systa_bus/__init__.py @@ -17,6 +17,8 @@ SystaBus = systa_bus_ns.class_("SystaBus", cg.Component, uart.UARTDevice) CONF_SYSTA_BUS_ID = "systa_bus_id" +CONF_SYSTASOLAR_AQUA = "systasolar_aqua" + CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(SystaBus), diff --git a/esphome/components/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py index 3def5fba12f..e4aa0684c78 100644 --- a/esphome/components/systa_bus/sensor/__init__.py +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -13,11 +13,16 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_SYSTA_BUS_ID, SystaBus, register_systa_bus_listener, systa_bus_ns +from .. import ( + CONF_SYSTA_BUS_ID, + CONF_SYSTASOLAR_AQUA, + SystaBus, + register_systa_bus_listener, + systa_bus_ns, +) SystaSolarAquaSensor = systa_bus_ns.class_("SystaSolarAquaSensor", cg.Component) -CONF_SYSTASOLAR_AQUA = "systasolar_aqua" CONF_TEMPERATURE_TSA = "temperature_tsa" CONF_TEMPERATURE_TSE = "temperature_tse" CONF_TEMPERATURE_TWU = "temperature_twu" diff --git a/esphome/components/systa_bus/systa_bus.h b/esphome/components/systa_bus/systa_bus.h index 99ca31590f5..b46f769db00 100644 --- a/esphome/components/systa_bus/systa_bus.h +++ b/esphome/components/systa_bus/systa_bus.h @@ -14,7 +14,8 @@ static constexpr uint8_t START_BYTE = 0xfc; static constexpr uint8_t FRAME_OVERHEAD = 3; // start byte, length, checksum // SystaSolar Aqua sensor data, 25 bytes: [0..1] type, [2..3] not decoded, big-endian int16 in 0.1 degrees at -// [4..5] TSA, [6..7] TSE, [8..9] TWU and [10..11] TW2, pump speed in percent at [12], [13..23] not decoded. +// [4..5] TSA, [6..7] TSE, [8..9] TWU and [10..11] TW2, pump speed in percent at [12], [13..14] not decoded, +// [15] error code (zero for no error), [16..23] not decoded. static constexpr uint16_t MESSAGE_TYPE_AQUA_SENSOR_DATA = 0xfc16; // The bus only buffers known message types, so the buffer is sized by the longest of them diff --git a/esphome/components/systa_bus/text_sensor/__init__.py b/esphome/components/systa_bus/text_sensor/__init__.py new file mode 100644 index 00000000000..4b5cb52b087 --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODEL +from esphome.types import ConfigType + +from .. import ( + CONF_SYSTA_BUS_ID, + CONF_SYSTASOLAR_AQUA, + SystaBus, + register_systa_bus_listener, + systa_bus_ns, +) + +SystaSolarAquaTextSensor = systa_bus_ns.class_("SystaSolarAquaTextSensor", cg.Component) + +CONF_ERROR_CODE = "error_code" + + +CONFIG_SCHEMA = cv.typed_schema( + { + CONF_SYSTASOLAR_AQUA: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaSolarAquaTextSensor), + cv.GenerateID(CONF_SYSTA_BUS_ID): cv.use_id(SystaBus), + cv.Optional(CONF_ERROR_CODE): text_sensor.text_sensor_schema(), + } + ), + }, + key=CONF_MODEL, + lower=True, + space="_", +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if config[CONF_MODEL] == CONF_SYSTASOLAR_AQUA and CONF_ERROR_CODE in config: + sens = await text_sensor.new_text_sensor(config[CONF_ERROR_CODE]) + cg.add(var.set_error_code_text_sensor(sens)) + + systa_bus = await cg.get_variable(config[CONF_SYSTA_BUS_ID]) + await register_systa_bus_listener(systa_bus, var) diff --git a/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp new file mode 100644 index 00000000000..cba3caa8a6d --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp @@ -0,0 +1,27 @@ +#include "systa_bus_text_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::systa_bus { + +ESPHOME_LOG_TAG(TAG, "systa_bus.text_sensor"); + +void SystaSolarAquaTextSensor::dump_config() { + ESP_LOGCONFIG(TAG, "SystaSolar Aqua:"); + LOG_TEXT_SENSOR(" ", "Error code", this->error_code_text_sensor_); +} + +void SystaSolarAquaTextSensor::handle_message(std::span message) { + if (get_message_type(message) == MESSAGE_TYPE_AQUA_SENSOR_DATA) { + if (this->error_code_text_sensor_ != nullptr) { + if (message[15] == 0) { + this->error_code_text_sensor_->publish_state("--"); + } else { + char buf[4]; + int len = snprintf(buf, sizeof(buf), "%02d", message[15]); + this->error_code_text_sensor_->publish_state(buf, static_cast(len)); + } + } + } +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h new file mode 100644 index 00000000000..f0c12b5dce5 --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "../systa_bus.h" +#include "esphome/components/text_sensor/text_sensor.h" + +#include + +namespace esphome::systa_bus { + +class SystaSolarAquaTextSensor : public SystaBusListener, public Component { + public: + void dump_config() override; + void set_error_code_text_sensor(text_sensor::TextSensor *sensor) { this->error_code_text_sensor_ = sensor; } + void handle_message(std::span message) override; + + protected: + text_sensor::TextSensor *error_code_text_sensor_{nullptr}; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0e5e2f4f292..de621f891a1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -188,7 +188,7 @@ #define USE_REMOTE_PROTOCOL_SYMPHONY #define USE_REMOTE_PROTOCOL_TOSHIBA_AC #define USE_REMOTE_PROTOCOL_TOTO -#define SYSTA_BUS_LISTENER_COUNT 1 +#define SYSTA_BUS_LISTENER_COUNT 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/tests/components/systa_bus/common.yaml b/tests/components/systa_bus/common.yaml index ea97e2d840b..b4ae2410fde 100644 --- a/tests/components/systa_bus/common.yaml +++ b/tests/components/systa_bus/common.yaml @@ -13,3 +13,9 @@ sensor: name: TW2 pump_speed: name: Pump Speed + +text_sensor: + - platform: systa_bus + model: systasolar_aqua + error_code: + name: Error Code From 8e8261c39e7a6ea998f111fd768263f7a6125a46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 08:14:56 -1000 Subject: [PATCH 047/651] [core] Add this week's review lessons to AGENTS.md (#20522) --- AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8194cd50407..6bdd9467c01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ file does, and it is the authority when they disagree. The most useful starting - Function-local constants: `lower_snake_case` - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations + - Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix - Enumerator names: prefix every value of an `enum class` with the enum name converted to `UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with @@ -177,6 +178,8 @@ file does, and it is the authority when they disagree. The most useful starting Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use `set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms: https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb + * **Repeating work:** use `set_interval`, never a `set_timeout` that re-arms itself; every re-arm + rebuilds the callback and re-queues a scheduler item, and the period drifts. * **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a `static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other components, so number them from 0 and keep all of a component's ids together in one place. @@ -191,6 +194,18 @@ file does, and it is the authority when they disagree. The most useful starting // Bad - this is exactly what the base class already does float get_setup_priority() const override { return setup_priority::DATA; } ``` + * **Constant data lives in RAM on ESP8266** unless marked `PROGMEM`: `const` tables, class-scope + constant arrays, and in-class `std::array` initializers (a `.rodata` template plus a copy). Mark the + table `PROGMEM` and read it with `progmem_memcpy()` or `progmem_read_byte()` (`esphome/core/hal.h`); + for a table of structs copy the whole entry out and return the copy, never a pointer into flash. + On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with + `ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g. + `this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array + a `TEMPLATABLE_BYTES` field so a static code stays in flash. + * **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure + sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`. + * **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a + runtime check of something the build already knows. * **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they can be stored in flash rather than RAM. ```cpp @@ -534,6 +549,7 @@ file does, and it is the authority when they disagree. The most useful starting `cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples. * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. + An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`. * **Configuration Validation:** * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`. @@ -569,6 +585,13 @@ file does, and it is the authority when they disagree. The most useful starting * **Testing:** * **Python:** Run unit tests with `pytest`. * **C++:** Use `clang-tidy` for static analysis. + * **C++ unit tests:** gtests in `tests/components//*.cpp`, run with + `script/cpp_unit_test.py `; see `tests/components/README.md`. Set the loop clock in a test + with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test. + * **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove + timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). + * **Keep build-test variants few:** one `test..yaml` per platform the code actually differs + on. A variant per board for platform-identical code only adds CI time. * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: ``` tests/ @@ -615,6 +638,7 @@ file does, and it is the authority when they disagree. The most useful starting name: My Sensor ``` Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time. + `!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead. * **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use: ```bash From 86af0af0e4161a157aa73b3528fe3a543e2cc20c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 08:48:47 -1000 Subject: [PATCH 048/651] [core] Add sum_bytes() and use it for the additive byte checksums components hand-roll (#20507) --- esphome/components/ballu/ballu.cpp | 5 +---- esphome/components/bl0939/bl0939.cpp | 6 +----- esphome/components/bl0940/bl0940.cpp | 8 ++------ esphome/components/bl0942/bl0942.cpp | 9 +++------ esphome/components/cse7761/cse7761.cpp | 16 ++++------------ esphome/components/cse7766/cse7766.cpp | 5 +---- esphome/components/daikin/daikin.cpp | 9 +-------- esphome/components/daikin_arc/daikin_arc.cpp | 5 +---- esphome/components/daikin_brc/daikin_brc.cpp | 6 ++---- esphome/components/daly_bms/daly_bms.cpp | 7 +------ esphome/components/delonghi/delonghi.cpp | 6 ++---- esphome/components/fs3000/fs3000.cpp | 7 +------ .../fujitsu_general/fujitsu_general.cpp | 6 +----- esphome/components/hm3301/hm3301.cpp | 9 +-------- .../improv_serial/improv_serial_component.cpp | 11 ++--------- esphome/components/mhz19/mhz19.cpp | 8 +------- esphome/components/mitsubishi/mitsubishi.cpp | 4 +--- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 3 +-- esphome/components/mk2pvrouter/mk2pvrouter.cpp | 9 +-------- esphome/components/pm1006/pm1006.cpp | 6 +----- esphome/components/pn532_i2c/pn532_i2c.cpp | 6 +----- esphome/components/pn532_spi/pn532_spi.cpp | 8 ++------ esphome/components/pzem004t/pzem004t.cpp | 4 +--- esphome/components/sds011/sds011.cpp | 6 +----- .../components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 12 +++--------- esphome/components/sm300d2/sm300d2.cpp | 8 +------- esphome/components/sonoff_d1/sonoff_d1.cpp | 8 +------- esphome/components/systa_bus/systa_bus.cpp | 7 +------ esphome/components/tcl112/tcl112.cpp | 4 +--- esphome/components/teleinfo/teleinfo.cpp | 10 +++++----- esphome/components/toshiba/toshiba.cpp | 7 +------ esphome/components/tuya/tuya.cpp | 10 +++------- esphome/components/ufm01/ufm01.cpp | 9 ++------- esphome/components/wts01/wts01.cpp | 5 +---- esphome/core/helpers.h | 8 ++++++++ tests/components/core/helpers_test.cpp | 8 ++++++++ 36 files changed, 69 insertions(+), 196 deletions(-) diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index 1553f336186..e18325303c8 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -146,10 +146,7 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; - // Calculate checksum and compare with signal value. - for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++) - checksum += remote_state[i]; + const uint8_t checksum = sum_bytes(remote_state, BALLU_STATE_LENGTH - 1); if (checksum != remote_state[BALLU_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index b0b9bb1c176..0e2da59ab1a 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -51,12 +51,8 @@ void BL0939::loop() { } bool BL0939::validate_checksum(const DataPacket *data) { - uint8_t checksum = BL0939_READ_COMMAND; // Whole package but checksum - for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) { - checksum += data->raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = sum_bytes(data->raw, sizeof(data->raw) - 1, BL0939_READ_COMMAND) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0939 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index a8b470de7ec..ac1386df03a 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -44,13 +44,9 @@ void BL0940::loop() { } bool BL0940::validate_checksum_(DataPacket *data) { - uint8_t checksum = this->read_command_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, this->read_command_) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 7d0eff8c9da..7da8dcca546 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -70,13 +70,10 @@ void BL0942::loop() { } bool BL0942::validate_checksum_(DataPacket *data) { - uint8_t checksum = BL0942_READ_COMMAND | this->address_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, BL0942_READ_COMMAND | this->address_) ^ + 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0942 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 695b7f33d6c..c4073d27821 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -81,11 +81,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { buffer[3] = data & 0xFF; len = 4; } - uint8_t crc = 0; - for (uint32_t i = 0; i < len; i++) { - crc += buffer[i]; - } - buffer[len] = ~crc; + buffer[len] = ~sum_bytes(buffer, len); len++; } @@ -115,16 +111,12 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { } rcvd--; + if (static_cast(~sum_bytes(buffer, rcvd, 0xA5 + reg)) != buffer[rcvd]) { + return false; + } uint32_t result = 0; - // CRC check - uint8_t crc = 0xA5 + reg; for (uint32_t i = 0; i < rcvd; i++) { result = (result << 8) | buffer[i]; - crc += buffer[i]; - } - crc = ~crc; - if (crc != buffer[rcvd]) { - return false; } *value = result; diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 92769011563..0bf077b7468 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -67,10 +67,7 @@ bool CSE7766Component::check_byte_() { } if (index == CSE7766_RAW_DATA_SIZE - 1) { - uint8_t checksum = 0; - for (uint8_t i = 2; i < CSE7766_RAW_DATA_SIZE - 1; i++) { - checksum += this->raw_data_[i]; - } + const uint8_t checksum = sum_bytes(this->raw_data_ + 2, CSE7766_RAW_DATA_SIZE - 3); if (checksum != this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]) { ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum, diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 8b8d16677a1..370c04fbf66 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -1,6 +1,7 @@ #include "daikin.h" #include "esphome/components/remote_base/remote_base.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::daikin { @@ -8,14 +9,6 @@ ESPHOME_LOG_TAG(TAG, "daikin.climate"); static constexpr uint32_t BOOST_TIMEOUT_ID = 0; -static uint8_t sum_bytes(const uint8_t *data, size_t len) { - uint8_t sum = 0; - for (size_t i = 0; i < len; i++) { - sum += data[i]; - } - return sum; -} - void DaikinClimate::setup() { climate_ir::ClimateIR::setup(); // A boost preset restored from flash gets its timer too diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index e79135b1859..fa01df73d85 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -246,10 +246,7 @@ climate::ClimateTraits DaikinArcClimate::traits() { } bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGI(TAG, "checksum error"); return false; diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 5d8ef52dc01..cfb3521a66d 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -1,5 +1,6 @@ #include "daikin_brc.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::daikin_brc { @@ -160,10 +161,7 @@ uint8_t DaikinBrcClimate::temperature_() { } bool DaikinBrcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_BRC_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_BRC_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_BRC_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGCONFIG(TAG, "Bad CheckSum %x", checksum); return false; diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index a486037266e..04ef798b69e 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -129,12 +129,7 @@ void DalyBmsComponent::decode_data_(std::vector data) { while ((it = std::find(it, data.end(), 0xA5)) != data.end()) { if (data.end() - it >= DALY_FRAME_SIZE && it[1] == 0x01) { - uint8_t checksum; - int sum = 0; - for (int i = 0; i < 12; i++) { - sum += it[i]; - } - checksum = sum; + const uint8_t checksum = sum_bytes(&*it, 12); if (checksum == it[12]) { switch (it[2]) { diff --git a/esphome/components/delonghi/delonghi.cpp b/esphome/components/delonghi/delonghi.cpp index eec4d7f55b1..3ed794daea0 100644 --- a/esphome/components/delonghi/delonghi.cpp +++ b/esphome/components/delonghi/delonghi.cpp @@ -1,5 +1,6 @@ #include "delonghi.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::delonghi { @@ -102,10 +103,7 @@ uint8_t DelonghiClimate::temperature_() { } bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DELONGHI_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DELONGHI_STATE_FRAME_SIZE - 1); if (frame[DELONGHI_STATE_FRAME_SIZE - 1] != checksum) { return false; } diff --git a/esphome/components/fs3000/fs3000.cpp b/esphome/components/fs3000/fs3000.cpp index 81d6647642e..b5e1c98bcb2 100644 --- a/esphome/components/fs3000/fs3000.cpp +++ b/esphome/components/fs3000/fs3000.cpp @@ -42,12 +42,7 @@ void FS3000Component::update() { } // checksum passes if the modulo 256 sum of the five bytes is 0 - uint8_t checksum = 0; - for (uint8_t i : data) { - checksum += i; - } - - if (checksum != 0) { + if (sum_bytes(data, sizeof(data)) != 0) { this->status_set_warning(); ESP_LOGW(TAG, "Checksum failure when reading from FS3000"); return; diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index 9a928dac01b..af411d133a8 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -224,11 +224,7 @@ void FujitsuGeneralClimate::transmit_(uint8_t const *message, uint8_t length) { } uint8_t FujitsuGeneralClimate::checksum_state_(uint8_t const *message) { - uint8_t checksum = 0; - for (uint8_t i = 7; i < FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 1; ++i) { - checksum += message[i]; - } - return 256 - checksum; + return -sum_bytes(message + 7, FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 8); } uint8_t FujitsuGeneralClimate::checksum_util_(uint8_t const *message) { return 255 - message[5]; } diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index bfed99b21b5..ddfe77d3ac1 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -80,14 +80,7 @@ void HM3301Component::update() { this->status_clear_warning(); } -bool HM3301Component::validate_checksum_(const uint8_t *data) { - uint8_t sum = 0; - for (int i = 0; i < 28; i++) { - sum += data[i]; - } - - return sum == data[28]; -} +bool HM3301Component::validate_checksum_(const uint8_t *data) { return sum_bytes(data, 28) == data[28]; } uint16_t HM3301Component::get_sensor_value_(const uint8_t *data, uint8_t i) { return (uint16_t) data[i * 2] << 8 | data[i * 2 + 1]; diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 10454678669..906781e17bf 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -95,16 +95,9 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) const uint8_t header_checksum_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE - 2; // Only transmit the full buffer length if there is no data (only state/error byte is provided in this case) const uint8_t header_tx_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE; - // Calculate checksum for message - uint8_t checksum = 0; - for (uint8_t i = 0; i < header_checksum_len; i++) { - checksum += this->tx_header_[i]; - } + uint8_t checksum = sum_bytes(this->tx_header_, header_checksum_len); if (there_is_data) { - // Include data in checksum - for (size_t i = 0; i < size; i++) { - checksum += data[i]; - } + checksum = sum_bytes(data, size, checksum); } this->tx_header_[TX_CHECKSUM_IDX] = checksum; diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index 5be6da88eff..0f623bfd726 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -31,13 +31,7 @@ static const LogString *detection_range_to_log_string(MHZ19DetectionRange range) } #endif -uint8_t mhz19_checksum(const uint8_t *command) { - uint8_t sum = 0; - for (uint8_t i = 1; i < MHZ19_REQUEST_LENGTH; i++) { - sum += command[i]; - } - return 0xFF - sum + 0x01; -} +uint8_t mhz19_checksum(const uint8_t *command) { return -sum_bytes(command + 1, MHZ19_REQUEST_LENGTH - 1); } void MHZ19Component::setup() { if (this->abc_boot_logic_ == MHZ19_ABC_ENABLED) { diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 515cd81fdfe..9fba222d635 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -242,9 +242,7 @@ void MitsubishiClimate::transmit_state() { } // Checksum - for (int i = 0; i < 17; i++) { - remote_state[17] += remote_state[i]; - } + remote_state[17] = sum_bytes(remote_state, 17); ESP_LOGD(TAG, "sending: %02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X", remote_state[0], remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index c6c7b57c5fa..3261d9d5694 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -31,7 +30,7 @@ static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { - return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); + return static_cast(0xFC - sum_bytes(bytes, length)); } template diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index eecaa1a1d41..b759758019a 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -36,14 +36,7 @@ static size_t get_field(char *dest, const char *buf_start, const char *buf_end, // Calculates the CRC (checksum) for a given group of characters. uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { - uint8_t crc_tmp{0}; - const auto effective_len = grp_len - CRC_SUFFIX_LEN; - for (size_t i = 0; i < effective_len; i++) { - crc_tmp += grp[i]; - } - crc_tmp &= CRC_MASK; - crc_tmp += CRC_OFFSET; - return crc_tmp; + return (sum_bytes(reinterpret_cast(grp), grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; } // Verifies the CRC of a group against its trailing CRC byte. diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index ebd508b5352..2f57c028439 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -43,11 +43,7 @@ void PM1006Component::loop() { } uint8_t PM1006Component::pm1006_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional PM1006Component::check_byte_() const { diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 1687edd5116..5326aeb145c 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -71,11 +71,7 @@ bool PN532I2C::read_response(uint8_t command, pn532::PN532Frame &data) { // frame: status, preamble, start code (2), LEN, LCS, TFI, command response code, data, DCS, postamble constexpr size_t tfi_offset = 6; - uint8_t checksum = 0; - for (size_t i = 0; i < len + 1U; i++) { - checksum += data[tfi_offset + i]; - } - checksum = ~checksum + 1; + const uint8_t checksum = -sum_bytes(data.data() + tfi_offset, len + 1U); if (data[tfi_offset + len + 1] != checksum) { ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[tfi_offset + len + 1], checksum); diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index e86153be86a..34c6a3e44f7 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -127,12 +127,8 @@ bool PN532Spi::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Response data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - uint8_t checksum = header[5] + header[6]; // TFI + Command response code - for (int i = 0; i < len - 1; i++) { - uint8_t dat = data[i]; - checksum += dat; - } - checksum = ~checksum + 1; + // TFI + Command response code + data + const uint8_t checksum = -sum_bytes(data.data(), len - 1, header[5] + header[6]); if (data[len - 1] != checksum) { ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len - 1], checksum); diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 7809e803c9d..606a03e0c23 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -34,9 +34,7 @@ void PZEM004T::loop() { // 1-5: data // 6: checksum (sum of other bytes) // see https://github.com/olehs/PZEM004T - uint8_t sum = 0; - for (int i = 0; i < 6; i++) - sum += resp[i]; + const uint8_t sum = sum_bytes(resp.data(), 6); if (sum != resp[6]) { ESP_LOGV(TAG, "PZEM004T invalid checksum! 0x%02X != 0x%02X", sum, resp[6]); diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 3d46f0613e0..147365251b5 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -115,11 +115,7 @@ void SDS011Component::sds011_write_command_(const uint8_t *command_data) { } uint8_t SDS011Component::sds011_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional SDS011Component::check_byte_() const { diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 31c8e89af3a..1bc3e0aeb42 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -251,17 +251,11 @@ void MR24HPC1Component::loop() { } } -// Calculate CRC check digit -static uint8_t get_frame_crc_sum(const uint8_t *data, int len) { - unsigned int crc_sum = 0; - for (int i = 0; i < len - 3; i++) { - crc_sum += data[i]; - } - return crc_sum & 0xff; -} +// Frames are at least 10 bytes, so the three trailing bytes can always be left out +static uint8_t get_frame_crc_sum(const uint8_t *data, size_t len) { return sum_bytes(data, len - 3); } // Check that the check digit is correct -static int get_frame_check_status(uint8_t *data, int len) { +static int get_frame_check_status(const uint8_t *data, size_t len) { uint8_t crc_sum = get_frame_crc_sum(data, len); uint8_t verified = data[len - 3]; return (verified == crc_sum) ? 1 : 0; diff --git a/esphome/components/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 0660e0f20e4..a7959a09997 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -83,13 +83,7 @@ void SM300D2Sensor::update() { this->humidity_sensor_->publish_state(humidity); } -uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { - uint8_t sum = 0; - for (int i = 0; i < (SM300D2_RESPONSE_LENGTH - 1); i++) { - sum += *ptr++; - } - return sum; -} +uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { return sum_bytes(ptr, SM300D2_RESPONSE_LENGTH - 1); } void SM300D2Sensor::dump_config() { ESP_LOGCONFIG(TAG, "SM300D2:"); diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index 02282fef9ca..c72210e932f 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -52,13 +52,7 @@ ESPHOME_LOG_TAG(TAG, "sonoff_d1"); static constexpr size_t SONOFF_D1_ACK_SIZE = 7; static constexpr size_t SONOFF_D1_MAX_CMD_SIZE = 17; -uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { - uint8_t crc = 0; - for (size_t i = 2; i < len - 1; i++) { - crc += cmd[i]; - } - return crc; -} +uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { return sum_bytes(cmd + 2, len - 3); } void SonoffD1Output::populate_checksum_(uint8_t *cmd, const size_t len) { // Update the checksum diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp index 4c537b75cbf..6c780d07a92 100644 --- a/esphome/components/systa_bus/systa_bus.cpp +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -10,12 +10,7 @@ ESPHOME_LOG_TAG(TAG, "systa_bus"); void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } -static bool checksum(std::span data) { - uint8_t csum = 0; - for (uint8_t i : data) - csum += i; - return csum == 0; -} +static bool checksum(std::span data) { return sum_bytes(data.data(), data.size()) == 0; } void SystaBus::loop() { uint8_t c; diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index 5e7f0ef29c7..15111338b24 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -168,11 +168,9 @@ bool Tcl112Climate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; // Calculate & set the checksum for the current internal state of the remote. // Stored the checksum value in the last byte. - for (uint8_t checksum_byte = 0; checksum_byte < TCL112_STATE_LENGTH - 1; checksum_byte++) - checksum += remote_state[checksum_byte]; + const uint8_t checksum = sum_bytes(remote_state, TCL112_STATE_LENGTH - 1); if (checksum != remote_state[TCL112_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); return false; diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index adf56707844..91a0d0e16f5 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -24,12 +24,12 @@ static int get_field(char *dest, char *buf_start, char *buf_end, int sep, int ma /* TeleInfo methods */ bool TeleInfo::check_crc_(const char *grp, const char *grp_end) { int grp_len = grp_end - grp; + if (grp_len < this->checksum_area_end_) { + ESP_LOGE(TAG, "Empty or too short group"); + return false; + } uint8_t raw_crc = grp[grp_len - 1]; - uint8_t crc_tmp = 0; - int i; - - for (i = 0; i < grp_len - checksum_area_end_; i++) - crc_tmp += grp[i]; + uint8_t crc_tmp = sum_bytes(reinterpret_cast(grp), grp_len - this->checksum_area_end_); crc_tmp &= 0x3F; crc_tmp += 0x20; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 57361a4a587..406a1bc55db 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1052,8 +1052,6 @@ bool ToshibaClimate::compare_rac_pt1411hwru_packets_(const uint8_t *message1, co } bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { - uint8_t checksum = 0; - switch (this->is_valid_rac_pt1411hwru_header_(message)) { case RAC_PT1411HWRU_MESSAGE_HEADER0: case RAC_PT1411HWRU_CS_HEADER: @@ -1065,10 +1063,7 @@ bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { break; case RAC_PT1411HWRU_MESSAGE_HEADER1: - for (uint8_t i = 0; i < RAC_PT1411HWRU_MESSAGE_LENGTH - 1; i++) { - checksum += message[i]; - } - if (checksum == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { + if (sum_bytes(message, RAC_PT1411HWRU_MESSAGE_LENGTH - 1) == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { return true; } break; diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index c6316f59fce..025b97ee727 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -152,9 +152,7 @@ bool Tuya::validate_message_() { // Byte 6+LEN: CHECKSUM - sum of all bytes (including header) modulo 256 uint8_t rx_checksum = new_byte; - uint8_t calc_checksum = 0; - for (uint32_t i = 0; i < 6 + length; i++) - calc_checksum += data[i]; + const uint8_t calc_checksum = sum_bytes(data, 6 + length); if (rx_checksum != calc_checksum) { ESP_LOGW(TAG, "Tuya Received invalid message checksum %02X!=%02X", rx_checksum, calc_checksum); @@ -531,10 +529,8 @@ void Tuya::send_raw_command_(TuyaCommand command) { if (!command.payload.empty()) this->write_array(command.payload.data(), command.payload.size()); - uint8_t checksum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; - for (auto &data : command.payload) - checksum += data; - this->write_byte(checksum); + const uint8_t header_sum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; + this->write_byte(sum_bytes(command.payload.data(), command.payload.size(), header_sum)); } void Tuya::process_command_queue_() { diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index cd4388372cd..c68842ab6c1 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -68,9 +68,7 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp } static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { - uint8_t sum = 0; - for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) - sum += data[i]; + const uint8_t sum = sum_bytes(data, FRAME_CHECKSUM_INDEX); return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && @@ -83,10 +81,7 @@ static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2 || data[22] != FRAME_STOP_BYTE) return false; - uint8_t sum = 0; - for (size_t i = 0; i < 21; ++i) - sum += data[i]; - return data[21] == (sum & 0xFF); + return data[21] == sum_bytes(data, 21); } static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index 2888fc0abb3..b7b9e5cfa99 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -58,10 +58,7 @@ void WTS01Sensor::process_packet_() { // Based on Tasmota implementation // Format: 55 01 01 04 01 11 16 12 95 // header T Td Ck - T = Temperature, Td = Temperature decimal, Ck = Checksum - uint8_t calculated_checksum = 0; - for (uint8_t i = 0; i < PACKET_SIZE - 1; i++) { - calculated_checksum += this->buffer_[i]; - } + const uint8_t calculated_checksum = sum_bytes(this->buffer_, PACKET_SIZE - 1); uint8_t received_checksum = this->buffer_[PACKET_SIZE - 1]; if (calculated_checksum != received_checksum) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a2ede273413..c54d1bc16f0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -879,6 +880,13 @@ template T remap(U value, U min, U max, T min_out, T max return (value - min) * (max_out - min_out) / (max - min) + min_out; } +/// Add up the bytes of \p data with size \p len, truncated to 8 bits: the checksum many serial and IR +/// protocols use. Prefer this over a bare std::accumulate, whose result type follows the seed, so a plain 0 +/// sums in int and compares wrong against a checksum byte. +constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum = 0) { + return std::accumulate(data, data + len, sum); +} + /// Calculate a CRC-8 checksum of \p data with size \p len. uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc = 0x00, uint8_t poly = 0x8C, bool msb_first = false); diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index 521ae10e5c3..d0058a634b2 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -55,6 +55,14 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { } } +TEST(HelpersTest, SumBytes) { + static constexpr uint8_t FRAME[] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x10}; + EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME)), 0xE7); // the Daikin comfort frame checksum + EXPECT_EQ(sum_bytes(FRAME, 0), 0); + EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME), 0x19), 0x00); // wraps at 8 bits + static_assert(sum_bytes(FRAME, 2) == 0xEB, "usable in constant expressions"); +} + TEST(StaticVectorTest, ConvertingConstructorFromSmaller) { StaticVector small{0x03, 0x00, 0x10, 0x00, 0x01}; StaticVector big = small; From c8a300164b71e38d585156f1c920ec8f3e207418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:53:37 +0200 Subject: [PATCH 049/651] [core] Add validators that reject deferring actions in an automation (#20523) Co-authored-by: Claude Opus 5.5 --- esphome/automation.py | 46 +++++++++++++++ tests/unit_tests/test_automation.py | 91 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/esphome/automation.py b/esphome/automation.py index 6b6af4ed72e..0105d1e1072 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1048,6 +1048,52 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: return False +def validate_no_deferring_actions( + context: str, data: str = "the trigger data" +) -> Callable[[ConfigType], ConfigType]: + """Return a validator that rejects deferring actions in a validated automation. + + Use it for triggers whose arguments only live while the callback runs (views into a reused + buffer, a JSON document freed on return, ...): deferring actions keep the arguments for later. + + :param context: Where the automation is, for the error message, e.g. ``"on_json and on_data"``. + :param data: What is only valid while the automation runs, for the error message. + """ + + def validator(value: ConfigType) -> ConfigType: + if has_non_synchronous_actions(value): + raise cv.Invalid( + f"Deferring actions (delay, wait_until, script.wait, ...) are not allowed in " + f"{context}: {data} is only valid while the automation runs. Copy what you need " + "into globals first, then defer in a separate script or automation." + ) + return value + + return validator + + +def validate_synchronous_automation( + context: str, + data: str = "the trigger data", + extra_schema=None, + extra_validators=None, + single: bool = False, +) -> cv.All: + """``validate_automation()`` that also rejects deferring actions. + + See ``validate_no_deferring_actions()`` for ``context`` and ``data``; the other parameters are + passed to ``validate_automation()``. + """ + return cv.All( + validate_automation( + extra_schema=extra_schema, + extra_validators=extra_validators, + single=single, + ), + validate_no_deferring_actions(context, data), + ) + + async def _new_automation( args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj ) -> MockObj: diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 3cd70c082d5..351afe541d6 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -36,12 +36,15 @@ from esphome.automation import ( register_simple_condition, string_ref_literal, templatable_bytes, + validate_no_deferring_actions, + validate_synchronous_automation, ) import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression +from esphome.types import ConfigType from esphome.util import Registry, RegistryEntry @@ -213,6 +216,94 @@ def test_has_non_synchronous_actions_dict_input( assert has_non_synchronous_actions({"logger.log": "hello"}) is False +def test_validate_no_deferring_actions_accepts_synchronous( + mock_registry: dict[str, RegistryEntry], +) -> None: + value = [{CONF_THEN: [{"logger.log": "hello"}]}] + assert validate_no_deferring_actions("on_test")(value) is value + + +def test_validate_no_deferring_actions_accepts_lambda_form( + mock_registry: dict[str, RegistryEntry], +) -> None: + """A returning lambda has no action list, so there is nothing to reject.""" + value = Lambda("return true;") + assert validate_no_deferring_actions("on_test")(value) is value + + +def test_validate_no_deferring_actions_rejects_deferring( + mock_registry: dict[str, RegistryEntry], +) -> None: + validator = validate_no_deferring_actions("on_test", "the test data") + with pytest.raises( + cv.Invalid, + match=r"not allowed in on_test: the test data is only valid while", + ): + validator([{CONF_THEN: [{"logger.log": "hello"}, {"delay": "1s"}]}]) + + +def test_validate_no_deferring_actions_default_data( + mock_registry: dict[str, RegistryEntry], +) -> None: + with pytest.raises(cv.Invalid, match=r"on_test: the trigger data is only valid"): + validate_no_deferring_actions("on_test")([{CONF_THEN: [{"delay": "1s"}]}]) + + +def test_validate_synchronous_automation_accepts_synchronous() -> None: + result = validate_synchronous_automation("on_test")( + [{CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]}] + ) + assert len(result) == 1 + + +def test_validate_synchronous_automation_rejects_deferring() -> None: + with pytest.raises(cv.Invalid, match="Deferring actions"): + validate_synchronous_automation("on_test")([{CONF_THEN: [{"delay": "1s"}]}]) + + +def test_validate_synchronous_automation_rejects_nested_deferring() -> None: + actions = [ + { + "if": { + "condition": {"lambda": Lambda("return true;")}, + CONF_THEN: [{"delay": "1s"}], + } + } + ] + with pytest.raises(cv.Invalid, match="Deferring actions"): + validate_synchronous_automation("on_test")([{CONF_THEN: actions}]) + + +def test_validate_synchronous_automation_passes_options() -> None: + """extra_schema and single are passed through to validate_automation().""" + validator = validate_synchronous_automation( + "on_test", extra_schema={cv.Optional("retry"): cv.string}, single=True + ) + result = validator( + {"retry": "yes", CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]} + ) + assert isinstance(result, dict) + assert result["retry"] == "yes" + with pytest.raises(cv.Invalid, match="Deferring actions"): + validator({CONF_THEN: [{"delay": "1s"}]}) + + +def test_validate_synchronous_automation_passes_extra_validators() -> None: + """extra_validators is passed through to validate_automation() and runs on each automation.""" + seen: list[ConfigType] = [] + + def extra_validator(value: ConfigType) -> ConfigType: + seen.append(value) + return value + + validator = validate_synchronous_automation( + "on_test", extra_validators=extra_validator + ) + validator([{CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]}]) + assert len(seen) == 1 + assert CONF_THEN in seen[0] + + def _build_forwarder( automation_name: str, args: list[tuple[str, str]], From 6322cef8e2310b1e053fc016fa1e85272de64de1 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:06:40 -0400 Subject: [PATCH 050/651] [tmp102] Add hardware configuration options (#20063) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/tmp102/__init__.py | 90 ++++++- esphome/components/tmp102/sensor.py | 65 +++-- esphome/components/tmp102/tmp102.cpp | 138 ++++++++++- esphome/components/tmp102/tmp102.h | 28 ++- esphome/core/defines.h | 1 + tests/component_tests/tmp102/__init__.py | 1 + .../tmp102/config/advanced.yaml | 23 ++ .../component_tests/tmp102/config/basic.yaml | 15 ++ tests/component_tests/tmp102/test_config.py | 95 ++++++++ tests/components/tmp102/__init__.py | 13 + tests/components/tmp102/common.yaml | 13 + tests/components/tmp102/test.esp32-idf.yaml | 3 +- tests/components/tmp102/test.esp8266-ard.yaml | 3 +- tests/components/tmp102/test.rp2040-ard.yaml | 3 +- tests/components/tmp102/test_tmp102.cpp | 228 ++++++++++++++++++ 15 files changed, 684 insertions(+), 35 deletions(-) create mode 100644 tests/component_tests/tmp102/__init__.py create mode 100644 tests/component_tests/tmp102/config/advanced.yaml create mode 100644 tests/component_tests/tmp102/config/basic.yaml create mode 100644 tests/component_tests/tmp102/test_config.py create mode 100644 tests/components/tmp102/__init__.py create mode 100644 tests/components/tmp102/test_tmp102.cpp diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 8510f18769a..6fd5f481e72 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -1,11 +1,85 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. +"""Shared TMP102 types and configuration validation.""" -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf - -""" +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE +import esphome.config_validation as cv +from esphome.types import ConfigType DOMAIN = "tmp102" + +tmp102_ns = cg.esphome_ns.namespace("tmp102") +TMP102Component = tmp102_ns.class_( + "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor +) + +CONF_EXTENDED_MODE = "extended_mode" +CONF_ONE_SHOT_MODE = "one_shot_mode" +CONF_TEMPERATURE_HIGH = "temperature_high" +CONF_TEMPERATURE_LOW = "temperature_low" +CONF_ALERT_POLARITY = "alert_polarity" +CONF_THERMOSTAT_MODE = "thermostat_mode" +CONF_FAULT_QUEUE = "fault_queue" + +CONVERSION_RATES = {"0.25Hz": 0, "1Hz": 1, "4Hz": 2, "8Hz": 3} +THERMOSTAT_MODES = {"comparator": 0, "interrupt": 1} +ALERT_POLARITIES = {"active_low": 0, "active_high": 1} +FAULT_QUEUE_BITS = {1: 0, 2: 1, 4: 2, 6: 3} + +TMP102_CONVERSION_FACTOR = 0.0625 +TMP102_DEFAULT_THIGH = 80.0 +TMP102_DEFAULT_TLOW = 75.0 + + +def encode_temperature(temperature: float, extended: bool) -> int: + """Encode a temperature as a TMP102 limit-register word.""" + steps = round(temperature / TMP102_CONVERSION_FACTOR) + return (steps << (3 if extended else 4)) & 0xFFFF + + +def build_configuration(config: ConfigType) -> int: + """Build the writable TMP102 configuration-register bits.""" + value = CONVERSION_RATES[config.get(CONF_CONVERSION_RATE, "4Hz")] << 6 + value |= FAULT_QUEUE_BITS[config.get(CONF_FAULT_QUEUE, 1)] << 11 + value |= ALERT_POLARITIES[config.get(CONF_ALERT_POLARITY, "active_low")] << 10 + value |= THERMOSTAT_MODES[config.get(CONF_THERMOSTAT_MODE, "comparator")] << 9 + if config.get(CONF_ONE_SHOT_MODE, False): + value |= 1 << 8 + if config.get(CONF_EXTENDED_MODE, False): + value |= 1 << 4 + return value + + +def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: + extended = config.get(CONF_EXTENDED_MODE, False) + t_min = -55.0 + t_max = 150.0 if extended else 127.9375 + high = config.get(CONF_TEMPERATURE_HIGH) + low = config.get(CONF_TEMPERATURE_LOW) + for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)): + if value is not None and not t_min <= value <= t_max: + raise cv.Invalid( + f"{key} ({value}°C) out of range for " + f"{'extended' if extended else 'normal'} mode " + f"({t_min}°C to {t_max}°C)", + [key], + ) + + if high is not None and low is not None and low > high: + raise cv.Invalid( + f"low limit ({low}°C) must be <= high limit ({high}°C)", + [CONF_TEMPERATURE_LOW], + ) + if high is not None and low is None and high < TMP102_DEFAULT_TLOW: + raise cv.Invalid( + f"high limit ({high}°C) is below the chip power-up TLOW default " + f"({TMP102_DEFAULT_TLOW}°C). Set temperature_low explicitly.", + [CONF_TEMPERATURE_HIGH], + ) + if low is not None and high is None and low > TMP102_DEFAULT_THIGH: + raise cv.Invalid( + f"low limit ({low}°C) is above the chip power-up THIGH default " + f"({TMP102_DEFAULT_THIGH}°C). Set temperature_high explicitly.", + [CONF_TEMPERATURE_LOW], + ) + return config diff --git a/esphome/components/tmp102/sensor.py b/esphome/components/tmp102/sensor.py index eba0d3c0269..fbfed7444d6 100644 --- a/esphome/components/tmp102/sensor.py +++ b/esphome/components/tmp102/sensor.py @@ -1,15 +1,8 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. - -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf - -""" +"""TMP102 temperature sensor and optional hardware configuration.""" import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, @@ -18,15 +11,42 @@ from esphome.const import ( ) from esphome.types import ConfigType +from . import ( + ALERT_POLARITIES, + CONF_ALERT_POLARITY, + CONF_EXTENDED_MODE, + CONF_FAULT_QUEUE, + CONF_ONE_SHOT_MODE, + CONF_TEMPERATURE_HIGH, + CONF_TEMPERATURE_LOW, + CONF_THERMOSTAT_MODE, + CONVERSION_RATES, + FAULT_QUEUE_BITS, + THERMOSTAT_MODES, + TMP102_DEFAULT_THIGH, + TMP102_DEFAULT_TLOW, + TMP102Component, + build_configuration, + encode_temperature, + validate_tmp102_thresholds, +) + CODEOWNERS = ["@timsavage"] DEPENDENCIES = ["i2c"] -tmp102_ns = cg.esphome_ns.namespace("tmp102") -TMP102Component = tmp102_ns.class_( - "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor -) +# No defaults here: absence of all advanced options preserves upstream's read-only behavior and firmware size. +_ADVANCED_SCHEMA = { + cv.Optional(CONF_EXTENDED_MODE): cv.boolean, + cv.Optional(CONF_CONVERSION_RATE): cv.enum(CONVERSION_RATES), + cv.Optional(CONF_ONE_SHOT_MODE): cv.boolean, + cv.Optional(CONF_ALERT_POLARITY): cv.enum(ALERT_POLARITIES), + cv.Optional(CONF_THERMOSTAT_MODE): cv.enum(THERMOSTAT_MODES), + cv.Optional(CONF_FAULT_QUEUE): cv.enum(FAULT_QUEUE_BITS, int=True), + cv.Optional(CONF_TEMPERATURE_HIGH): cv.temperature, + cv.Optional(CONF_TEMPERATURE_LOW): cv.temperature, +} -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( TMP102Component, unit_of_measurement=UNIT_CELSIUS, @@ -36,6 +56,8 @@ CONFIG_SCHEMA = ( ) .extend(cv.polling_component_schema("60s")) .extend(i2c.i2c_device_schema(0x48)) + .extend(_ADVANCED_SCHEMA), + validate_tmp102_thresholds, ) @@ -43,3 +65,18 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + + if not any(key in config for key in _ADVANCED_SCHEMA): + return + + extended = config.get(CONF_EXTENDED_MODE, False) + high = config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH) + low = config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW) + cg.add_define("USE_TMP102_CONFIGURE") + cg.add( + var.set_configuration( + build_configuration(config), + encode_temperature(high, extended), + encode_temperature(low, extended), + ) + ) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index d851bd925fa..a1066b97619 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -8,13 +8,33 @@ ESPHOME_LOG_TAG(TAG, "tmp102"); static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; -static const uint8_t TMP102_ADDRESS = 0x48; -static const uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; -static const uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; -static const uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; -static const uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; +static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; +static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; +#ifdef USE_TMP102_CONFIGURE +static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; +static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; +static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; -static const float TMP102_CONVERSION_FACTOR = 0.0625; +static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0; +static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; +static constexpr uint16_t TMP102_ONE_SHOT_BIT = 0x8000; +static constexpr uint16_t TMP102_SHUTDOWN_BIT = 0x0100; +static constexpr uint16_t TMP102_THERMOSTAT_MODE_BIT = 0x0200; +static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; +static constexpr uint16_t TMP102_EXTENDED_MODE_BIT = 0x0010; +static constexpr uint32_t TMP102_ONESHOT_DELAY_MS = 40; + +static float decode_temperature(uint16_t raw, bool extended) { + return (static_cast(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR; +} + +void TMP102Component::setup() { + if (!this->configure_) + return; + if (!this->write_configuration_()) + this->mark_failed(); +} +#endif void TMP102Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP102:"); @@ -24,9 +44,44 @@ void TMP102Component::dump_config() { } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); +#ifdef USE_TMP102_CONFIGURE + if (!this->configure_) + return; + const uint8_t rate = (this->config_ >> 6) & 0x03; + const LogString *rate_str = rate == 0 ? LOG_STR("0.25Hz") + : rate == 1 ? LOG_STR("1Hz") + : rate == 2 ? LOG_STR("4Hz") + : LOG_STR("8Hz"); + const uint8_t fault_bits = (this->config_ >> 11) & 0x03; + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + ESP_LOGCONFIG( + TAG, + " Extended Mode: %s\n" + " One-Shot Mode: %s\n" + " Conversion Rate: %s\n" + " Thermostat Mode: %s\n" + " Alert Polarity: %s\n" + " Fault Queue: %u\n" + " Temperature High: %.4f°C\n" + " Temperature Low: %.4f°C", + YESNO(extended), YESNO(this->config_ & TMP102_SHUTDOWN_BIT), LOG_STR_ARG(rate_str), + this->config_ & TMP102_THERMOSTAT_MODE_BIT ? LOG_STR_LITERAL("Interrupt") : LOG_STR_LITERAL("Comparator"), + this->config_ & TMP102_ALERT_POLARITY_BIT ? LOG_STR_LITERAL("Active High") : LOG_STR_LITERAL("Active Low"), + fault_bits == 3 ? 6 : 1U << fault_bits, decode_temperature(this->high_limit_, extended), + decode_temperature(this->low_limit_, extended)); + if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) { + ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin"); + } +#endif } void TMP102Component::update() { +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->update_configured_(); + return; + } +#endif if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { this->status_set_warning(); return; @@ -47,4 +102,75 @@ void TMP102Component::update() { }); } +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::update_configured_() { + if (!this->check_configuration_()) + return; + if (this->config_ & TMP102_SHUTDOWN_BIT) { + if (!this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_ | TMP102_ONE_SHOT_BIT)) { + this->status_set_warning(); + return; + } + this->set_timeout(READ_TEMP_TIMEOUT_ID, TMP102_ONESHOT_DELAY_MS, [this]() { this->read_temperature_(); }); + } else { + this->read_temperature_(); + } +} + +bool TMP102Component::write_register_(uint8_t reg, uint16_t value) { + uint8_t frame[3] = {reg, static_cast(value >> 8), static_cast(value)}; + if (this->write(frame, 3) == i2c::ERROR_OK) + return true; + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return false; +} + +bool TMP102Component::write_configuration_() { + return this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_) && + this->write_register_(TMP102_REGISTER_HIGH_LIMIT, this->high_limit_) && + this->write_register_(TMP102_REGISTER_LOW_LIMIT, this->low_limit_); +} + +bool TMP102Component::check_configuration_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->status_set_warning(); + return false; + } + bool matches = (config & TMP102_CONFIGURATION_MASK) == (this->config_ & TMP102_CONFIGURATION_MASK); + // A chip reset is invisible in the configuration register when it matches the reset default; check the limits. + if (matches && + (this->config_ & TMP102_CONFIGURATION_MASK) == (TMP102_RESET_CONFIGURATION & TMP102_CONFIGURATION_MASK)) { + uint16_t high, low; + if (!this->read_byte_16(TMP102_REGISTER_HIGH_LIMIT, &high) || + !this->read_byte_16(TMP102_REGISTER_LOW_LIMIT, &low)) { + this->status_set_warning(); + return false; + } + matches = high == this->high_limit_ && low == this->low_limit_; + } + if (matches) + return true; + + ESP_LOGW(TAG, "Configuration changed; restoring configured settings and thresholds"); + this->status_set_warning(); + if (!this->write_configuration_()) { + ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + } + return false; +} + +void TMP102Component::read_temperature_() { + uint16_t raw; + if (!this->read_byte_16(TMP102_REGISTER_TEMPERATURE, &raw)) { + this->status_set_warning(); + return; + } + const float temperature = decode_temperature(raw, raw & 0x0001); + ESP_LOGD(TAG, "Got Temperature=%.2f°C", temperature); + this->publish_state(temperature); + this->status_clear_warning(); +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index f9eda32e989..b14b6c4cc1f 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -1,15 +1,41 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" +#include "esphome/core/defines.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" + +#include namespace esphome::tmp102 { class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: +#ifdef USE_TMP102_CONFIGURE + void setup() override; + void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { + this->config_ = config; + this->high_limit_ = high_limit; + this->low_limit_ = low_limit; + this->configure_ = true; + } +#endif void dump_config() override; void update() override; + + protected: +#ifdef USE_TMP102_CONFIGURE + bool write_register_(uint8_t reg, uint16_t value); + bool write_configuration_(); + bool check_configuration_(); + void read_temperature_(); + void update_configured_(); + + uint16_t config_{0}; + uint16_t high_limit_{0}; + uint16_t low_limit_{0}; + bool configure_{false}; +#endif }; } // namespace esphome::tmp102 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index de621f891a1..31549ceb8eb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -243,6 +243,7 @@ #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TMP102_CONFIGURE #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX diff --git a/tests/component_tests/tmp102/__init__.py b/tests/component_tests/tmp102/__init__.py new file mode 100644 index 00000000000..61db1769a2f --- /dev/null +++ b/tests/component_tests/tmp102/__init__.py @@ -0,0 +1 @@ +"""TMP102 regression tests.""" diff --git a/tests/component_tests/tmp102/config/advanced.yaml b/tests/component_tests/tmp102/config/advanced.yaml new file mode 100644 index 00000000000..5d32255cb83 --- /dev/null +++ b/tests/component_tests/tmp102/config/advanced.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + extended_mode: false + conversion_rate: 1Hz + one_shot_mode: false + alert_polarity: active_high + thermostat_mode: interrupt + fault_queue: 2 + temperature_high: 30 + temperature_low: 25 diff --git a/tests/component_tests/tmp102/config/basic.yaml b/tests/component_tests/tmp102/config/basic.yaml new file mode 100644 index 00000000000..6326b0eaa26 --- /dev/null +++ b/tests/component_tests/tmp102/config/basic.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py new file mode 100644 index 00000000000..020303d455d --- /dev/null +++ b/tests/component_tests/tmp102/test_config.py @@ -0,0 +1,95 @@ +"""Exercise TMP102 sensor validation and code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import tmp102 as component +from esphome.components.tmp102.sensor import CONFIG_SCHEMA +import esphome.config_validation as cv +from esphome.core import CORE + + +def test_basic_unchanged( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("basic.yaml")) + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_" not in defines + + +def test_advanced_options_enable_configuration_once( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("advanced.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(3648, 7680, 6400)" in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_CONFIGURE" in defines + + +@pytest.mark.parametrize( + ("extended", "high", "low"), + [ + (False, 127.9375, -55), + (True, 150, -55), + (False, 30, 30), + ], +) +def test_valid_limits(extended: bool, high: float, low: float) -> None: + validated = CONFIG_SCHEMA( + { + "name": "Temperature", + "extended_mode": extended, + "temperature_high": high, + "temperature_low": low, + } + ) + assert validated["temperature_high"] == high + assert validated["temperature_low"] == low + + +@pytest.mark.parametrize( + "config", + [ + {"temperature_high": float("nan")}, + {"temperature_low": float("inf")}, + {"temperature_low": -55.0625}, + {"temperature_high": 128}, + {"extended_mode": True, "temperature_high": 150.0625}, + {"temperature_high": 30, "temperature_low": 31}, + {"temperature_high": 74}, + {"temperature_low": 81}, + {"fault_queue": 3}, + ], +) +def test_invalid_limits(config: dict) -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"name": "Temperature", **config}) + + +def test_precomputed_register_values() -> None: + config = CONFIG_SCHEMA( + { + "name": "Temperature", + "extended_mode": True, + "conversion_rate": "0.25Hz", + "one_shot_mode": True, + "alert_polarity": "active_high", + "thermostat_mode": "interrupt", + "fault_queue": 6, + "temperature_high": 12.22, + "temperature_low": -12.22, + } + ) + assert component.build_configuration(config) == 0x1F10 + assert component.encode_temperature(config["temperature_high"], True) == 0x0620 + assert component.encode_temperature(config["temperature_low"], True) == 0xF9E0 + + +def test_defaults_match_chip_reset() -> None: + assert component.build_configuration({}) == 0x0080 diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py new file mode 100644 index 00000000000..9702134e8c6 --- /dev/null +++ b/tests/components/tmp102/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + CORE.testing_ensure_platform_registered("sensor") + cg.add_define("USE_TMP102_CONFIGURE") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index 049a8067da7..c764eae42b2 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -2,3 +2,16 @@ sensor: - platform: tmp102 i2c_id: i2c_bus name: TMP102 Temperature + - platform: tmp102 + id: tmp102_extended_temperature + name: TMP102 Extended Temperature + i2c_id: i2c_bus + address: 0x49 + extended_mode: true + one_shot_mode: true + conversion_rate: 8Hz + alert_polarity: active_high + thermostat_mode: interrupt + fault_queue: 6 + temperature_high: 140°C + temperature_low: 120°C diff --git a/tests/components/tmp102/test.esp32-idf.yaml b/tests/components/tmp102/test.esp32-idf.yaml index b47e39c3898..d879c5b4aef 100644 --- a/tests/components/tmp102/test.esp32-idf.yaml +++ b/tests/components/tmp102/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test.esp8266-ard.yaml b/tests/components/tmp102/test.esp8266-ard.yaml index 4a98b9388ab..10be01238cf 100644 --- a/tests/components/tmp102/test.esp8266-ard.yaml +++ b/tests/components/tmp102/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test.rp2040-ard.yaml b/tests/components/tmp102/test.rp2040-ard.yaml index 319a7c71a65..b364443f573 100644 --- a/tests/components/tmp102/test.rp2040-ard.yaml +++ b/tests/components/tmp102/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp new file mode 100644 index 00000000000..b148b07716c --- /dev/null +++ b/tests/components/tmp102/test_tmp102.cpp @@ -0,0 +1,228 @@ +#ifdef USE_HOST + +#include + +#include +#include "esphome/components/tmp102/tmp102.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::tmp102::testing { + +class RegisterBus final : public i2c::I2CBus { + public: + std::array registers{0x1900, 0x60A0, 0x4B00, 0x5000}; + int fail_read{-1}; + int fail_write{-1}; + int fail_select{-1}; + unsigned transactions{0}; + unsigned writes{0}; + uint8_t pointer{0}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + ++this->transactions; + if (size != 0) { + if (data[0] >= this->registers.size() || data[0] == this->fail_select) + return i2c::ERROR_NOT_ACKNOWLEDGED; + this->pointer = data[0]; + if (size == 3) { + if (this->pointer == this->fail_write) + return i2c::ERROR_NOT_ACKNOWLEDGED; + ++this->writes; + this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2]; + } + } + if (out_size != 0) { + if (out_size != 2 || this->pointer == this->fail_read) + return i2c::ERROR_NOT_ACKNOWLEDGED; + out[0] = this->registers[this->pointer] >> 8; + out[1] = this->registers[this->pointer]; + } + return i2c::ERROR_OK; + } +}; + +class TMP102Test : public ::testing::Test { + protected: + RegisterBus bus_; + TMP102Component chip_; + unsigned publications_{0}; + + void SetUp() override { + new (&App) Application; + char name[] = "tmp102-regression-tests"; + char friendly_name[] = "TMP102 tests"; + App.pre_setup(name, 23, friendly_name, 12); + this->chip_.set_i2c_bus(&this->bus_); + this->chip_.set_i2c_address(0x48); + this->chip_.add_on_state_callback([this](float) { ++this->publications_; }); + } + + void TearDown() override { + App.scheduler.cancel_timeout(&this->chip_, uint32_t{0}); + App.scheduler.call(millis()); + App.~Application(); + } + + static void run_timers(uint32_t duration) { + App.scheduler.call(millis()); + delay(duration); + App.scheduler.call(millis()); + } + + void configure(uint16_t config = 0x0080, uint16_t high = 0x5000, uint16_t low = 0x4B00) { + this->chip_.set_configuration(config, high, low); + } +}; + +TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { + const auto original = this->bus_.registers; + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + this->chip_.update(); + run_timers(55); + EXPECT_FLOAT_EQ(this->chip_.state, 25); + EXPECT_EQ(this->publications_, 1); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.registers, original); +} + +TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) { + for (bool configured_extended : {false, true}) { + this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000, + configured_extended ? 0x2580 : 0x4B00); + this->chip_.setup(); + const auto writes = this->bus_.writes; + for (auto raw : {0x1900, 0x0C81, 0xFF00, 0xFF81}) { + this->bus_.registers[0] = raw; + this->chip_.update(); + EXPECT_FLOAT_EQ(this->chip_.state, raw & 0x8000 ? -1 : 25); + EXPECT_FALSE(this->chip_.status_has_warning()); + EXPECT_EQ(this->bus_.writes, writes); + } + } +} + +TEST_F(TMP102Test, WritableConfigurationFields) { + this->configure(0x1FD0, 0x2800, 0x2580); + this->chip_.setup(); + EXPECT_EQ(this->bus_.registers[1], 0x1FD0); + EXPECT_EQ(this->bus_.registers[3], 0x2800); + EXPECT_EQ(this->bus_.registers[2], 0x2580); +} + +TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { + this->configure(0x0090, 0x2D00, 0x2A80); + this->chip_.setup(); + const auto configured = this->bus_.registers; + this->chip_.update(); + this->bus_.registers = {0x1900, 0x60A0, 0x4B00, 0x5000}; + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); + EXPECT_TRUE(this->chip_.status_has_warning()); + for (size_t reg = 1; reg < 4; ++reg) + EXPECT_EQ(this->bus_.registers[reg], configured[reg]); + this->chip_.update(); + EXPECT_EQ(this->publications_, 2); + EXPECT_FLOAT_EQ(this->chip_.state, 25); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, RecoveryRetriesFailedWritesAndDetectsThresholdOnlyChange) { + this->configure(); + this->chip_.setup(); + this->bus_.registers[3] = 0; + this->bus_.fail_write = 3; + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + this->bus_.fail_write = -1; + this->chip_.update(); + EXPECT_EQ(this->bus_.registers[3], 0x5000); + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); +} + +TEST_F(TMP102Test, ConfigurationReadFailuresDoNotPublishOrOverwriteRegisters) { + this->configure(); + this->chip_.setup(); + for (int reg : {1, 2, 3}) { + const auto publications = this->publications_; + const auto writes = this->bus_.writes; + this->bus_.fail_read = reg; + this->chip_.update(); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_EQ(this->publications_, publications); + EXPECT_EQ(this->bus_.writes, writes); + this->bus_.fail_read = -1; + } +} + +TEST_F(TMP102Test, ReadOnlyConfigurationBitsDoNotTriggerRecovery) { + this->configure(); + this->chip_.setup(); + const auto writes = this->bus_.writes; + this->bus_.registers[1] ^= 0xE020; + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotTriggerFailureSetsWarning) { + this->configure(0x0180); + this->chip_.setup(); + this->bus_.fail_write = 1; + this->chip_.update(); + run_timers(45); + EXPECT_EQ(this->publications_, 0); + EXPECT_TRUE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotRestartsPendingReadAndReselectsTemperature) { + this->configure(0x0180); + this->chip_.setup(); + this->chip_.update(); + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + this->bus_.pointer = 3; + run_timers(45); + EXPECT_EQ(this->publications_, 1); + EXPECT_FLOAT_EQ(this->chip_.state, 25); +} + +TEST_F(TMP102Test, LimitsAreOnlyCheckedWhenConfigurationMatchesResetDefault) { + this->configure(); + this->chip_.setup(); + this->bus_.registers[2] = 0; + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_EQ(this->bus_.registers[2], 0x4B00); + + this->configure(0x00C0); + this->chip_.setup(); + this->bus_.registers[2] = 0; + const auto transactions = this->bus_.transactions; + const auto writes = this->bus_.writes; + this->chip_.update(); + EXPECT_EQ(this->bus_.transactions - transactions, 2); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_EQ(this->publications_, 1); +} + +TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { + for (int reg : {1, 2, 3}) { + TMP102Component failed; + failed.set_i2c_bus(&this->bus_); + failed.set_i2c_address(0x48); + failed.set_configuration(0x0080, 0x5000, 0x4B00); + this->bus_.fail_write = reg; + failed.setup(); + EXPECT_TRUE(failed.is_failed()); + } +} + +} // namespace esphome::tmp102::testing + +#endif From eb1aa2bfd6e44ffbc5082b53be410bad68776896 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 09:07:06 -1000 Subject: [PATCH 051/651] [web_server] Keep the cover, valve and alarm panel action tables in flash (#20397) --- esphome/components/web_server/web_server.cpp | 72 ++++++++------------ 1 file changed, 27 insertions(+), 45 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 062007b02e0..323857c37e8 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -60,6 +60,27 @@ ESPHOME_LOG_TAG(TAG, "web_server"); // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +// One action of a POST /{domain}/{entity}/{action} request, kept in PROGMEM with the name inline so the +// table needs no RAM on ESP8266. +template struct ActionEntry { + char name[16]; + Call &(Call::*action)(); +}; + +// Runs the entry whose name matches the request's action; false when none does. +template +static bool run_action(const UrlMatch &match, Call &call, const ActionEntry (&table)[N]) { + for (const auto &entry : table) { + if (match.method_equals(reinterpret_cast(entry.name))) { + Call &(Call::*action)(); + progmem_memcpy(&action, &entry.action, sizeof(action)); + (call.*action)(); + return true; + } + } + return false; +} + // Out of line: every GET handler ends with this [[maybe_unused]] static void send_json(AsyncWebServerRequest *request, json::JsonBuilder &builder) { auto data = builder.serialize(); @@ -1051,27 +1072,14 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->make_call(); - // Lookup table for cover methods - static const struct { - const char *name; - cover::CoverCall &(cover::CoverCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &cover::CoverCall::set_command_open}, {"close", &cover::CoverCall::set_command_close}, {"stop", &cover::CoverCall::set_command_stop}, {"toggle", &cover::CoverCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1745,27 +1753,14 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->make_call(); - // Lookup table for valve methods - static const struct { - const char *name; - valve::ValveCall &(valve::ValveCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &valve::ValveCall::set_command_open}, {"close", &valve::ValveCall::set_command_close}, {"stop", &valve::ValveCall::set_command_stop}, {"toggle", &valve::ValveCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1831,11 +1826,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques static_cast(&decltype(call)::set_code)); - // Lookup table for alarm control panel methods - static const struct { - const char *name; - alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm}, {"arm_away", &alarm_control_panel::AlarmControlPanelCall::arm_away}, {"arm_home", &alarm_control_panel::AlarmControlPanelCall::arm_home}, @@ -1843,16 +1834,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques {"arm_vacation", &alarm_control_panel::AlarmControlPanelCall::arm_vacation}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found) { + if (!run_action(match, call, ACTIONS)) { request->send(404); return; } From 9355bdc3c5472560566763ec7ed5949b43c8fdf0 Mon Sep 17 00:00:00 2001 From: Patrick Date: Sun, 11 Oct 2026 21:12:28 +0200 Subject: [PATCH 052/651] [canbus] Bus error logging, Auto-recover bus-off on ESP32 (#10953) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/canbus/__init__.py | 17 ++++- esphome/components/canbus/canbus.cpp | 58 +++++++++++++++ esphome/components/canbus/canbus.h | 44 ++++++++++++ esphome/components/esp32_can/canbus.py | 2 +- esphome/components/esp32_can/esp32_can.cpp | 82 ++++++++++++++++++++++ esphome/components/esp32_can/esp32_can.h | 5 ++ esphome/components/mcp2515/canbus.py | 2 +- esphome/components/mcp2515/mcp2515.cpp | 53 +++++++++++--- esphome/components/mcp2515/mcp2515.h | 5 ++ tests/components/esp32_can/common.yaml | 9 +++ tests/components/mcp2515/common.yaml | 11 +++ 11 files changed, 273 insertions(+), 15 deletions(-) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 23dc41d5092..56733870dc4 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -1,14 +1,17 @@ +import logging import re from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID +from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM, CONF_TRIGGER_ID from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@mvturnho", "@danielschramm"] DOMAIN = "canbus" IS_PLATFORM_COMPONENT = True @@ -150,9 +153,19 @@ async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: ) -async def register_canbus(var: MockObj, config: ConfigType) -> None: +async def register_canbus( + var: MockObj, config: ConfigType, *, reports_status: bool | None = None +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.new_Pvariable(config[CONF_ID], var) + if reports_status is None: + _LOGGER.warning( + "CAN bus platform '%s' does not report bus state, so bus-off recovery and " + "error-state logging are disabled. Platform authors should either override " + "get_status() and get_events() and call register_canbus(..., reports_status=True) " + "or pass reports_status=False if the hardware cannot report it", + config[CONF_PLATFORM], + ) await setup_canbus_core_(var, config) diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 88eed2246fd..dfe3ab9fd31 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -1,5 +1,6 @@ #include "canbus.h" #include +#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::canbus { @@ -65,6 +66,12 @@ void Canbus::add_trigger(CanbusTrigger *trigger) { }; void Canbus::loop() { + uint32_t now = App.get_loop_component_start_time(); + if ((now - this->last_event_check_time_) >= EVENT_CHECK_INTERVAL_MS) { + this->log_events_(this->get_events(), now); + this->last_event_check_time_ = now; + } + struct CanFrame can_message; uint32_t message_counter = 0; for (; message_counter < this->max_frames_per_loop_; message_counter++) { @@ -109,4 +116,55 @@ void Canbus::loop() { } } +void Canbus::log_events_(CanEventFlags events, uint32_t now) { + // Bus-off gets its own handling because automatic recovery can make it flap + if (events & CanEventFlags::CAN_EVENT_BUS_OFF) { + this->last_bus_off_time_ = now; + if (!this->bus_off_) { + this->bus_off_ = true; + this->status_set_warning(LOG_STR("Bus off")); + } + } else if (this->bus_off_) { + if ((now - this->last_bus_off_time_) >= EVENT_LOG_BUS_OFF_HOLDOFF_MS) { + // No bus-off event for the hold-off time, so ask whether it is really over + if (this->get_status().bus_off) { + this->last_bus_off_time_ = now; + } else { + this->bus_off_ = false; + this->status_clear_warning(); + // Events gathered while bus-off say nothing about the recovered bus + this->events_to_log_ = 0; + } + } + } else { + this->events_to_log_ |= events; + if (this->events_to_log_ != 0 && (now - this->last_event_log_time_) >= EVENT_LOG_THROTTLE_MS) { + if (this->events_to_log_ & (CanEventFlags::CAN_EVENT_PASSIVE | CanEventFlags::CAN_EVENT_ACTIVE)) { + bool error_passive = this->events_to_log_ & CanEventFlags::CAN_EVENT_PASSIVE; + if (error_passive && (this->events_to_log_ & CanEventFlags::CAN_EVENT_ACTIVE)) { + // Both edges within one throttle window, so the counters decide the current state + auto status = this->get_status(); + error_passive = status.rx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD || + status.tx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD; + } + ESP_LOGW(TAG, "entered %s", error_passive ? LOG_STR_LITERAL("error-passive") : LOG_STR_LITERAL("error-active")); + } + if (this->events_to_log_ & CanEventFlags::CAN_EVENT_RX_QUEUE_FULL) { + ESP_LOGD(TAG, "receive buffer overrun"); + } + this->events_to_log_ = 0; + this->last_event_log_time_ = now; + } + } + +#ifdef ESPHOME_LOG_HAS_VERBOSE + if ((now - this->last_state_log_time_) >= STATE_LOG_INTERVAL_MS) { + auto status = this->get_status(); + ESP_LOGV(TAG, "Status: bus_off %d, tx_err %d, rx_err %d", status.bus_off, status.tx_error_counter, + status.rx_error_counter); + this->last_state_log_time_ = now; + } +#endif +} + } // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 432eb77e13d..077ac9d4cdf 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -48,6 +48,8 @@ template class CanbusSendAction; /* CAN payload length definitions according to ISO 11898-1 */ static const uint8_t CAN_MAX_DATA_LENGTH = 8; +static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; + /* Can Frame describes a normative CAN Frame The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used @@ -61,6 +63,26 @@ struct CanFrame { uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8))); }; +enum CanEventFlags : uint8_t { + CAN_EVENT_PASSIVE = 1 << 0, + CAN_EVENT_ACTIVE = 1 << 1, + CAN_EVENT_BUS_OFF = 1 << 2, + CAN_EVENT_RX_QUEUE_FULL = 1 << 3, +}; + +static constexpr uint32_t CAN_STATUS_UNSET = UINT32_MAX; + +struct CanStatus { + bool bus_off{false}; + uint8_t rx_error_counter{0}; + uint8_t tx_error_counter{0}; + uint32_t tx_failed_count{CAN_STATUS_UNSET}; + uint32_t rx_missed_count{CAN_STATUS_UNSET}; + uint32_t rx_overrun_count{CAN_STATUS_UNSET}; + uint32_t arb_lost_count{CAN_STATUS_UNSET}; + uint32_t bus_error_count{CAN_STATUS_UNSET}; +}; + class Canbus : public Component { public: Canbus(){}; @@ -83,6 +105,7 @@ class Canbus : public Component { void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } + virtual CanStatus get_status() { return {}; } void add_trigger(CanbusTrigger *trigger); /** @@ -104,12 +127,33 @@ class Canbus : public Component { uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py bool use_extended_id_{false}; + uint8_t events_to_log_{0}; + bool bus_off_{false}; CallbackManager &data)> callback_manager_{}; + // interval in which to check for new can bus events (bus-off, passive, active) + static constexpr uint32_t EVENT_CHECK_INTERVAL_MS = 100; + uint32_t last_event_check_time_{0}; + + // time to wait after logging an event before logging the next event(s) + static constexpr uint32_t EVENT_LOG_THROTTLE_MS = 1000; + uint32_t last_event_log_time_{0}; + + // time span without bus-off events before the bus is declared recovered + static constexpr uint32_t EVENT_LOG_BUS_OFF_HOLDOFF_MS = 1000; + uint32_t last_bus_off_time_{0}; + +#ifdef ESPHOME_LOG_HAS_VERBOSE + static constexpr uint32_t STATE_LOG_INTERVAL_MS = 1000; + uint32_t last_state_log_time_{0}; +#endif + + void log_events_(CanEventFlags events, uint32_t now); virtual bool setup_internal() = 0; virtual Error send_message(struct CanFrame *frame) = 0; virtual Error read_message(struct CanFrame *frame) = 0; + virtual CanEventFlags get_events() { return CanEventFlags{}; } }; template class CanbusSendAction final : public Action, public Parented { diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2272459fb8f..2713bae241a 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -129,7 +129,7 @@ async def to_code(config: ConfigType) -> None: # Also enable esp_driver_twai for future migration to new API include_builtin_idf_component("esp_driver_twai") var = cg.new_Pvariable(config[CONF_ID]) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) cg.add(var.set_rx(config[CONF_RX_PIN])) cg.add(var.set_tx(config[CONF_TX_PIN])) diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index 083c97f8cfe..b98ba853e78 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -84,6 +84,8 @@ bool ESP32Can::setup_internal() { twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t) this->tx_, (gpio_num_t) this->rx_, twai_mode); g_config.controller_id = next_twai_ctrl_num++; + g_config.alerts_enabled = TWAI_ALERT_ERR_PASS | TWAI_ALERT_ERR_ACTIVE | TWAI_ALERT_BUS_OFF | + TWAI_ALERT_BUS_RECOVERED | TWAI_ALERT_RX_QUEUE_FULL; if (this->tx_queue_len_.has_value()) { g_config.tx_queue_len = this->tx_queue_len_.value(); } @@ -116,6 +118,86 @@ bool ESP32Can::setup_internal() { return true; } +canbus::CanEventFlags ESP32Can::get_events() { + if (this->twai_handle_ == nullptr) { + return {}; // not set up yet, or setup failed + } + + uint32_t alerts; + esp_err_t err = twai_read_alerts_v2(this->twai_handle_, &alerts, 0); + if (err != ESP_OK) { + if (err != ESP_ERR_TIMEOUT) { + ESP_LOGD(TAG, "failed to get CAN events"); + } + alerts = 0; + } + + uint8_t events = 0; + if (alerts & TWAI_ALERT_ERR_PASS) { + events |= canbus::CAN_EVENT_PASSIVE; + } + if (alerts & TWAI_ALERT_ERR_ACTIVE) { + events |= canbus::CAN_EVENT_ACTIVE; + } + if (alerts & TWAI_ALERT_BUS_OFF) { + events |= canbus::CAN_EVENT_BUS_OFF; + // Start recovery at once, as the MCP2515 does in hardware + this->recovery_pending_ = true; + } + if (alerts & TWAI_ALERT_BUS_RECOVERED) { + // Recovery leaves the controller stopped + this->restart_pending_ = true; + } + if (this->recovery_pending_ || this->restart_pending_) { + this->handle_bus_off_recovery_(); + } + if (alerts & TWAI_ALERT_RX_QUEUE_FULL) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + } + return static_cast(events); +} + +void ESP32Can::handle_bus_off_recovery_() { + // These only fail with an invalid handle or outside the expected state; retry on the next pass + if (this->recovery_pending_) { + if (twai_initiate_recovery_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Recovery after bus off failed")); + return; + } + this->recovery_pending_ = false; + } + if (this->restart_pending_) { + if (twai_start_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Restart after bus off failed")); + return; + } + this->restart_pending_ = false; + } + this->status_clear_error(); +} + +canbus::CanStatus ESP32Can::get_status() { + canbus::CanStatus status = {}; + + twai_status_info_t twai_status; + + if (this->twai_handle_ != nullptr && twai_get_status_info_v2(this->twai_handle_, &twai_status) == ESP_OK) { + status.bus_off = (twai_status.state == TWAI_STATE_BUS_OFF) || (twai_status.state == TWAI_STATE_RECOVERING); + status.rx_error_counter = twai_status.rx_error_counter; + status.tx_error_counter = twai_status.tx_error_counter; + + status.tx_failed_count = twai_status.tx_failed_count; + status.rx_missed_count = twai_status.rx_missed_count; + status.rx_overrun_count = twai_status.rx_overrun_count; + status.arb_lost_count = twai_status.arb_lost_count; + status.bus_error_count = twai_status.bus_error_count; + } else { + ESP_LOGD(TAG, "failed to get CAN status"); + } + + return status; +} + canbus::Error ESP32Can::send_message(struct canbus::CanFrame *frame) { // In listen-only mode, we cannot transmit if (this->mode_ == CAN_MODE_LISTEN_ONLY) { diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f224e10be33..f9549bea346 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -24,16 +24,21 @@ class ESP32Can final : public canbus::Canbus { void set_tx_enqueue_timeout_ms(uint32_t tx_enqueue_timeout_ms) { this->tx_enqueue_timeout_ticks_ = pdMS_TO_TICKS(tx_enqueue_timeout_ms); } + canbus::CanStatus get_status() override; ESP32Can(){}; protected: bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error send_message(struct canbus::CanFrame *frame) override; canbus::Error read_message(struct canbus::CanFrame *frame) override; + void handle_bus_off_recovery_(); int rx_{-1}; int tx_{-1}; CanMode mode_{CAN_MODE_NORMAL}; + bool recovery_pending_{false}; + bool restart_pending_{false}; TickType_t tx_enqueue_timeout_ticks_{}; optional tx_queue_len_{}; optional rx_queue_len_{}; diff --git a/esphome/components/mcp2515/canbus.py b/esphome/components/mcp2515/canbus.py index 8bb8918f965..eab9be0f700 100644 --- a/esphome/components/mcp2515/canbus.py +++ b/esphome/components/mcp2515/canbus.py @@ -40,7 +40,7 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( async def to_code(config: ConfigType) -> None: rhs = mcp2515.new() var = cg.Pvariable(config[CONF_ID], rhs) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) if CONF_CLOCK in config: canclock = CAN_CLOCK[config[CONF_CLOCK]] cg.add(var.set_mcp_clock(canclock)) diff --git a/esphome/components/mcp2515/mcp2515.cpp b/esphome/components/mcp2515/mcp2515.cpp index 8a1973d022f..599a864e54e 100644 --- a/esphome/components/mcp2515/mcp2515.cpp +++ b/esphome/components/mcp2515/mcp2515.cpp @@ -43,6 +43,48 @@ bool MCP2515::setup_internal() { return true; } +canbus::CanEventFlags MCP2515::get_events() { + uint8_t events = 0; + uint8_t error_flags = this->get_error_flags_(); + + // Error-passive is one state for RX and TX together, so only a change of the combined flag is an event + bool was_passive = this->last_error_flags_ & (EFLG_RXEP | EFLG_TXEP); + bool is_passive = error_flags & (EFLG_RXEP | EFLG_TXEP); + if (was_passive != is_passive) { + events |= is_passive ? canbus::CAN_EVENT_PASSIVE : canbus::CAN_EVENT_ACTIVE; + } + if ((this->last_error_flags_ ^ error_flags) & EFLG_TXBO) { + events |= canbus::CAN_EVENT_BUS_OFF; + } + + // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set + // once both buffers are full. However, the RX0OVR flag is actually set instead. + // We can just check for both though because it doesn't break anything. + if (error_flags & (EFLG_RX0OVR | EFLG_RX1OVR)) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + this->clear_rx_n_ovr_flags_(); + } + + this->last_error_flags_ = error_flags; + return static_cast(events); +} + +canbus::CanStatus MCP2515::get_status() { + canbus::CanStatus status = {}; + + uint8_t error_flags = this->get_error_flags_(); + status.bus_off = error_flags & EFLG_TXBO; + + // Transmit Error Counter is followed by Receive Error Counter so we're reading both at once + uint8_t err_counters[2]; + this->read_registers_(MCP_TEC, err_counters, 2); + + status.rx_error_counter = err_counters[1]; + status.tx_error_counter = err_counters[0]; + + return status; +} + canbus::Error MCP2515::reset_() { this->enable(); this->transfer_byte(INSTRUCTION_RESET); @@ -331,17 +373,6 @@ canbus::Error MCP2515::read_message(struct canbus::CanFrame *frame) { rc = canbus::ERROR_NOMSG; } -#ifdef ESPHOME_LOG_HAS_DEBUG - uint8_t err = get_error_flags_(); - // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set - // once both buffers are full. However, the RX0OVR flag is actually set instead. - // We can just check for both though because it doesn't break anything. - if (err & (EFLG_RX0OVR | EFLG_RX1OVR)) { - ESP_LOGD(TAG, "receive buffer overrun"); - clear_rx_n_ovr_flags_(); - } -#endif - return rc; } diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index 960e1508000..8d2fe4d51b6 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -58,6 +58,7 @@ class MCP2515 final : public canbus::Canbus, MCP2515(){}; void set_mcp_clock(CanClock clock) { this->mcp_clock_ = clock; }; void set_mcp_mode(const CanctrlReqopMode mode) { this->mcp_mode_ = mode; } + canbus::CanStatus get_status() override; static const struct TxBnRegs { REGISTER CTRL; REGISTER SIDH; @@ -74,7 +75,11 @@ class MCP2515 final : public canbus::Canbus, protected: CanClock mcp_clock_{MCP_8MHZ}; CanctrlReqopMode mcp_mode_ = CANCTRL_REQOP_NORMAL; + + uint8_t last_error_flags_{0}; + bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error set_mode_(CanctrlReqopMode mode); uint8_t read_register_(REGISTER reg); diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index f15b609d843..a26c5d35a34 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -44,3 +44,12 @@ canbus: break; // to be continued... } + +interval: + - interval: 1s + then: + - lambda: |- + auto status = id(esp32_can_esp32_internal_can).get_status(); + ESP_LOGD("canbus_status", "bus_off: %d, tx_err: %d, rx_err: %d, bus_err: %" PRIu32 ", arb_lost: %" PRIu32, + status.bus_off, status.tx_error_counter, status.rx_error_counter, + status.bus_error_count, status.arb_lost_count); diff --git a/tests/components/mcp2515/common.yaml b/tests/components/mcp2515/common.yaml index 2e88f3e5c64..a35839d9f29 100644 --- a/tests/components/mcp2515/common.yaml +++ b/tests/components/mcp2515/common.yaml @@ -42,6 +42,17 @@ canbus: // to be continued... } +interval: + - interval: 1s + then: + - lambda: |- + auto status = id(mcp2515_can).get_status(); + ESP_LOGD("canbus_status", "bus_off: %d, tx_err: %d, rx_err: %d", + status.bus_off, + status.tx_error_counter, + status.rx_error_counter + ); + button: - platform: template name: MCP2515 Send From 8449820480e14cc8769b9fab16352c50d6c0660d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:17:18 +0200 Subject: [PATCH 053/651] [emontx] Use the core validate_synchronous_automation() helper (#20525) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/__init__.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 971f9769246..79b0e83405b 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -44,20 +44,11 @@ def _get_data() -> EmonTxData: return CORE.data[DOMAIN] -def _synchronous_automation(value: ConfigType) -> ConfigType: - """Reject deferring actions: the received line and parsed JSON are only valid while the - automation runs, and DelayAction and friends keep the trigger arguments for later.""" - if automation.has_non_synchronous_actions(value): - raise cv.Invalid( - "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in on_json and " - "on_data: the received data is only valid while the automation runs. Copy what you need " - "into globals first, then defer in a separate script or automation." - ) - return value - - def _synchronous_automation_schema() -> cv.All: - return cv.All(automation.validate_automation({}), _synchronous_automation) + # The received line and the parsed JSON document only live while the callbacks run. + return automation.validate_synchronous_automation( + "on_json and on_data", "the received data" + ) # Main configuration schema From eff3a6f9a269d80329ad490b3d1bd4498a570a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:17:39 +0200 Subject: [PATCH 054/651] [modbus_client] Use the core deferring-actions validators (#20526) Co-authored-by: Claude Opus 5.5 --- esphome/components/modbus_client/__init__.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 5294f20f2de..67c3cd75bdc 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -113,20 +113,18 @@ def _packed_bit_bytes(bits: int) -> int: return (bits + 7) // 8 -def _synchronous_handler(value: ConfigType) -> ConfigType: - """Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused - once the handler returns, and DelayAction and friends capture the trigger args for later replay.""" - if automation.has_non_synchronous_actions(value): - raise cv.Invalid( - "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in modbus_client " - "handlers: the request/response data is only valid while the handler runs. Copy what you " - "need into globals first, then defer in a separate script or automation." - ) - return value +# Handler PDU spans point into hub buffers that are reused once the handler returns. +_HANDLER_CONTEXT = "modbus_client handlers" +_HANDLER_DATA = "the request/response data" +_synchronous_handler = automation.validate_no_deferring_actions( + _HANDLER_CONTEXT, _HANDLER_DATA +) def _handler_schema() -> cv.All: - return cv.All(automation.validate_automation(single=True), _synchronous_handler) + return automation.validate_synchronous_automation( + _HANDLER_CONTEXT, _HANDLER_DATA, single=True + ) # Each action is its own hub device: the modbus hub routes the reply straight back to the action that From 78c7cd725de3b80dc6d2cc496141617a85d0a5d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 09:22:39 -1000 Subject: [PATCH 055/651] [remote_base] Transmit constant Haier, Mirage and AEHA codes without a heap vector (#20287) --- .../components/remote_base/aeha_protocol.cpp | 9 +- .../components/remote_base/aeha_protocol.h | 12 +- .../components/remote_base/haier_protocol.cpp | 7 +- .../components/remote_base/haier_protocol.h | 7 +- .../remote_base/mirage_protocol.cpp | 9 +- .../components/remote_base/mirage_protocol.h | 9 +- tests/components/remote_base/__init__.py | 3 +- .../remote_base/ir_code_encoders_test.cpp | 139 ++++++++++++++++++ 8 files changed, 170 insertions(+), 25 deletions(-) create mode 100644 tests/components/remote_base/ir_code_encoders_test.cpp diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index eb05f7d21e8..3263f6b2208 100644 --- a/esphome/components/remote_base/aeha_protocol.cpp +++ b/esphome/components/remote_base/aeha_protocol.cpp @@ -14,20 +14,21 @@ static constexpr uint16_t BIT_ONE_LOW_US = BITWISE * 3; static constexpr uint16_t BIT_ZERO_LOW_US = BITWISE; static constexpr uint16_t TRAILER = BITWISE; -void AEHAProtocol::encode(RemoteTransmitData *dst, const AEHAData &data) { - dst->reserve(2 + 32 + (data.data.size() * 2) + 1); +void AEHAProtocol::encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len) { + dst->reserve(2 + 32 + (len * 2) + 1); dst->item(HEADER_HIGH_US, HEADER_LOW_US); for (uint16_t mask = 1 << 15; mask != 0; mask >>= 1) { - if (data.address & mask) { + if (address & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); } else { dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); } } - for (uint8_t bit : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t bit = data[i]; for (uint8_t mask = 1 << 7; mask != 0; mask >>= 1) { if (bit & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 57706a7e4f1..f6064dd5df7 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -15,7 +15,10 @@ struct AEHAData { class AEHAProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const AEHAData &data); + void encode(RemoteTransmitData *dst, const AEHAData &data) { + this->encode(dst, data.address, data.data.data(), data.data.size()); + } + void encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len); optional decode(RemoteReceiveData src); void dump(const AEHAData &data); @@ -32,11 +35,10 @@ template class AEHAAction : public RemoteTransmitterActionBaseaddress_.value(x...); - data.data = this->data_.value(x...); + const uint16_t address = this->address_.value(x...); dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); - AEHAProtocol().encode(dst, data); + this->data_.visit( + [dst, address](const uint8_t *data, size_t len) { AEHAProtocol().encode(dst, address, data, len); }, x...); } }; diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index 3c9ba7b61f0..e7fa563fb54 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -29,16 +29,17 @@ void HaierProtocol::encode_byte_(RemoteTransmitData *dst, uint8_t item) { } } -void HaierProtocol::encode(RemoteTransmitData *dst, const HaierData &data) { +void HaierProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 16)); + dst->reserve(5 + ((len + 1) * 16)); dst->mark(HEADER_LOW_US); dst->space(HEADER_LOW_US); dst->mark(HEADER_LOW_US); dst->space(HEADER_HIGH_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t item = data[i]; this->encode_byte_(dst, item); checksum += item; } diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 90af88d2c78..770784b0fc5 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -13,7 +13,8 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const HaierData &data); + void encode(RemoteTransmitData *dst, const HaierData &data) { this->encode(dst, data.data.data(), data.data.size()); } + void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); optional decode(RemoteReceiveData src); void dump(const HaierData &data); @@ -28,9 +29,7 @@ template class HaierAction : public RemoteTransmitterActionBase< TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { - HaierData data{}; - data.data = this->code_.value(x...); - HaierProtocol().encode(dst, data); + this->code_.visit([dst](const uint8_t *data, size_t len) { HaierProtocol().encode(dst, data, len); }, x...); } }; diff --git a/esphome/components/remote_base/mirage_protocol.cpp b/esphome/components/remote_base/mirage_protocol.cpp index c2a487080bc..23c6bd21e8f 100644 --- a/esphome/components/remote_base/mirage_protocol.cpp +++ b/esphome/components/remote_base/mirage_protocol.cpp @@ -16,16 +16,17 @@ constexpr unsigned int MIRAGE_IR_PACKET_BIT_SIZE = 120; // Max data bytes in packet (excluding checksum) constexpr size_t MIRAGE_MAX_DATA_BYTES = (MIRAGE_IR_PACKET_BIT_SIZE / 8); -void MirageProtocol::encode(RemoteTransmitData *dst, const MirageData &data) { +void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { char hex_buf[format_hex_pretty_size(MIRAGE_MAX_DATA_BYTES)]; - ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); + ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, data, len)); dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 2)); + dst->reserve(5 + ((len + 1) * 2)); dst->mark(HEADER_MARK_US); dst->space(HEADER_SPACE_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t item = data[i]; this->encode_byte_(dst, item); checksum += (item >> 4) + (item & 0xF); } diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index 958127ba664..923d412e841 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -13,7 +13,10 @@ struct MirageData { class MirageProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MirageData &data); + void encode(RemoteTransmitData *dst, const MirageData &data) { + this->encode(dst, data.data.data(), data.data.size()); + } + void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); optional decode(RemoteReceiveData src); void dump(const MirageData &data); @@ -28,9 +31,7 @@ template class MirageAction : public RemoteTransmitterActionBase TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { - MirageData data{}; - data.data = this->code_.value(x...); - MirageProtocol().encode(dst, data); + this->code_.visit([dst](const uint8_t *data, size_t len) { MirageProtocol().encode(dst, data, len); }, x...); } }; diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 714f8ab571a..c52eb41ca44 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: # Protocol sources are compiled only behind their define; keep the ones under test. - request_protocol("hob2hood") + for protocol in ("hob2hood", "haier", "mirage", "aeha"): + request_protocol(protocol) manifest.to_code = to_code_testing # AUTO_LOAD is not resolved by the unit test build. diff --git a/tests/components/remote_base/ir_code_encoders_test.cpp b/tests/components/remote_base/ir_code_encoders_test.cpp new file mode 100644 index 00000000000..bffea25864e --- /dev/null +++ b/tests/components/remote_base/ir_code_encoders_test.cpp @@ -0,0 +1,139 @@ +#include +#include +#include +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/components/remote_base/haier_protocol.h" +#include "esphome/components/remote_base/mirage_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +const uint8_t HAIER_CODE[] = {0xA6, 0x12, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05}; +const uint8_t MIRAGE_CODE[] = {0x56, 0x75, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; +const uint8_t AEHA_CODE[] = {0x80, 0x00, 0x00, 0x00, 0x00}; +constexpr uint16_t AEHA_ADDRESS = 0x2002; + +// Captured from the struct encoders before they gained the pointer and length overloads +const RawTimings HAIER_GOLDEN = { + 3100, -3100, 3100, -4400, 540, -1650, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -1650, 540, -1650, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -1650, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -1650, 540, -1650, 540, -580, + 540, -1650, 540, -1650, 540, -1650, 540, -1650, 540, -580, 540, -1650, 540}; +const RawTimings MIRAGE_GOLDEN = { + 8360, -4248, 554, -545, 554, -1592, 554, -1592, 554, -545, 554, -1592, 554, -545, 554, -1592, 554, -545, + 554, -1592, 554, -545, 554, -1592, 554, -545, 554, -1592, 554, -1592, 554, -1592, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -1592, 554, -1592, 554, -1592, 554, -545, + 554, -1592, 554, -545, 554, -545, 554, -545, 554}; +const RawTimings AEHA_GOLDEN = { + 3400, -1700, 425, -425, 425, -425, 425, -1275, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -1275, 425, -425, 425, -1275, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425}; + +std::vector haier_code() { return {std::begin(HAIER_CODE), std::end(HAIER_CODE)}; } +std::vector mirage_code() { return {std::begin(MIRAGE_CODE), std::end(MIRAGE_CODE)}; } +std::vector aeha_code() { return {std::begin(AEHA_CODE), std::end(AEHA_CODE)}; } + +// The Haier and Mirage decoders expect an item after the last mark (pre-existing) +RawTimings with_trailing_space(RawTimings timings) { + timings.push_back(-10000); + return timings; +} + +template RawTimings encode(A &action) { + RemoteTransmitData dst; + action.encode(&dst); + return dst.get_data(); +} + +// Exposes the protected encode() of an action for testing +template class Open : public A { + public: + using A::encode; +}; + +// A constant and a lambda code must both encode to the golden timings +template +void expect_static_and_lambda_match(const uint8_t *code, size_t len, std::vector (*lambda)(), + const RawTimings &golden) { + Open fixed, from_lambda; + fixed.set_code_static(code, len); + from_lambda.set_code_template(lambda); + EXPECT_EQ(encode(fixed), golden); + EXPECT_EQ(encode(from_lambda), golden); +} + +template void expect_decodes_to(const RawTimings &timings, const std::vector &code) { + auto decoded = P().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + EXPECT_EQ(decoded.value_or(typename P::ProtocolData{}).data, code); +} + +} // namespace + +TEST(IrCodeEncodersTest, HaierStaticAndLambdaMatchGolden) { + expect_static_and_lambda_match>(HAIER_CODE, sizeof(HAIER_CODE), haier_code, HAIER_GOLDEN); +} + +TEST(IrCodeEncodersTest, MirageStaticAndLambdaMatchGolden) { + expect_static_and_lambda_match>(MIRAGE_CODE, sizeof(MIRAGE_CODE), mirage_code, MIRAGE_GOLDEN); +} + +TEST(IrCodeEncodersTest, AehaStaticAndLambdaMatchGolden) { + Open> fixed, lambda; + for (auto *action : {&fixed, &lambda}) { + // TemplatableFn fields take functions, not raw constants + action->set_address([]() -> uint16_t { return AEHA_ADDRESS; }); + action->set_carrier_frequency([]() -> uint32_t { return 38000; }); + } + fixed.set_data_static(AEHA_CODE, sizeof(AEHA_CODE)); + lambda.set_data_template(aeha_code); + EXPECT_EQ(encode(fixed), AEHA_GOLDEN); + EXPECT_EQ(encode(lambda), AEHA_GOLDEN); +} + +TEST(IrCodeEncodersTest, StructEncodersMatchGolden) { + RemoteTransmitData haier, mirage, aeha; + HaierProtocol().encode(&haier, HaierData{haier_code()}); + MirageProtocol().encode(&mirage, MirageData{mirage_code()}); + AEHAData aeha_data; + aeha_data.address = AEHA_ADDRESS; + aeha_data.data = aeha_code(); + AEHAProtocol().encode(&aeha, aeha_data); + EXPECT_EQ(haier.get_data(), HAIER_GOLDEN); + EXPECT_EQ(mirage.get_data(), MIRAGE_GOLDEN); + EXPECT_EQ(aeha.get_data(), AEHA_GOLDEN); +} + +TEST(IrCodeEncodersTest, RoundTripDecodes) { + expect_decodes_to(with_trailing_space(HAIER_GOLDEN), haier_code()); + expect_decodes_to(with_trailing_space(MIRAGE_GOLDEN), mirage_code()); + expect_decodes_to(AEHA_GOLDEN, aeha_code()); + auto aeha = AEHAProtocol().decode(RemoteReceiveData(AEHA_GOLDEN, 25, TOLERANCE_MODE_PERCENTAGE)); + EXPECT_EQ(aeha.value_or(AEHAData{}).address, AEHA_ADDRESS); +} + +} // namespace esphome::remote_base::testing From c130e2808b24fe430df9d88d628ffd3f6da3ee05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 09:23:05 -1000 Subject: [PATCH 056/651] [display] Build on_page_change with build_callback_automation and compile it out when unused (#20456) --- esphome/components/display/__init__.py | 30 ++++++++++++-------------- esphome/components/display/display.cpp | 17 ++++++--------- esphome/components/display/display.h | 26 +++++++++------------- esphome/core/defines.h | 1 + 4 files changed, 32 insertions(+), 42 deletions(-) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 1cd75ddaf07..0b871af30cd 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -21,7 +21,6 @@ from esphome.const import ( CONF_PAGES, CONF_ROTATION, CONF_TO, - CONF_TRIGGER_ID, CONF_UPDATE_INTERVAL, CONF_WIDTH, SCHEDULER_DONT_RUN, @@ -39,14 +38,13 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayOnPageChangeTrigger = display_ns.class_( - "DisplayOnPageChangeTrigger", automation.Trigger -) CONF_ON_PAGE_CHANGE = "on_page_change" CONF_SHOW_TEST_CARD = "show_test_card" CONF_UNSPECIFIED = "unspecified" +_request_page_change_slot = cg.slot_counter("DISPLAY_PAGE_CHANGE_CALLBACK_COUNT") + DISPLAY_ROTATIONS = { 0: display_ns.DISPLAY_ROTATION_0_DEGREES, 90: display_ns.DISPLAY_ROTATION_90_DEGREES, @@ -99,9 +97,6 @@ FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend( ), cv.Optional(CONF_ON_PAGE_CHANGE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayOnPageChangeTrigger - ), cv.Optional(CONF_FROM): cv.use_id(DisplayPage), cv.Optional(CONF_TO): cv.use_id(DisplayPage), } @@ -137,15 +132,18 @@ async def setup_display_core_(var, config): pages.append(page) cg.add(var.set_pages(pages)) for conf in config.get(CONF_ON_PAGE_CHANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if CONF_FROM in conf: - page = await cg.get_variable(conf[CONF_FROM]) - cg.add(trigger.set_from(page)) - if CONF_TO in conf: - page = await cg.get_variable(conf[CONF_TO]) - cg.add(trigger.set_to(page)) - await automation.build_automation( - trigger, [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], conf + _request_page_change_slot(str(var)) + checks = [] + for key, name in ((CONF_FROM, "from"), (CONF_TO, "to")): + if (page_id := conf.get(key)) is not None: + page = await cg.get_variable(page_id) + checks.append(f"{name} == ::{page}") + await automation.build_callback_automation( + var, + "add_on_page_change_callback", + [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], + conf, + when=" && ".join(checks) or None, ) if config.get(CONF_SHOW_TEST_CARD): cg.add(var.show_test_card()) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 49cd2fc7c66..0bd8346953c 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -678,12 +678,14 @@ void Display::set_pages(std::vector pages) { } void Display::show_page(DisplayPage *page) { - this->previous_page_ = this->page_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + DisplayPage *previous = this->page_; this->page_ = page; - if (this->previous_page_ != this->page_) { - for (auto *t : on_page_change_triggers_) - t->process(this->previous_page_, this->page_); - } + if (previous != page) + this->page_change_callback_.call(previous, page); +#else + this->page_ = page; +#endif } void Display::do_update_() { @@ -700,11 +702,6 @@ void Display::do_update_() { this->clear_clipping_(); } -void DisplayOnPageChangeTrigger::process(DisplayPage *from, DisplayPage *to) { - if ((this->from_ == nullptr || this->from_ == from) && (this->to_ == nullptr || this->to_ == to)) - this->trigger(from, to); -} - void Display::strftime(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, ESPTime time) { char buffer[64]; diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 74b5bcedd94..7d1053f4fa7 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -7,6 +7,8 @@ #include "esphome/core/color.h" #include "esphome/core/automation.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/time.h" #include "esphome/core/log.h" #include "display_color_utils.h" @@ -173,7 +175,6 @@ enum RegularPolygonDrawing { class Display; class DisplayPage; -class DisplayOnPageChangeTrigger; /** Optimized display writer that uses function pointers for stateless lambdas. * @@ -702,7 +703,11 @@ class Display : public PollingComponent { const DisplayPage *get_active_page() const { return this->page_; } - void add_on_page_change_trigger(DisplayOnPageChangeTrigger *t) { this->on_page_change_triggers_.push_back(t); } +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + template void add_on_page_change_callback(F &&callback) { + this->page_change_callback_.add(std::forward(callback)); + } +#endif /// Internal method to set the display rotation with. virtual void set_rotation(DisplayRotation rotation); @@ -809,8 +814,9 @@ class Display : public PollingComponent { DisplayRotation rotation_{DISPLAY_ROTATION_0_DEGREES}; display_writer_t writer_{}; DisplayPage *page_{nullptr}; - DisplayPage *previous_page_{nullptr}; - std::vector on_page_change_triggers_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + StaticCallbackManager page_change_callback_; +#endif bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; uint8_t wdt_pixel_counter_{0}; @@ -850,18 +856,6 @@ template class DisplayPageShowAction final : public Action { - public: - explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } - void process(DisplayPage *from, DisplayPage *to); - void set_from(DisplayPage *p) { this->from_ = p; } - void set_to(DisplayPage *p) { this->to_ = p; } - - protected: - DisplayPage *from_{nullptr}; - DisplayPage *to_{nullptr}; -}; - const LogString *text_align_to_string(TextAlign textalign); } // namespace esphome::display diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 31549ceb8eb..552221dd5eb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -63,6 +63,7 @@ #define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES #define USE_DISPLAY +#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT From 86192d89f4afc681e18b0652ea1a7fb1f6b4bb35 Mon Sep 17 00:00:00 2001 From: Petter Ljungqvist Date: Sun, 11 Oct 2026 21:29:56 +0200 Subject: [PATCH 057/651] [ufm01] Improve stale recovery, add diagnostics and clear flow action (#18899) Co-authored-by: Cursor Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/ufm01/__init__.py | 37 ++ esphome/components/ufm01/text_sensor.py | 34 ++ esphome/components/ufm01/ufm01.cpp | 563 ++++++++++++++---- esphome/components/ufm01/ufm01.h | 135 +++-- esphome/core/defines.h | 3 + tests/components/ufm01/__init__.py | 12 + tests/components/ufm01/benchmark.yaml | 29 + tests/components/ufm01/common.h | 214 ++++++- tests/components/ufm01/common.yaml | 14 + tests/components/ufm01/test.esp32-idf.yaml | 3 +- tests/components/ufm01/test.esp8266-ard.yaml | 3 +- tests/components/ufm01/test.rp2040-ard.yaml | 3 +- .../ufm01/ufm01_clear_action_test.cpp | 113 ++++ .../ufm01/ufm01_diagnostics_test.cpp | 173 ++++++ tests/components/ufm01/ufm01_frame_test.cpp | 26 +- tests/components/ufm01/ufm01_startup_test.cpp | 127 +++- tests/components/ufm01/validate.host.yaml | 16 + .../integration/fixtures/uart_mock_ufm01.yaml | 153 +++++ tests/integration/test_uart_mock_ufm01.py | 399 +++++++++++++ 19 files changed, 1858 insertions(+), 199 deletions(-) create mode 100644 esphome/components/ufm01/text_sensor.py create mode 100644 tests/components/ufm01/__init__.py create mode 100644 tests/components/ufm01/benchmark.yaml create mode 100644 tests/components/ufm01/ufm01_clear_action_test.cpp create mode 100644 tests/components/ufm01/ufm01_diagnostics_test.cpp create mode 100644 tests/components/ufm01/validate.host.yaml create mode 100644 tests/integration/fixtures/uart_mock_ufm01.yaml create mode 100644 tests/integration/test_uart_mock_ufm01.py diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index e1fcef107d5..1846958e591 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -1,7 +1,11 @@ +from dataclasses import dataclass + +from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@ljungqvist"] @@ -16,6 +20,23 @@ UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component CONF_UFM01_ID = "ufm01_id" + +@dataclass +class UFM01Data: + clear_action_used: bool = False + + +def _get_data() -> UFM01Data: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UFM01Data() + return CORE.data[DOMAIN] + + +def _note_clear_action(config: ConfigType) -> ConfigType: + _get_data().clear_action_used = True + return config + + CONFIG_SCHEMA = ( cv.Schema( { @@ -26,6 +47,13 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(UFM01Component), + }, + _note_clear_action, +) + FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "ufm01", require_tx=True, @@ -41,3 +69,12 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) + if _get_data().clear_action_used: + cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") + + +automation.register_apply_action( + "ufm01.clear_accumulated_flow", + CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA, + automation.ApplyCall("request_clear_accumulated_flow()"), +) diff --git a/esphome/components/ufm01/text_sensor.py b/esphome/components/ufm01/text_sensor.py new file mode 100644 index 00000000000..dcb5d3294c2 --- /dev/null +++ b/esphome/components/ufm01/text_sensor.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP, ICON_FINGERPRINT +from esphome.types import ConfigType + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_METER_ID = "meter_id" +CONF_SOFTWARE_VERSION = "software_version" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_METER_ID): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_FINGERPRINT + ), + cv.Optional(CONF_SOFTWARE_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UFM01_ID]) + sensors = text_sensor.sub_text_sensors(config) + if await sensors(CONF_METER_ID, hub.set_meter_id_text_sensor) is not None: + cg.add_define("USE_UFM01_METER_ID") + if ( + await sensors(CONF_SOFTWARE_VERSION, hub.set_software_version_text_sensor) + is not None + ): + cg.add_define("USE_UFM01_SOFTWARE_VERSION") diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index c68842ab6c1..47ff2165f8c 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -1,8 +1,9 @@ #include "ufm01.h" -#include "esphome/core/hal.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include #include #include #include @@ -13,6 +14,7 @@ ESPHOME_LOG_TAG(TAG, "ufm01"); static constexpr uint8_t COMMAND_ACK = 0xE5; static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 500; +static constexpr uint32_t CLEAR_QUEUE_TIMEOUT_MS = 15000; static constexpr uint32_t STARTUP_DELAY_MS = 2000; static constexpr uint32_t POST_RESET_DELAY_MS = 2000; static constexpr uint32_t RESET_RETRY_DELAY_MS = 800; @@ -20,23 +22,40 @@ static constexpr uint32_t STARTUP_RETRY_MS = 3000; static constexpr uint32_t PASSIVE_POLL_INTERVAL_MS = 1000; static constexpr uint32_t ACTIVE_STALE_MS = 5000; static constexpr uint32_t PASSIVE_READ_TIMEOUT_MS = 1000; +static constexpr uint32_t SOFTWARE_VERSION_READ_TIMEOUT_MS = 2000; +static constexpr uint32_t SOFTWARE_VERSION_RETRY_MS = 30000; +static constexpr uint8_t SOFTWARE_VERSION_MAX_ATTEMPTS = 3; static constexpr uint32_t ACTIVE_FRAME_TIMEOUT_MS = 3000; +// After this many consecutive passive poll failures, re-run the reset/startup sequence +static constexpr uint8_t PASSIVE_FAIL_ESCALATE_COUNT = 8; static constexpr float L_PER_M3 = 1000.0f; static constexpr float M3_PER_L = 1.0f / L_PER_M3; static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array PASSIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x01, 0x5D, 0x16}; static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; static constexpr std::array READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; +static constexpr std::array READ_SENSOR_DATA_WITH_ID = {0xFE, 0xFE, 0x11, 0x5B, 0xCB, 0x26, 0x16}; +static constexpr std::array GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11, 0x5E, 0x62, 0xC0, 0x16}; // Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_DEVICE_ID_INDEX = 2; static constexpr size_t FRAME_CHECKSUM_INDEX = 30; static constexpr size_t FRAME_STOP_INDEX = 31; static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr size_t PASSIVE_WITH_ID_DEVICE_ID_INDEX = 2; +static constexpr uint8_t PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX = 22; +static constexpr uint8_t PASSIVE_WITH_ID_TEMP_FLAG_INDEX = 31; +static constexpr uint8_t PASSIVE_WITH_ID_ST1_INDEX = 35; +static constexpr size_t PASSIVE_WITH_ID_CHECKSUM_INDEX = 37; +static constexpr size_t PASSIVE_WITH_ID_STOP_INDEX = 38; static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; @@ -58,8 +77,40 @@ static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; +static_assert(SOFTWARE_VERSION_RESPONSE_SIZE <= PASSIVE_FRAME_MAX_SIZE, "software version read reuses passive_frame_"); +static constexpr size_t SOFTWARE_VERSION_CHECKSUM_INDEX = 5; +static constexpr size_t SOFTWARE_VERSION_STOP_INDEX = 6; + static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } +static bool bcd_to_digits(const uint8_t *data, size_t len, char *out) { + bool all_zero = true; + for (size_t i = 0; i < len; ++i) { + const uint8_t lo = data[i] & 0x0F; + const uint8_t hi = data[i] >> 4; + if (lo > 9 || hi > 9) + return false; + const size_t pos = (len - 1 - i) * 2; + if (hi != 0) + all_zero = false; + out[pos] = static_cast('0' + hi); + if (lo != 0) + all_zero = false; + out[pos + 1] = static_cast('0' + lo); + } + out[len * 2] = '\0'; + return !all_zero; +} + +static bool validate_software_version_response(const uint8_t data[SOFTWARE_VERSION_RESPONSE_SIZE]) { + if (data[0] != COMMAND_ACK || data[SOFTWARE_VERSION_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 1; i < SOFTWARE_VERSION_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[SOFTWARE_VERSION_CHECKSUM_INDEX] == sum; +} + static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { if (data[index] == expected) return true; @@ -84,25 +135,42 @@ static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { return data[21] == sum_bytes(data, 21); } -static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { +#ifdef USE_UFM01_METER_ID +static bool validate_passive_with_id_frame(const uint8_t data[PASSIVE_FRAME_WITH_ID_SIZE]) { + if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2_WITH_ID || + data[PASSIVE_WITH_ID_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 0; i < PASSIVE_WITH_ID_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[PASSIVE_WITH_ID_CHECKSUM_INDEX] == sum; +} +#endif + +// Start index of each run in a passive reply; each run maps to the matching run of the active frame +struct PassiveLayout { + uint8_t acc_flow; // flag + 6 bytes, active 8..14 + uint8_t instant_flow; // flag + 5 bytes, active 15..20 + uint8_t temperature; // flag + 3 bytes, active 24..27 + uint8_t status; // ST1, ST2, checksum, active 28..30 +}; +static constexpr PassiveLayout PASSIVE_NO_ID_LAYOUT{2, 9, 15, 19}; +#ifdef USE_UFM01_METER_ID +static constexpr PassiveLayout PASSIVE_WITH_ID_LAYOUT{PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_TEMP_FLAG_INDEX, PASSIVE_WITH_ID_ST1_INDEX}; +#endif + +static void passive_to_active_frame(const uint8_t *passive, const PassiveLayout &layout, uint8_t active[FRAME_SIZE]) { std::memset(active, 0, FRAME_SIZE); active[0] = FRAME_START_BYTE_1; active[1] = FRAME_START_BYTE_2; - active[7] = 0x01; - active[8] = passive[2]; - for (size_t i = 0; i < 6; ++i) - active[9 + i] = passive[3 + i]; - active[15] = passive[9]; - for (size_t i = 0; i < 5; ++i) - active[16 + i] = passive[10 + i]; - active[21] = FRAME_FLAG_RESERVED_SECTION; - active[24] = passive[15]; - for (size_t i = 0; i < 3; ++i) - active[25 + i] = passive[16 + i]; - active[28] = passive[19]; - active[29] = passive[20]; - active[30] = passive[21]; - active[31] = FRAME_STOP_BYTE; + std::copy_n(&passive[layout.acc_flow], 7, &active[FRAME_ACC_FLOW_FLAG_INDEX]); + std::copy_n(&passive[layout.instant_flow], 6, &active[FRAME_INDEX_INSTANT_FLOW_FLAG]); + active[FRAME_INDEX_RESERVED_SECTION] = FRAME_FLAG_RESERVED_SECTION; + std::copy_n(&passive[layout.temperature], 4, &active[FRAME_INDEX_TEMP_FLAG]); + std::copy_n(&passive[layout.status], 3, &active[FRAME_ST1_INDEX]); + active[FRAME_STOP_INDEX] = FRAME_STOP_BYTE; } static float read_accumulated_flow(const uint8_t data[FRAME_SIZE]) { @@ -118,8 +186,10 @@ static float read_flow(const uint8_t data[FRAME_SIZE]) { M3_PER_L; } +static constexpr size_t LOG_HEX_MAX_SIZE = std::max(FRAME_SIZE, PASSIVE_FRAME_MAX_SIZE); + static void log_hex(const uint8_t *data, size_t len) { - char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + char hex_buf[format_hex_pretty_size(LOG_HEX_MAX_SIZE)]; ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); } @@ -157,7 +227,6 @@ void UFM01Component::send_command_no_wait_(const std::array &command this->flush(); } -// Drains whatever is currently in the RX buffer, looking for a command ACK. bool UFM01Component::consume_ack_() { while (this->available()) { uint8_t byte; @@ -170,29 +239,65 @@ bool UFM01Component::consume_ack_() { return false; } -bool UFM01Component::send_command_(const std::array &command) { - this->send_command_no_wait_(command); - const uint32_t start = millis(); - while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { - if (this->consume_ack_()) - return true; - delay(1); - } - return false; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +bool UFM01Component::can_start_clear_action_() const { + return +#ifdef USE_UFM01_SOFTWARE_VERSION + !this->software_version_read_pending_ && +#endif + (this->operating_mode_ == OperatingMode::ACTIVE_STREAM || + (this->operating_mode_ == OperatingMode::PASSIVE_POLL && !this->passive_read_pending_)); } -bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } +void UFM01Component::request_clear_accumulated_flow() { + if (this->clear_pending_) { + ESP_LOGD(TAG, "Clear accumulated flow already pending"); + return; + } + this->clear_pending_ = true; + this->pending_clear_sent_ = false; + this->pending_clear_start_ms_ = App.get_loop_component_start_time(); +} -bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } +void UFM01Component::finish_pending_clear_() { + this->clear_pending_ = false; + this->pending_clear_sent_ = false; +} -bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } +void UFM01Component::loop_pending_clear_action_(uint32_t now) { + if (!this->clear_pending_) + return; + if (!this->pending_clear_sent_) { + if (!this->can_start_clear_action_()) { + if (now - this->pending_clear_start_ms_ < CLEAR_QUEUE_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow timed out waiting for an idle UART"); + this->finish_pending_clear_(); + return; + } + this->pending_clear_start_ms_ = now; + this->pending_clear_sent_ = true; + this->send_command_no_wait_(CLEAR_ACCUMULATED_FLOW); + return; + } + if (this->consume_ack_()) { + ESP_LOGI(TAG, "Clear accumulated flow acknowledged"); + this->finish_pending_clear_(); + return; + } + if (now - this->pending_clear_start_ms_ < COMMAND_ACK_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow not acknowledged"); + this->finish_pending_clear_(); +} +#endif // USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION float UFM01Component::get_setup_priority() const { return setup_priority::LATE; } void UFM01Component::setup() { ESP_LOGI(TAG, "Setting up UFM-01..."); this->startup_wait_ms_ = STARTUP_DELAY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, App.get_loop_component_start_time()); } void UFM01Component::dump_config() { @@ -208,9 +313,123 @@ void UFM01Component::dump_config() { LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); #endif +#ifdef USE_UFM01_METER_ID + LOG_TEXT_SENSOR(" ", "Meter ID", this->meter_id_text_sensor_); +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + LOG_TEXT_SENSOR(" ", "Software Version", this->software_version_text_sensor_); +#endif } -void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { +// The binary sensors keep their last state while readings are stale; only flow and temperature go unknown +void UFM01Component::publish_stale_flow_and_temperature_() { +#ifdef USE_SENSOR + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); +#endif +} + +#ifdef USE_UFM01_METER_ID +void UFM01Component::publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]) { + if (this->meter_id_text_sensor_ == nullptr || this->meter_id_published_) + return; + + char meter_id_str[DEVICE_ID_STRING_LENGTH + 1]; + if (!bcd_to_digits(&data[FRAME_DEVICE_ID_INDEX], DEVICE_ID_LENGTH, meter_id_str)) { + if (!this->meter_id_invalid_logged_) { + ESP_LOGW(TAG, "Meter ID in frame is zero or not BCD, leaving the sensor unknown"); + this->meter_id_invalid_logged_ = true; + } + return; + } + + this->meter_id_text_sensor_->publish_state(meter_id_str); + this->meter_id_published_ = true; + ESP_LOGI(TAG, "UFM-01 meter ID: %s", meter_id_str); +} +#endif + +#ifdef USE_UFM01_SOFTWARE_VERSION +void UFM01Component::start_software_version_read_(uint32_t now) { + this->send_command_no_wait_(GET_SOFTWARE_VERSION); + this->passive_index_ = 0; + this->passive_start_ms_ = now; + this->software_version_read_pending_ = true; +} + +ReadResult UFM01Component::continue_software_version_read_(uint32_t now) { + while (this->available() && this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + // The tail of an in-flight active-stream frame can arrive before the reply + if (this->passive_index_ == 0 && byte != COMMAND_ACK) + continue; + this->passive_frame_[this->passive_index_++] = byte; + } + + if (this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + if (now - this->passive_start_ms_ < SOFTWARE_VERSION_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "software version read timeout (%u/%u bytes)", this->passive_index_, + static_cast(SOFTWARE_VERSION_RESPONSE_SIZE)); + return ReadResult::READ_RESULT_FAILURE; + } + + if (!validate_software_version_response(this->passive_frame_)) { + log_hex(this->passive_frame_, SOFTWARE_VERSION_RESPONSE_SIZE); + ESP_LOGW(TAG, "invalid software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_) { + char version_str[SOFTWARE_VERSION_STRING_LENGTH + 1]; + if (!bcd_to_digits(&this->passive_frame_[1], SOFTWARE_VERSION_LENGTH, version_str)) { + ESP_LOGW(TAG, "invalid BCD data in software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + this->software_version_text_sensor_->publish_state(version_str); + this->software_version_published_ = true; + ESP_LOGI(TAG, "UFM-01 software version: %s", version_str); + } + + return ReadResult::READ_RESULT_SUCCESS; +} + +void UFM01Component::finish_software_version_read_(ReadResult result, uint32_t now) { + this->software_version_read_pending_ = false; + this->last_software_version_attempt_ms_ = now; + if (result == ReadResult::READ_RESULT_FAILURE && + ++this->software_version_failures_ == SOFTWARE_VERSION_MAX_ATTEMPTS) { + ESP_LOGW(TAG, "Software version not read after %u attempts, giving up", SOFTWARE_VERSION_MAX_ATTEMPTS); + } +} + +// Returns true while a software version read owns the UART, so the caller skips its own work +bool UFM01Component::service_software_version_(uint32_t now) { + if (this->software_version_read_pending_) { + const ReadResult result = this->continue_software_version_read_(now); + if (result != ReadResult::READ_RESULT_PENDING) + this->finish_software_version_read_(result, now); + return true; + } + if (this->software_version_text_sensor_ == nullptr || this->software_version_published_ || + this->software_version_failures_ >= SOFTWARE_VERSION_MAX_ATTEMPTS || this->passive_read_pending_) + return false; + if (this->last_software_version_attempt_ms_ != 0 && + now - this->last_software_version_attempt_ms_ < SOFTWARE_VERSION_RETRY_MS) + return false; + this->start_software_version_read_(now); + return true; +} +#endif // USE_UFM01_SOFTWARE_VERSION + +void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now) { +#ifdef USE_UFM01_METER_ID + this->publish_meter_id_from_frame_(data); +#endif bool empty_tube = read_empty_tube(data); #ifdef USE_BINARY_SENSOR if (this->ufc_chip_error_binary_sensor_ != nullptr) @@ -229,10 +448,7 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); if (empty_tube) { - if (this->flow_sensor_ != nullptr) - this->flow_sensor_->publish_state(NAN); - if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(NAN); + this->publish_stale_flow_and_temperature_(); } else { if (this->flow_sensor_ != nullptr) this->flow_sensor_->publish_state(read_flow(data)); @@ -240,12 +456,12 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->temperature_sensor_->publish_state(read_temperature(data)); } #endif - this->last_valid_frame_ms_ = millis(); + this->last_valid_frame_ms_ = now; this->status_clear_warning(); this->status_clear_error(); } -bool UFM01Component::process_active_stream_() { +bool UFM01Component::process_active_stream_(uint32_t now) { bool got_valid_frame = false; while (this->available()) { @@ -264,7 +480,7 @@ bool UFM01Component::process_active_stream_() { continue; if (validate_active_frame(this->data_)) { - this->on_active_frame_(this->data_); + this->on_active_frame_(this->data_, now); this->read_index_ = 0; got_valid_frame = true; continue; @@ -287,33 +503,89 @@ bool UFM01Component::process_active_stream_() { return got_valid_frame; } -void UFM01Component::set_startup_phase_(StartupPhase phase) { - this->startup_phase_ = phase; - this->phase_start_ms_ = millis(); -} - -void UFM01Component::enter_active_stream_(const char *reason) { - ESP_LOGI(TAG, "UFM-01 active stream %s", reason); - this->operating_mode_ = OperatingMode::ACTIVE_STREAM; +void UFM01Component::set_mode_(OperatingMode mode, uint32_t now) { + this->operating_mode_ = mode; + this->phase_start_ms_ = now; this->passive_read_pending_ = false; +#ifdef USE_UFM01_SOFTWARE_VERSION + this->software_version_read_pending_ = false; +#endif + this->consecutive_passive_failures_ = 0; } -void UFM01Component::start_passive_read_() { - this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); +void UFM01Component::set_startup_phase_(StartupPhase phase, uint32_t now) { + this->startup_phase_ = phase; + this->phase_start_ms_ = now; +} + +void UFM01Component::send_reset_(const LogString *status, uint32_t now) { + this->status_set_warning(status); + this->reset_retried_ = false; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); +} + +void UFM01Component::enter_active_stream_(const LogString *reason, uint32_t now) { + ESP_LOGI(TAG, "UFM-01 active stream %s", LOG_STR_ARG(reason)); + this->set_mode_(OperatingMode::ACTIVE_STREAM, now); +} + +void UFM01Component::enter_passive_from_stale_(uint32_t now) { + ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); + this->publish_stale_flow_and_temperature_(); + // Flushes leftover stream bytes first; the ACK is not awaited, the next command's flush discards it + this->send_command_no_wait_(PASSIVE_MODE); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; + this->status_set_warning(LOG_STR("UFM-01 passive poll")); +} + +void UFM01Component::restart_startup_(const LogString *reason, uint32_t now) { + ESP_LOGW(TAG, "%s, re-initializing UFM-01", LOG_STR_ARG(reason)); + this->publish_stale_flow_and_temperature_(); + this->set_mode_(OperatingMode::STARTUP, now); + this->send_reset_(LOG_STR("re-initializing UFM-01"), now); +} + +void UFM01Component::note_passive_poll_result_(bool success, uint32_t now) { + if (success) { + this->consecutive_passive_failures_ = 0; + return; + } + this->status_set_warning(LOG_STR("UFM-01 passive poll failed")); + if (++this->consecutive_passive_failures_ < PASSIVE_FAIL_ESCALATE_COUNT) + return; + this->restart_startup_(LOG_STR("Passive poll failed repeatedly"), now); +} + +void UFM01Component::start_passive_read_(uint32_t now) { +#ifdef USE_UFM01_METER_ID + if (this->meter_id_text_sensor_ != nullptr && !this->meter_id_published_) { + this->passive_expected_size_ = PASSIVE_FRAME_WITH_ID_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2_WITH_ID; + this->send_command_no_wait_(READ_SENSOR_DATA_WITH_ID); + } else +#endif + { + this->passive_expected_size_ = PASSIVE_FRAME_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2; + this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); + } this->passive_index_ = 0; - this->passive_start_ms_ = millis(); + this->passive_start_ms_ = now; } -// Accumulates the reply to a passive read request across loop iterations. -PassiveReadResult UFM01Component::continue_passive_read_() { - while (this->available() && this->passive_index_ < PASSIVE_FRAME_SIZE) { +ReadResult UFM01Component::continue_passive_read_(uint32_t now) { + const uint8_t expected_size = this->passive_expected_size_; + + while (this->available() && this->passive_index_ < expected_size) { uint8_t byte; if (!this->read_byte(&byte)) break; if (this->passive_index_ == 0 && byte != FRAME_START_BYTE_1) continue; - if (this->passive_index_ == 1 && byte != PASSIVE_START_BYTE_2) { + if (this->passive_index_ == 1 && byte != this->passive_start_byte_2_) { // The mismatched byte may itself be the start of the real frame this->passive_index_ = (byte == FRAME_START_BYTE_1) ? 1 : 0; continue; @@ -321,57 +593,69 @@ PassiveReadResult UFM01Component::continue_passive_read_() { this->passive_frame_[this->passive_index_++] = byte; } - if (this->passive_index_ < PASSIVE_FRAME_SIZE) { - if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) - return PassiveReadResult::PASSIVE_READ_RESULT_PENDING; - ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; - } - - if (!validate_passive_frame(this->passive_frame_)) { - log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); - ESP_LOGW(TAG, "invalid passive frame"); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; + if (this->passive_index_ < expected_size) { + if (now - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "passive read timeout (%u/%u bytes)", this->passive_index_, expected_size); + return ReadResult::READ_RESULT_FAILURE; } uint8_t active_frame[FRAME_SIZE]; - passive_no_id_to_active_frame(this->passive_frame_, active_frame); - this->on_active_frame_(active_frame); - return PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS; +#ifdef USE_UFM01_METER_ID + if (expected_size == PASSIVE_FRAME_WITH_ID_SIZE) { + if (!validate_passive_with_id_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_WITH_ID_SIZE); + ESP_LOGW(TAG, "invalid passive frame with ID"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_WITH_ID_LAYOUT, active_frame); + // Device ID and the byte after it + std::copy_n(&this->passive_frame_[PASSIVE_WITH_ID_DEVICE_ID_INDEX], DEVICE_ID_LENGTH + 1, + &active_frame[FRAME_DEVICE_ID_INDEX]); + } else +#endif + { + if (!validate_passive_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); + ESP_LOGW(TAG, "invalid passive frame"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_NO_ID_LAYOUT, active_frame); + active_frame[FRAME_DEVICE_ID_INDEX + DEVICE_ID_LENGTH] = 0x01; + } + this->on_active_frame_(active_frame, now); + return ReadResult::READ_RESULT_SUCCESS; } -void UFM01Component::loop_startup_() { - const uint32_t elapsed = millis() - this->phase_start_ms_; +void UFM01Component::loop_startup_(uint32_t now) { + const uint32_t elapsed = now - this->phase_start_ms_; switch (this->startup_phase_) { case StartupPhase::WAIT: // Pick up an already-streaming device without resetting it - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < this->startup_wait_ms_) return; ESP_LOGD(TAG, "Running startup sequence"); - this->status_set_warning("initializing UFM-01"); - this->reset_retried_ = false; - this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->send_reset_(LOG_STR("initializing UFM-01"), now); return; case StartupPhase::RESET_WAIT_ACK: if (this->consume_ack_()) { - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); return; } if (elapsed < COMMAND_ACK_TIMEOUT_MS) return; if (!this->reset_retried_) { ESP_LOGW(TAG, "Reset not acknowledged, retrying in %" PRIu32 " ms", RESET_RETRY_DELAY_MS); - this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT); + this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT, now); } else { ESP_LOGW(TAG, "Reset failed during startup"); - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); } return; @@ -380,91 +664,138 @@ void UFM01Component::loop_startup_() { return; this->reset_retried_ = true; this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); return; case StartupPhase::POST_RESET_WAIT: if (elapsed < POST_RESET_DELAY_MS) return; +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_ && + this->software_version_failures_ < SOFTWARE_VERSION_MAX_ATTEMPTS) { + this->start_software_version_read_(now); + this->set_startup_phase_(StartupPhase::SOFTWARE_VERSION_WAIT_REPLY, now); + return; + } +#endif this->send_command_no_wait_(ACTIVE_MODE); - this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); return; +#ifdef USE_UFM01_SOFTWARE_VERSION + case StartupPhase::SOFTWARE_VERSION_WAIT_REPLY: { + const ReadResult result = this->continue_software_version_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) + return; + if (result == ReadResult::READ_RESULT_SUCCESS) { + ESP_LOGD(TAG, "Software version read during startup"); + } else { + ESP_LOGW(TAG, "Software version read failed during startup, continuing"); + } + this->finish_software_version_read_(result, now); + this->send_command_no_wait_(ACTIVE_MODE); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); + return; + } +#endif + case StartupPhase::ACTIVE_WAIT_FRAME: // The command ACK (0xE5) is consumed by the frame parser as noise - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < ACTIVE_FRAME_TIMEOUT_MS) return; - this->start_passive_read_(); - this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY); + this->send_command_no_wait_(PASSIVE_MODE); + this->set_startup_phase_(StartupPhase::SET_PASSIVE_WAIT_ACK, now); + return; + + case StartupPhase::SET_PASSIVE_WAIT_ACK: + if (this->consume_ack_()) { + ESP_LOGD(TAG, "Passive mode acknowledged during startup"); + } else if (elapsed < COMMAND_ACK_TIMEOUT_MS) { + return; + } else { + ESP_LOGW(TAG, "SET_PASSIVE_MODE not acknowledged during startup, continuing"); + } + this->start_passive_read_(now); + this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY, now); return; case StartupPhase::PASSIVE_WAIT_REPLY: - switch (this->continue_passive_read_()) { - case PassiveReadResult::PASSIVE_READ_RESULT_PENDING: + switch (this->continue_passive_read_(now)) { + case ReadResult::READ_RESULT_PENDING: return; - case PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS: + case ReadResult::READ_RESULT_SUCCESS: ESP_LOGI(TAG, "UFM-01 using passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = millis(); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; return; - case PassiveReadResult::PASSIVE_READ_RESULT_FAILURE: + case ReadResult::READ_RESULT_FAILURE: ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); this->startup_wait_ms_ = STARTUP_RETRY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, now); return; } } } -void UFM01Component::loop_active_stream_() { - this->process_active_stream_(); - if (this->last_valid_frame_ms_ != 0 && millis() - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) { - ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = 0; - this->status_set_warning("UFM-01 passive poll"); - } +void UFM01Component::loop_active_stream_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + + this->process_active_stream_(now); + + if (this->last_valid_frame_ms_ != 0 && now - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) + this->enter_passive_from_stale_(now); } -void UFM01Component::loop_passive_poll_() { +void UFM01Component::loop_passive_poll_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + if (this->passive_read_pending_) { - const PassiveReadResult result = this->continue_passive_read_(); - if (result == PassiveReadResult::PASSIVE_READ_RESULT_PENDING) + const ReadResult result = this->continue_passive_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) return; this->passive_read_pending_ = false; - if (result == PassiveReadResult::PASSIVE_READ_RESULT_FAILURE) - this->status_set_warning("UFM-01 passive poll failed"); + this->note_passive_poll_result_(result == ReadResult::READ_RESULT_SUCCESS, now); return; } - if (this->process_active_stream_()) { - this->enter_active_stream_("resumed"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("resumed"), now); return; } - if (millis() - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { - this->last_poll_ms_ = millis(); - this->start_passive_read_(); + if (now - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { + this->last_poll_ms_ = now; + this->start_passive_read_(now); this->passive_read_pending_ = true; } } void UFM01Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + this->loop_pending_clear_action_(now); + if (this->clear_pending_ && this->pending_clear_sent_) + return; +#endif switch (this->operating_mode_) { case OperatingMode::STARTUP: - this->loop_startup_(); + this->loop_startup_(now); return; case OperatingMode::ACTIVE_STREAM: - this->loop_active_stream_(); + this->loop_active_stream_(now); return; case OperatingMode::PASSIVE_POLL: - this->loop_passive_poll_(); + this->loop_passive_poll_(now); return; } } diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index 0a39dcc9afd..cf73daa5fa7 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -8,6 +8,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include "esphome/components/uart/uart.h" #include @@ -23,6 +26,17 @@ class TestableUFM01; static constexpr size_t FRAME_SIZE = 32; static constexpr size_t PASSIVE_FRAME_SIZE = 23; +static constexpr size_t PASSIVE_FRAME_WITH_ID_SIZE = 39; +#ifdef USE_UFM01_METER_ID +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_WITH_ID_SIZE; +#else +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_SIZE; +#endif +static constexpr size_t SOFTWARE_VERSION_RESPONSE_SIZE = 7; +static constexpr size_t DEVICE_ID_LENGTH = 5; +static constexpr size_t DEVICE_ID_STRING_LENGTH = 10; +static constexpr size_t SOFTWARE_VERSION_LENGTH = 4; +static constexpr size_t SOFTWARE_VERSION_STRING_LENGTH = 8; enum class OperatingMode : uint8_t { STARTUP = 0, @@ -31,18 +45,22 @@ enum class OperatingMode : uint8_t { }; enum class StartupPhase : uint8_t { - WAIT = 0, - RESET_WAIT_ACK = 1, - RESET_RETRY_WAIT = 2, - POST_RESET_WAIT = 3, - ACTIVE_WAIT_FRAME = 4, - PASSIVE_WAIT_REPLY = 5, + WAIT, + RESET_WAIT_ACK, + RESET_RETRY_WAIT, + POST_RESET_WAIT, +#ifdef USE_UFM01_SOFTWARE_VERSION + SOFTWARE_VERSION_WAIT_REPLY, +#endif + ACTIVE_WAIT_FRAME, + SET_PASSIVE_WAIT_ACK, + PASSIVE_WAIT_REPLY, }; -enum class PassiveReadResult : uint8_t { - PASSIVE_READ_RESULT_PENDING = 0, - PASSIVE_READ_RESULT_SUCCESS = 1, - PASSIVE_READ_RESULT_FAILURE = 2, +enum class ReadResult : uint8_t { + READ_RESULT_PENDING = 0, + READ_RESULT_SUCCESS = 1, + READ_RESULT_FAILURE = 2, }; class UFM01Component : public uart::UARTDevice, public Component { @@ -59,6 +77,13 @@ class UFM01Component : public uart::UARTDevice, public Component { SUB_BINARY_SENSOR(flow_rate_out_of_range) #endif +#ifdef USE_UFM01_METER_ID + SUB_TEXT_SENSOR(meter_id) +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + SUB_TEXT_SENSOR(software_version) +#endif + public: void setup() override; @@ -66,43 +91,85 @@ class UFM01Component : public uart::UARTDevice, public Component { void loop() override; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + /// Queue a clear of the accumulated flow; sent once the UART is idle, a request while one is pending joins it + void request_clear_accumulated_flow(); +#endif + float get_setup_priority() const override; - protected: - bool clear_accumulated_flow_(); - bool set_active_mode_(); - bool reset_device_(); - private: - bool send_command_(const std::array &command); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool can_start_clear_action_() const; + void loop_pending_clear_action_(uint32_t now); + void finish_pending_clear_(); +#endif void send_command_no_wait_(const std::array &command); bool consume_ack_(); void flush_rx_(); - bool process_active_stream_(); - void on_active_frame_(uint8_t data[FRAME_SIZE]); + bool process_active_stream_(uint32_t now); + void on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now); +#ifdef USE_UFM01_METER_ID + void publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]); +#endif + void publish_stale_flow_and_temperature_(); +#ifdef USE_UFM01_SOFTWARE_VERSION + void start_software_version_read_(uint32_t now); + ReadResult continue_software_version_read_(uint32_t now); + void finish_software_version_read_(ReadResult result, uint32_t now); + bool service_software_version_(uint32_t now); +#endif - void loop_startup_(); - void loop_active_stream_(); - void loop_passive_poll_(); - void set_startup_phase_(StartupPhase phase); - void enter_active_stream_(const char *reason); - void start_passive_read_(); - PassiveReadResult continue_passive_read_(); + void loop_startup_(uint32_t now); + void loop_active_stream_(uint32_t now); + void loop_passive_poll_(uint32_t now); + void set_mode_(OperatingMode mode, uint32_t now); + void set_startup_phase_(StartupPhase phase, uint32_t now); + void send_reset_(const LogString *status, uint32_t now); + void enter_active_stream_(const LogString *reason, uint32_t now); + void enter_passive_from_stale_(uint32_t now); + void restart_startup_(const LogString *reason, uint32_t now); + void start_passive_read_(uint32_t now); + ReadResult continue_passive_read_(uint32_t now); + void note_passive_poll_result_(bool success, uint32_t now); + + uint32_t phase_start_ms_{0}; + uint32_t startup_wait_ms_{0}; + uint32_t last_valid_frame_ms_{0}; + uint32_t last_poll_ms_{0}; + // Shared by the passive read and the software version read, which never overlap + uint32_t passive_start_ms_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + uint32_t last_software_version_attempt_ms_{0}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + uint32_t pending_clear_start_ms_{0}; +#endif + int32_t read_index_{0}; OperatingMode operating_mode_{OperatingMode::STARTUP}; StartupPhase startup_phase_{StartupPhase::WAIT}; - uint32_t phase_start_ms_{0}; - uint32_t startup_wait_ms_{0}; bool reset_retried_{false}; - uint32_t last_valid_frame_ms_{0}; - uint32_t last_poll_ms_{0}; - bool passive_read_pending_{false}; - uint32_t passive_start_ms_{0}; - size_t passive_index_{0}; - uint8_t passive_frame_[PASSIVE_FRAME_SIZE]; + uint8_t consecutive_passive_failures_{0}; + uint8_t passive_index_{0}; + uint8_t passive_expected_size_{PASSIVE_FRAME_SIZE}; + uint8_t passive_start_byte_2_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + bool software_version_published_{false}; + bool software_version_read_pending_{false}; + uint8_t software_version_failures_{0}; +#endif +#ifdef USE_UFM01_METER_ID + bool meter_id_published_{false}; + bool meter_id_invalid_logged_{false}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool clear_pending_{false}; + bool pending_clear_sent_{false}; +#endif - int32_t read_index_ = 0; + uint8_t passive_frame_[PASSIVE_FRAME_MAX_SIZE]; uint8_t data_[FRAME_SIZE]; friend class testing::TestableUFM01; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 552221dd5eb..730e17fb53a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -248,6 +248,9 @@ #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX +#define USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +#define USE_UFM01_METER_ID +#define USE_UFM01_SOFTWARE_VERSION #define USE_UPDATE #define USE_UPTIME_TIMESTAMP #define USE_VALVE diff --git a/tests/components/ufm01/__init__.py b/tests/components/ufm01/__init__.py new file mode 100644 index 00000000000..19febacc61a --- /dev/null +++ b/tests/components/ufm01/__init__.py @@ -0,0 +1,12 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The optional features are define-gated; emit them so the tests cover those paths + async def to_code_testing(config): + cg.add_define("USE_UFM01_METER_ID") + cg.add_define("USE_UFM01_SOFTWARE_VERSION") + cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") + + manifest.to_code = to_code_testing diff --git a/tests/components/ufm01/benchmark.yaml b/tests/components/ufm01/benchmark.yaml new file mode 100644 index 00000000000..f78cdfe0334 --- /dev/null +++ b/tests/components/ufm01/benchmark.yaml @@ -0,0 +1,29 @@ +# Defines USE_SENSOR; the tests attach their own instances +sensor: + - platform: template + id: bench_flow + name: "Flow" + - platform: template + id: bench_temperature + name: "Temperature" + +# Defines USE_TEXT_SENSOR +text_sensor: + - platform: template + id: bench_meter_id + name: "Meter ID" + - platform: template + id: bench_software_version + name: "Software version" + +# One instance so __init__.py's to_code runs and emits the feature defines +uart: + - id: uart_bus + baud_rate: 2400 + parity: EVEN + stop_bits: 1 + port: /dev/null + +ufm01: + - id: ufm01_bench + uart_id: uart_bus diff --git a/tests/components/ufm01/common.h b/tests/components/ufm01/common.h index 1582358700c..e04e09b765c 100644 --- a/tests/components/ufm01/common.h +++ b/tests/components/ufm01/common.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -9,17 +10,36 @@ #include "esphome/components/uart/uart_component.h" #include "esphome/components/ufm01/ufm01.h" +#include "esphome/core/application.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::ufm01::testing { static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; static constexpr uint8_t COMMAND_ACK = 0xE5; +// Matches PASSIVE_FAIL_ESCALATE_COUNT in ufm01.cpp +static constexpr uint8_t ESCALATE_COUNT = 8; + +using Command = std::array; +static constexpr Command ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr Command PASSIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x01, 0x5D, 0x16}; +static constexpr Command CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; +static constexpr Command RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; +static constexpr Command READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; +static constexpr Command READ_SENSOR_DATA_WITH_ID = {0xFE, 0xFE, 0x11, 0x5B, 0xCB, 0x26, 0x16}; +static constexpr Command GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11, 0x5E, 0x62, 0xC0, 0x16}; // UART mock with a byte queue for read-side simulation. class QueuedMockUART : public uart::UARTComponent { @@ -31,6 +51,10 @@ class QueuedMockUART : public uart::UARTComponent { this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); } + template void enqueue(const std::array &data) { + this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); + } + void enqueue(std::initializer_list data) { for (uint8_t byte : data) this->rx_queue.push_back(byte); @@ -71,15 +95,37 @@ class TestableUFM01 : public UFM01Component { public: void set_mock_uart(QueuedMockUART *uart) { this->set_uart_parent(uart); } - bool process_active_stream() { return this->process_active_stream_(); } + static uint32_t now() { return App.get_loop_component_start_time(); } - PassiveReadResult continue_passive_read() { return this->continue_passive_read_(); } + bool process_active_stream() { return this->process_active_stream_(now()); } + + ReadResult continue_passive_read() { return this->continue_passive_read_(now()); } bool consume_ack() { return this->consume_ack_(); } - void start_passive_read() { this->start_passive_read_(); } + void start_passive_read() { this->start_passive_read_(now()); } - void loop_startup() { this->loop_startup_(); } + void loop_startup() { this->loop_startup_(now()); } + + void loop_active_stream() { this->loop_active_stream_(now()); } + + void loop_passive_poll() { this->loop_passive_poll_(now()); } + + void loop_pending_clear_action() { this->loop_pending_clear_action_(now()); } + + bool clear_pending() const { return this->clear_pending_; } + + bool pending_clear_sent() const { return this->pending_clear_sent_; } + + bool meter_id_published() const { return this->meter_id_published_; } + + void start_software_version_read() { this->start_software_version_read_(now()); } + + ReadResult continue_software_version_read() { return this->continue_software_version_read_(now()); } + + bool software_version_published() const { return this->software_version_published_; } + + void set_last_software_version_attempt_ms(uint32_t ms) { this->last_software_version_attempt_ms_ = ms; } OperatingMode operating_mode() const { return this->operating_mode_; } @@ -87,20 +133,52 @@ class TestableUFM01 : public UFM01Component { int32_t read_index() const { return this->read_index_; } - size_t passive_index() const { return this->passive_index_; } + uint8_t passive_index() const { return this->passive_index_; } uint32_t last_valid_frame_ms() const { return this->last_valid_frame_ms_; } + uint8_t consecutive_passive_failures() const { return this->consecutive_passive_failures_; } + + void set_operating_mode(OperatingMode mode) { this->operating_mode_ = mode; } + + // Expects a reply without the meter ID, as start_passive_read_() picks when no meter ID sensor is set void prepare_passive_read() { this->passive_index_ = 0; - this->passive_start_ms_ = millis(); + this->passive_expected_size_ = PASSIVE_FRAME_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2; + this->passive_start_ms_ = now(); + } + + void begin_pending_passive_read() { + this->prepare_passive_read(); + this->passive_read_pending_ = true; } void init_wait_phase() { this->operating_mode_ = OperatingMode::STARTUP; this->startup_phase_ = StartupPhase::WAIT; this->startup_wait_ms_ = 60000; - this->phase_start_ms_ = millis(); + this->phase_start_ms_ = now(); + } + + void begin_active_stream() { + this->operating_mode_ = OperatingMode::ACTIVE_STREAM; + this->last_valid_frame_ms_ = now(); + this->passive_read_pending_ = false; + this->read_index_ = 0; + } + + void begin_active_wait_frame() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::ACTIVE_WAIT_FRAME; + this->phase_start_ms_ = now(); + } + + void begin_post_reset_wait() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::POST_RESET_WAIT; + this->phase_start_ms_ = now(); + this->software_version_published_ = false; } void reset_state() { @@ -108,24 +186,59 @@ class TestableUFM01 : public UFM01Component { this->last_valid_frame_ms_ = 0; this->passive_index_ = 0; this->passive_read_pending_ = false; + this->operating_mode_ = OperatingMode::STARTUP; + this->consecutive_passive_failures_ = 0; + this->meter_id_published_ = false; + this->software_version_published_ = false; + this->software_version_read_pending_ = false; + this->software_version_failures_ = 0; + this->meter_id_invalid_logged_ = false; + this->last_software_version_attempt_ms_ = 0; + this->startup_phase_ = StartupPhase::WAIT; + this->clear_pending_ = false; + this->pending_clear_sent_ = false; } }; +template inline uint8_t checksum(const std::array &data, size_t begin, size_t end) { + uint8_t sum = 0; + for (size_t i = begin; i < end; ++i) + sum += data[i]; + return sum; +} + inline std::array make_active_frame() { std::array frame{}; frame[0] = FRAME_START_BYTE_1; frame[1] = FRAME_START_BYTE_2; + // Datasheet example device ID 2307140001 + frame[2] = 0x01; + frame[3] = 0x00; + frame[4] = 0x14; + frame[5] = 0x07; + frame[6] = 0x23; + frame[7] = 0x01; frame[15] = FRAME_FLAG_INSTANT_FLOW; frame[21] = FRAME_FLAG_RESERVED_SECTION; frame[24] = FRAME_FLAG_TEMP; frame[31] = FRAME_STOP_BYTE; - uint8_t sum = 0; - for (size_t i = 0; i < 30; ++i) - sum += frame[i]; - frame[30] = sum; + frame[30] = checksum(frame, 0, 30); return frame; } +inline std::array make_software_version_response() { + // Datasheet example version 23380315 + std::array response{}; + response[0] = COMMAND_ACK; + response[1] = 0x15; + response[2] = 0x03; + response[3] = 0x38; + response[4] = 0x23; + response[5] = checksum(response, 1, 5); + response[6] = FRAME_STOP_BYTE; + return response; +} + inline std::array make_passive_frame() { std::array frame{}; frame[0] = FRAME_START_BYTE_1; @@ -133,22 +246,93 @@ inline std::array make_passive_frame() { frame[9] = FRAME_FLAG_INSTANT_FLOW; frame[15] = FRAME_FLAG_TEMP; frame[22] = FRAME_STOP_BYTE; - uint8_t sum = 0; - for (size_t i = 0; i < 21; ++i) - sum += frame[i]; - frame[21] = sum; + frame[21] = checksum(frame, 0, 21); + return frame; +} + +inline std::array make_passive_with_id_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = PASSIVE_START_BYTE_2_WITH_ID; + frame[2] = 0x01; + frame[3] = 0x00; + frame[4] = 0x14; + frame[5] = 0x07; + frame[6] = 0x23; + frame[7] = 0x01; + frame[8] = 0x0A; + // Non-measurement bytes ensure a shifted instant-flow offset is detected. + for (size_t i = 17; i < 22; ++i) + frame[i] = 0x99; + frame[22] = FRAME_FLAG_INSTANT_FLOW; + frame[23] = 0x34; + frame[24] = 0x12; + frame[27] = 0x00; + frame[31] = FRAME_FLAG_TEMP; + frame[32] = 0x50; + frame[33] = 0x23; + frame[38] = FRAME_STOP_BYTE; + frame[37] = checksum(frame, 0, 37); return frame; } class UFM01Test : public ::testing::Test { protected: + // Non-zero so a frame received at the start time still counts as a valid timestamp + static constexpr uint32_t START_MS = 1000; + void SetUp() override { this->mock_uart_.clear_rx(); this->mock_uart_.written_data.clear(); this->ufm01_.set_mock_uart(&this->mock_uart_); this->ufm01_.reset_state(); + this->set_clock(START_MS); } + // Publishes the time the component reads as the loop clock + void set_clock(uint32_t ms) { + this->now_ = ms; + LoopBlockingGuard clock{nullptr, nullptr, ms}; + } + + void advance(uint32_t ms) { this->set_clock(this->now_ + ms); } + + void expect_written(const Command &command) { + EXPECT_EQ(this->mock_uart_.written_data, std::vector(command.begin(), command.end())); + } + + void fail_passive_poll() { + this->ufm01_.begin_pending_passive_read(); + this->advance(2000); + this->ufm01_.loop_passive_poll(); + } + + void make_active_stream_stale() { + this->ufm01_.begin_active_stream(); + this->advance(6000); + } + +#ifdef USE_SENSOR + void attach_flow_and_temperature_sensors() { + this->ufm01_.set_flow_sensor(&this->flow_sensor_); + this->ufm01_.set_temperature_sensor(&this->temperature_sensor_); + this->flow_sensor_.publish_state(1.23f); + this->temperature_sensor_.publish_state(20.5f); + } + + sensor::Sensor flow_sensor_; + sensor::Sensor temperature_sensor_; +#endif + + void attach_diagnostic_text_sensors() { + this->ufm01_.set_meter_id_text_sensor(&this->meter_id_text_sensor_); + this->ufm01_.set_software_version_text_sensor(&this->software_version_text_sensor_); + } + + text_sensor::TextSensor meter_id_text_sensor_; + text_sensor::TextSensor software_version_text_sensor_; + + uint32_t now_{0}; QueuedMockUART mock_uart_; TestableUFM01 ufm01_; }; diff --git a/tests/components/ufm01/common.yaml b/tests/components/ufm01/common.yaml index c818dc29651..29bc05dfabd 100644 --- a/tests/components/ufm01/common.yaml +++ b/tests/components/ufm01/common.yaml @@ -28,3 +28,17 @@ binary_sensor: flow_rate_out_of_range: id: flow_rate_out_of_range name: "Flow rate out of range" + +text_sensor: + - platform: ufm01 + meter_id: + id: ufm01_meter_id + name: "Meter ID" + software_version: + id: ufm01_software_version + name: "Software version" + +script: + - id: ufm01_clear_accumulated_flow + then: + - ufm01.clear_accumulated_flow: ufm01_component diff --git a/tests/components/ufm01/test.esp32-idf.yaml b/tests/components/ufm01/test.esp32-idf.yaml index 34041cc2237..a2d4469798c 100644 --- a/tests/components/ufm01/test.esp32-idf.yaml +++ b/tests/components/ufm01/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/test.esp8266-ard.yaml b/tests/components/ufm01/test.esp8266-ard.yaml index 195f4b41b59..7826cdc44a0 100644 --- a/tests/components/ufm01/test.esp8266-ard.yaml +++ b/tests/components/ufm01/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/test.rp2040-ard.yaml b/tests/components/ufm01/test.rp2040-ard.yaml index 13b3284fe30..1483674ddcf 100644 --- a/tests/components/ufm01/test.rp2040-ard.yaml +++ b/tests/components/ufm01/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/ufm01_clear_action_test.cpp b/tests/components/ufm01/ufm01_clear_action_test.cpp new file mode 100644 index 00000000000..a1d10b1926d --- /dev/null +++ b/tests/components/ufm01/ufm01_clear_action_test.cpp @@ -0,0 +1,113 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ClearAccumulatedFlowSendsCommand) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); + EXPECT_TRUE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, ClearAccumulatedFlowFinishesOnAck) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->mock_uart_.enqueue({COMMAND_ACK}); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, ClearAccumulatedFlowFinishesOnTimeout) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->advance(400); + this->ufm01_.loop_pending_clear_action(); + ASSERT_TRUE(this->ufm01_.clear_pending()); + + this->advance(200); + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, DuplicateClearRequestJoinsThePendingOne) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->mock_uart_.written_data.clear(); + + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue({COMMAND_ACK}); + this->ufm01_.loop_pending_clear_action(); + EXPECT_FALSE(this->ufm01_.clear_pending()); + + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsForPendingPassiveRead) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + this->ufm01_.begin_pending_passive_read(); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue(make_passive_frame()); + this->ufm01_.loop_passive_poll(); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsForPendingSoftwareVersionRead) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.start_software_version_read(); + this->mock_uart_.written_data.clear(); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue(make_software_version_response()); + this->ufm01_.loop_active_stream(); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsDuringStartup) { + this->ufm01_.set_operating_mode(OperatingMode::STARTUP); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_TRUE(this->ufm01_.clear_pending()); + EXPECT_FALSE(this->ufm01_.pending_clear_sent()); +} + +TEST_F(UFM01Test, ClearTimesOutWhileWaitingForIdle) { + this->ufm01_.set_operating_mode(OperatingMode::STARTUP); + this->ufm01_.request_clear_accumulated_flow(); + this->advance(20000); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_diagnostics_test.cpp b/tests/components/ufm01/ufm01_diagnostics_test.cpp new file mode 100644 index 00000000000..5898b565633 --- /dev/null +++ b/tests/components/ufm01/ufm01_diagnostics_test.cpp @@ -0,0 +1,173 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ActiveFramePublishesMeterIdOnce) { + this->attach_diagnostic_text_sensors(); + auto frame = make_active_frame(); + this->mock_uart_.enqueue(frame); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_TRUE(this->ufm01_.meter_id_published()); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); + + this->mock_uart_.enqueue(frame); + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); +} + +#ifdef USE_SENSOR +TEST_F(UFM01Test, PassiveFrameWithIdUsesDocumentedMeasurementOffsets) { + this->attach_diagnostic_text_sensors(); + this->attach_flow_and_temperature_sensors(); + this->ufm01_.start_passive_read(); + this->expect_written(READ_SENSOR_DATA_WITH_ID); + + this->mock_uart_.enqueue(make_passive_with_id_frame()); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); + EXPECT_NEAR(this->flow_sensor_.get_state(), 0.01234f, 0.000001f); + EXPECT_NEAR(this->temperature_sensor_.get_state(), 23.5f, 0.001f); +} +#endif + +TEST_F(UFM01Test, InvalidPassiveFrameWithIdIsRejected) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_passive_read(); + auto frame = make_passive_with_id_frame(); + frame[37] ^= 0xFF; + this->mock_uart_.enqueue(frame); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.meter_id_published()); +} + +TEST_F(UFM01Test, SoftwareVersionResponseDecoded) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + this->mock_uart_.enqueue(make_software_version_response()); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_TRUE(this->ufm01_.software_version_published()); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, SoftwareVersionReadSkipsLeftoverStreamBytes) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + // Tail of an active frame that was still on the wire when the command was sent + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin() + 20, frame.end())); + this->mock_uart_.enqueue(make_software_version_response()); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, InvalidSoftwareVersionBcdFails) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + auto response = make_software_version_response(); + response[2] = 0xFA; + response[5] = checksum(response, 1, 5); + this->mock_uart_.enqueue(response); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, SoftwareVersionReadTimeoutFails) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + this->advance(1900); + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_PENDING); + + this->advance(100); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, StartupReadsSoftwareVersionBeforeActiveMode) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.begin_post_reset_wait(); + this->advance(3000); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::SOFTWARE_VERSION_WAIT_REPLY); + this->expect_written(GET_SOFTWARE_VERSION); + + this->mock_uart_.enqueue(make_software_version_response()); + this->mock_uart_.written_data.clear(); + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::ACTIVE_WAIT_FRAME); + this->expect_written(ACTIVE_MODE); + EXPECT_TRUE(this->ufm01_.software_version_published()); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, AlreadyActiveStartupRequestsSoftwareVersion) { + this->attach_diagnostic_text_sensors(); + this->mock_uart_.enqueue(make_active_frame()); + this->ufm01_.init_wait_phase(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::ACTIVE_STREAM); + EXPECT_TRUE(this->ufm01_.meter_id_published()); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->ufm01_.loop_active_stream(); + + this->expect_written(GET_SOFTWARE_VERSION); +} + +TEST_F(UFM01Test, ActiveStreamRetriesSoftwareVersionRead) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.set_last_software_version_attempt_ms(this->now_); + this->advance(29000); + this->ufm01_.loop_active_stream(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->advance(2000); + this->ufm01_.loop_active_stream(); + + this->expect_written(GET_SOFTWARE_VERSION); +} + +TEST_F(UFM01Test, ActiveStreamGivesUpSoftwareVersionAfterRepeatedFailures) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + for (int attempt = 0; attempt < 3; ++attempt) { + this->ufm01_.loop_active_stream(); + this->expect_written(GET_SOFTWARE_VERSION); + this->mock_uart_.written_data.clear(); + this->advance(2000); + this->ufm01_.loop_active_stream(); + this->advance(30000); + } + + this->ufm01_.loop_active_stream(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, InvalidMeterIdLeavesSensorUnknown) { + this->attach_diagnostic_text_sensors(); + auto frame = make_active_frame(); + frame[2] = 0xAB; + frame[30] = checksum(frame, 0, 30); + this->mock_uart_.enqueue(frame); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + + EXPECT_FALSE(this->ufm01_.meter_id_published()); + EXPECT_FALSE(this->meter_id_text_sensor_.has_state()); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_frame_test.cpp b/tests/components/ufm01/ufm01_frame_test.cpp index 3b1b148d504..8e659ce40df 100644 --- a/tests/components/ufm01/ufm01_frame_test.cpp +++ b/tests/components/ufm01/ufm01_frame_test.cpp @@ -4,7 +4,7 @@ namespace esphome::ufm01::testing { TEST_F(UFM01Test, ValidActiveFrameAccepted) { auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_TRUE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -14,7 +14,7 @@ TEST_F(UFM01Test, ValidActiveFrameAccepted) { TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { this->mock_uart_.enqueue({0x00, 0xFF, 0xAA}); auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_TRUE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -23,7 +23,7 @@ TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { auto frame = make_active_frame(); frame[30] ^= 0xFF; - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_FALSE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -32,10 +32,10 @@ TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { auto frame = make_passive_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); } @@ -43,29 +43,29 @@ TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { TEST_F(UFM01Test, InvalidPassiveChecksumFails) { auto frame = make_passive_frame(); frame[21] ^= 0xFF; - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_FAILURE); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_FAILURE); EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); } TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) { auto frame = make_passive_frame(); this->mock_uart_.enqueue({0x00, 0x01, 0x02}); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { auto frame = make_passive_frame(); this->mock_uart_.enqueue({FRAME_START_BYTE_1, 0x99}); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { @@ -73,11 +73,11 @@ TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.begin() + 10)); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_PENDING); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_PENDING); EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); this->mock_uart_.enqueue(std::vector(frame.begin() + 10, frame.end())); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } } // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_startup_test.cpp b/tests/components/ufm01/ufm01_startup_test.cpp index 6b7feb0f1b0..c6d5a7f4ebf 100644 --- a/tests/components/ufm01/ufm01_startup_test.cpp +++ b/tests/components/ufm01/ufm01_startup_test.cpp @@ -1,14 +1,7 @@ #include "common.h" -#include "esphome/core/component.h" - namespace esphome::ufm01::testing { -TEST(UFM01SetupPriority, IsLate) { - TestableUFM01 ufm01; - EXPECT_EQ(ufm01.get_setup_priority(), setup_priority::LATE); -} - TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { this->mock_uart_.enqueue({0x00, 0x01, COMMAND_ACK, 0x02}); @@ -19,8 +12,7 @@ TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { TEST_F(UFM01Test, ConsumeAckReturnsFalseWhenEmpty) { EXPECT_FALSE(this->ufm01_.consume_ack()); } TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { - auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(make_active_frame()); this->ufm01_.init_wait_phase(); this->ufm01_.loop_startup(); @@ -32,12 +24,117 @@ TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { TEST_F(UFM01Test, StartPassiveReadSendsCommand) { this->ufm01_.start_passive_read(); - ASSERT_EQ(this->mock_uart_.written_data.size(), 7u); - EXPECT_EQ(this->mock_uart_.written_data[0], 0xFE); - EXPECT_EQ(this->mock_uart_.written_data[1], 0xFE); - EXPECT_EQ(this->mock_uart_.written_data[2], 0x11); - EXPECT_EQ(this->mock_uart_.written_data[3], 0x5B); - EXPECT_EQ(this->mock_uart_.written_data[6], FRAME_STOP_BYTE); + this->expect_written(READ_SENSOR_DATA_NO_ID); +} + +TEST_F(UFM01Test, StartupActiveWaitTimeoutSendsSetPassiveMode) { + this->ufm01_.begin_active_wait_frame(); + this->advance(4000); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::SET_PASSIVE_WAIT_ACK); + this->expect_written(PASSIVE_MODE); +} + +TEST_F(UFM01Test, StartupSetPassiveWaitAckStartsPassiveRead) { + this->ufm01_.begin_active_wait_frame(); + this->advance(4000); + this->ufm01_.loop_startup(); + this->mock_uart_.enqueue({COMMAND_ACK}); + this->mock_uart_.written_data.clear(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::PASSIVE_WAIT_REPLY); + this->expect_written(READ_SENSOR_DATA_NO_ID); +} + +TEST_F(UFM01Test, StaleActiveStreamSendsSetPassiveMode) { + // Leftover stream noise should be flushed before SET_PASSIVE_MODE + this->mock_uart_.enqueue({0x3C, 0x32, 0x00}); + this->make_active_stream_stale(); + + this->ufm01_.loop_active_stream(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + EXPECT_EQ(this->ufm01_.read_index(), 0); + this->expect_written(PASSIVE_MODE); + EXPECT_EQ(this->mock_uart_.available(), 0u); // RX flushed +} + +TEST_F(UFM01Test, StalePassivePollWaitsOneIntervalAndDiscardsLateAck) { + this->make_active_stream_stale(); + this->ufm01_.loop_active_stream(); + ASSERT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + this->mock_uart_.written_data.clear(); + + this->mock_uart_.enqueue({COMMAND_ACK}); + this->advance(500); + this->ufm01_.loop_passive_poll(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + + this->advance(500); + this->ufm01_.loop_passive_poll(); + this->expect_written(READ_SENSOR_DATA_NO_ID); + EXPECT_EQ(this->mock_uart_.available(), 0u); +} + +#ifdef USE_SENSOR +TEST_F(UFM01Test, StaleActiveStreamPublishesNanToFlowAndTemperature) { + this->attach_flow_and_temperature_sensors(); + this->make_active_stream_stale(); + + this->ufm01_.loop_active_stream(); + + EXPECT_TRUE(std::isnan(this->flow_sensor_.get_state())); + EXPECT_TRUE(std::isnan(this->temperature_sensor_.get_state())); +} + +TEST_F(UFM01Test, RepeatedPassiveFailuresPublishNanBeforeReset) { + this->attach_flow_and_temperature_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (uint8_t i = 0; i < ESCALATE_COUNT; ++i) + this->fail_passive_poll(); + + EXPECT_TRUE(std::isnan(this->flow_sensor_.get_state())); + EXPECT_TRUE(std::isnan(this->temperature_sensor_.get_state())); +} +#endif + +TEST_F(UFM01Test, PassivePollSuccessClearsFailureCount) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (int i = 0; i < 3; ++i) + this->fail_passive_poll(); + ASSERT_EQ(this->ufm01_.consecutive_passive_failures(), 3u); + + this->mock_uart_.enqueue(make_passive_frame()); + this->ufm01_.begin_pending_passive_read(); + this->ufm01_.loop_passive_poll(); + + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), 0u); + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); +} + +TEST_F(UFM01Test, RepeatedPassiveFailuresEscalateToReset) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (uint8_t i = 0; i < ESCALATE_COUNT - 1; ++i) { + this->fail_passive_poll(); + ASSERT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL) << "i=" << static_cast(i); + } + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), ESCALATE_COUNT - 1); + + this->mock_uart_.written_data.clear(); + this->fail_passive_poll(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::STARTUP); + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::RESET_WAIT_ACK); + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), 0u); + this->expect_written(RESET_DEVICE); } } // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/validate.host.yaml b/tests/components/ufm01/validate.host.yaml new file mode 100644 index 00000000000..19c0739f0bd --- /dev/null +++ b/tests/components/ufm01/validate.host.yaml @@ -0,0 +1,16 @@ +esphome: + name: ufm01-validate + +host: + +logger: + +packages: + ufm01: !include common.yaml + +uart: + id: uart_bus + baud_rate: 2400 + parity: EVEN + stop_bits: 1 + port: /dev/null diff --git a/tests/integration/fixtures/uart_mock_ufm01.yaml b/tests/integration/fixtures/uart_mock_ufm01.yaml new file mode 100644 index 00000000000..0f72225c089 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ufm01.yaml @@ -0,0 +1,153 @@ +esphome: + name: uart-mock-ufm01 + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates + actions: + - action: clear_accumulated_flow + then: + - ufm01.clear_accumulated_flow: ufm01_dev + - lambda: |- + id(clear_count) += 1; + id(clear_completions).publish_state(id(clear_count)); +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ufm01's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 2400 + port: /dev/null + +globals: + # The simulated meter streams active frames only after SET_ACTIVE_MODE + - id: streaming + type: bool + initial_value: "false" + # Set by CLEAR_ACCUMULATED_FLOW; later frames report zero accumulated flow + - id: cleared + type: bool + initial_value: "false" + - id: clear_count + type: int + initial_value: "0" + +# Simulated UFM-01: every command is one 7 byte write +uart_mock: + id: mock_uart + baud_rate: 2400 + parity: EVEN + on_tx: + - then: + - lambda: |- + if (id(silent).state || data.size() != 7) { + return; + } + static const std::vector ACK = {0xE5}; + switch (data[3]) { + case 0x5D: // RESET_DEVICE + id(streaming) = false; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5C: // SET_ACTIVE_MODE (00) or SET_PASSIVE_MODE (01) + id(streaming) = data[4] == 0x00; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5A: // CLEAR_ACCUMULATED_FLOW + id(cleared) = true; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5B: // READ_SENSOR_DATA without meter ID + if (data[4] != 0x0F) { + break; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0x3C, 0x64, // start + 0x0A, 0x78, 0x56, 0x02, 0x00, 0x00, 0x00, // accumulated flow 25.678 L + 0x0B, 0x00, 0x67, 0x05, 0x00, 0x00, // flow 0.567 m3/h + 0x0D, 0x25, 0x19, 0x00, // temperature 19.25 C + 0x00, 0x00, // ST1, ST2 + 0x3C, 0x16, // checksum, stop + }); + break; + case 0x5E: // GET_SOFTWARE_VERSION: ACK, version 23380315 (BCD, low byte first), checksum, stop + id(mock_uart).inject_to_rx_buffer(std::vector{0xE5, 0x15, 0x03, 0x38, 0x23, 0x73, 0x16}); + break; + } + +interval: + - interval: 1s + then: + - if: + condition: + lambda: "return id(streaming) && !id(silent).state;" + then: + - lambda: |- + // Meter ID 2307140001 (BCD, low byte first), flow 1.2345 m3/h, temperature 23.5 C + std::vector frame{ + 0x3C, 0x32, // start + 0x01, 0x00, 0x14, 0x07, 0x23, 0x01, // meter ID, reserved + 0x0A, 0x45, 0x23, 0x01, 0x00, 0x00, 0x00, // accumulated flow 12.345 L + 0x0B, 0x50, 0x34, 0x12, 0x00, 0x00, // flow + 0x0C, 0x00, 0x00, // reserved section + 0x0D, 0x50, 0x23, 0x00, // temperature + 0x00, 0x00, // ST1, ST2 + 0x4E, 0x16, // checksum, stop + }; + if (id(cleared)) { + // Accumulated flow 0 m3: unit flag 0x1A, zero digits, checksum follows + frame[8] = 0x1A; + std::fill(frame.begin() + 9, frame.begin() + 15, 0x00); + frame[30] = 0xF5; + } + id(mock_uart).inject_to_rx_buffer(frame); + +ufm01: + id: ufm01_dev + uart_id: mock_uart + +sensor: + - platform: ufm01 + accumulated_flow: + name: "Accumulated Flow" + flow: + name: "Flow" + temperature: + name: "Temperature" + - platform: template + id: clear_completions + name: "Clear Completions" + update_interval: never + +text_sensor: + - platform: ufm01 + meter_id: + name: "Meter ID" + software_version: + name: "Software Version" + +switch: + # The meter stops streaming and answers nothing + - platform: template + id: silent + name: "Silent" + optimistic: true + turn_on_action: + - lambda: "id(streaming) = false;" + +button: + # The stream stops while the meter still answers commands + - platform: template + name: "Pause Stream" + on_press: + - lambda: "id(streaming) = false;" + - platform: template + name: "Resume Stream" + on_press: + - lambda: "id(streaming) = true;" diff --git a/tests/integration/test_uart_mock_ufm01.py b/tests/integration/test_uart_mock_ufm01.py new file mode 100644 index 00000000000..c2c2f08f523 --- /dev/null +++ b/tests/integration/test_uart_mock_ufm01.py @@ -0,0 +1,399 @@ +"""Integration tests for the UFM-01 flow meter with a simulated meter on the mock UART. + +All tests share uart_mock_ufm01.yaml, whose on_tx lambda answers like a UFM-01: it ACKs commands, +answers sensor data and software version reads, and streams active frames each second after +SET_ACTIVE_MODE. The "Silent" switch stops it answering, "Pause Stream" stops the stream while it still +answers, and "Resume Stream" starts the stream again. + +test_uart_mock_ufm01_startup: + Reset, software version read and active mode go out in order, the active frames publish flow, + temperature and accumulated flow, and the meter ID and software version publish once. +test_uart_mock_ufm01_stale_recovery: + A stopped stream marks flow and temperature unknown after 5 s and switches to passive polling, + whose answers publish; a resumed stream switches back to the active stream and polling stops. +test_uart_mock_ufm01_escalation: + Eight unanswered passive polls send a reset and run the startup sequence again; values publish + once the meter answers, and the diagnostics are not published again. +test_uart_mock_ufm01_clear_action: + The clear command is sent once the UART is idle while the automation continues at once; the + meter's ACK, or its absence, is only logged, and a second request joins the pending one. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import math +import re + +from aioesphomeapi import ( + APIClient, + ButtonInfo, + EntityInfo, + EntityState, + SensorState, + SwitchInfo, + TextSensorState, + UserService, +) +import pytest + +from .state_utils import InitialStateHelper, StateWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Commands as the uart mock logs them: FE FE 11, command, argument, checksum, 16 +RESET_DEVICE = "FE:FE:11:5D:CB:28:16" +SET_ACTIVE_MODE = "FE:FE:11:5C:00:5C:16" +SET_PASSIVE_MODE = "FE:FE:11:5C:01:5D:16" +READ_SENSOR_DATA = "FE:FE:11:5B:0F:6A:16" +GET_SOFTWARE_VERSION = "FE:FE:11:5E:62:C0:16" +CLEAR_ACCUMULATED_FLOW = "FE:FE:11:5A:FD:57:16" + +# Values the fixture's frames encode +ACTIVE_FLOW = 1.2345 +ACTIVE_TEMPERATURE = 23.5 +ACTIVE_ACCUMULATED = 12.345 +PASSIVE_FLOW = 0.567 +PASSIVE_TEMPERATURE = 19.25 +PASSIVE_ACCUMULATED = 25.678 +METER_ID = "2307140001" +SOFTWARE_VERSION = "23380315" + +ACTIVE_STARTED = "UFM-01 active stream started" +ACTIVE_RESUMED = "UFM-01 active stream resumed" +STALE = "Active stream stale, switching to passive polling" +ESCALATED = "Passive poll failed repeatedly, re-initializing UFM-01" +CLEAR_ACKED = "Clear accumulated flow acknowledged" +CLEAR_NOT_ACKED = "Clear accumulated flow not acknowledged" + +# Startup wait, reset ACK, post-reset wait, then the first frame within the 1 s stream interval +STARTUP_TIMEOUT = 8.0 +ACTIVE_STALE_S = 5.0 + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + +class _Log: + """Record TX frames and ufm01 log lines with their arrival time, and wait for lines.""" + + def __init__(self) -> None: + self.events: list[tuple[float, str]] = [] + self._awaited: list[tuple[str, asyncio.Future[float]]] = [] + + def on_line(self, line: str) -> None: + line = ANSI_ESCAPE.sub("", line) + if "uart_mock" in line and "TX " in line and "bytes: " in line: + text = line.split("bytes: ", 1)[1].strip() + elif "[ufm01" in line: + text = line + else: + return + now = asyncio.get_running_loop().time() + self.events.append((now, text)) + for wanted, future in self._awaited: + if wanted in text and not future.done(): + future.set_result(now) + + def next(self, text: str) -> asyncio.Future[float]: + """Return a future for the time of the next event containing ``text``.""" + future: asyncio.Future[float] = asyncio.get_running_loop().create_future() + self._awaited.append((text, future)) + return future + + @property + def tx(self) -> list[str]: + return [text for _, text in self.events if text.startswith("FE:")] + + def index(self, text: str, start: int = 0) -> int: + return next( + i + for i, (_, event) in enumerate(self.events) + if i >= start and text in event + ) + + def tx_between(self, start: int, end: int) -> list[str]: + return [text for _, text in self.events[start:end] if text.startswith("FE:")] + + +class _Meter: + """Entities and state history of the running fixture.""" + + def __init__( + self, client: APIClient, entities: list[EntityInfo], services: list[UserService] + ) -> None: + self.client = client + self.services = services + self.by_id = {entity.object_id: entity for entity in entities} + self.waiter = StateWaiter() + self.states: dict[str, list[float | str]] = {name: [] for name in self.by_id} + self._names = {entity.key: entity.object_id for entity in entities} + self.helper = InitialStateHelper(entities) + + def on_state(self, state: EntityState) -> None: + if ( + isinstance(state, (SensorState, TextSensorState)) + and not state.missing_state + ): + self.states[self._names[state.key]].append(state.state) + self.waiter.on_state(state) + + def expect( + self, name: str, check: Callable[[float | str], bool], timeout: float + ) -> Awaitable[EntityState]: + key = self.by_id[name].key + return self.waiter.expect( + lambda s: s.key == key and not s.missing_state and check(s.state), + timeout=timeout, + label=name, + ) + + def press(self, name: str) -> None: + entity = self.by_id[name] + assert isinstance(entity, ButtonInfo) + self.client.button_command(entity.key) + + def set_silent(self, silent: bool) -> None: + entity = self.by_id["silent"] + assert isinstance(entity, SwitchInfo) + self.client.switch_command(entity.key, silent) + + def all_values(self, name: str) -> list[float | str]: + """Return every value ``name`` held, including one already set at connect time.""" + initial = self.helper.initial_states[self.by_id[name].key] + return ([] if initial.missing_state else [initial.state]) + self.states[name] + + +async def _connect(client: APIClient) -> _Meter: + entities, services = await client.list_entities_services() + meter = _Meter(client, entities, services) + client.subscribe_states(meter.helper.on_state_wrapper(meter.on_state)) + try: + await meter.helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + return meter + + +def _is_nan(value: float | str) -> bool: + return isinstance(value, float) and math.isnan(value) + + +def _approx(expected: float) -> Callable[[float | str], bool]: + return lambda value: value == pytest.approx(expected, abs=1e-4) + + +def _active_values(meter: _Meter, timeout: float) -> list[Awaitable[EntityState]]: + """Arm waits for the next flow, temperature and accumulated flow from an active frame.""" + return [ + meter.expect("flow", _approx(ACTIVE_FLOW), timeout), + meter.expect("temperature", _approx(ACTIVE_TEMPERATURE), timeout), + meter.expect("accumulated_flow", _approx(ACTIVE_ACCUMULATED), timeout), + ] + + +async def _gather(meter: _Meter, waits: list[Awaitable[EntityState]]) -> None: + try: + await asyncio.gather(*waits) + except TimeoutError as err: + pytest.fail(f"{err}; states: {meter.states}") + + +async def _wait_for(log: _Log, future: asyncio.Future[float], timeout: float) -> float: + try: + return await asyncio.wait_for(future, timeout) + except TimeoutError: + pytest.fail(f"Timed out; log: {log.events}") + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_startup( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Startup reaches the active stream, publishes the decoded values and the diagnostics once.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + values = _active_values(meter, STARTUP_TIMEOUT) + await _wait_for(log, started, STARTUP_TIMEOUT) + await _gather(meter, values) + # Two more stream frames + for _ in range(2): + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + assert log.tx == [RESET_DEVICE, GET_SOFTWARE_VERSION, SET_ACTIVE_MODE] + assert meter.all_values("meter_id") == [METER_ID] + assert meter.all_values("software_version") == [SOFTWARE_VERSION] + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_stale_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stopped stream falls back to passive polling and a resumed stream ends it.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + await _wait_for(log, started, STARTUP_TIMEOUT) + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + stale = log.next(STALE) + flow_unknown = meter.expect("flow", _is_nan, ACTIVE_STALE_S + 2) + temperature_unknown = meter.expect("temperature", _is_nan, ACTIVE_STALE_S + 2) + polled = [ + meter.expect("flow", _approx(PASSIVE_FLOW), ACTIVE_STALE_S + 4), + meter.expect( + "temperature", _approx(PASSIVE_TEMPERATURE), ACTIVE_STALE_S + 4 + ), + meter.expect( + "accumulated_flow", _approx(PASSIVE_ACCUMULATED), ACTIVE_STALE_S + 4 + ), + ] + meter.press("pause_stream") + paused_at = asyncio.get_running_loop().time() + stale_at = await _wait_for(log, stale, ACTIVE_STALE_S + 2) + # The last frame came up to 1 s before the pause + assert stale_at - paused_at >= ACTIVE_STALE_S - 1.5 + await _gather(meter, [flow_unknown, temperature_unknown]) + await _gather(meter, polled) + + stale_index = log.index(STALE) + assert log.tx_between(stale_index, len(log.events))[:2] == [ + SET_PASSIVE_MODE, + READ_SENSOR_DATA, + ] + # Unknown first, then the passive answers + assert _is_nan(meter.states["flow"][-2]) + assert _is_nan(meter.states["temperature"][-2]) + + resumed = log.next(ACTIVE_RESUMED) + meter.press("resume_stream") + values = _active_values(meter, 3.0) + await _wait_for(log, resumed, 3.0) + await _gather(meter, values) + # Two more stream frames without any poll in between + for _ in range(2): + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + assert READ_SENSOR_DATA not in log.tx_between( + log.index(ACTIVE_RESUMED), len(log.events) + ) + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_escalation( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Eight unanswered passive polls reset the meter and rerun the startup sequence.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + await _wait_for(log, started, STARTUP_TIMEOUT) + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + escalated = log.next(ESCALATED) + meter.set_silent(True) + # Stale after 5 s, then eight polls that each time out after 1 s + flow_count = len(meter.states["flow"]) + await _wait_for(log, escalated, ACTIVE_STALE_S + 8 + 4) + + restarted = log.next(ACTIVE_STARTED) + values = _active_values(meter, STARTUP_TIMEOUT) + meter.set_silent(False) + await _wait_for(log, restarted, STARTUP_TIMEOUT) + await _gather(meter, values) + + stale_index = log.index(STALE) + escalated_index = log.index(ESCALATED, stale_index) + assert ( + log.tx_between(stale_index, escalated_index) + == [SET_PASSIVE_MODE] + [READ_SENSOR_DATA] * 8 + ) + # A reset (resent once if the meter was still silent), then active mode; the software + # version is already known, so it is not read again + after = log.tx_between( + escalated_index, log.index(ACTIVE_STARTED, escalated_index) + ) + assert after in ( + [RESET_DEVICE, SET_ACTIVE_MODE], + [RESET_DEVICE, RESET_DEVICE, SET_ACTIVE_MODE], + ), after + # Unknown when the stream went stale and again on the re-init, nothing in between; + # a frame already in flight when the meter went silent may come first + flow = meter.states["flow"][flow_count:] + first_unknown = next(i for i, value in enumerate(flow) if _is_nan(value)) + assert _is_nan(flow[first_unknown + 1]), flow + assert flow[first_unknown + 2] == pytest.approx(ACTIVE_FLOW, abs=1e-4), flow + + assert meter.all_values("meter_id") == [METER_ID] + assert meter.all_values("software_version") == [SOFTWARE_VERSION] + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_clear_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The clear command is sent once the UART is idle; the automation continues at once.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + acked = log.next(CLEAR_ACKED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + # A second entity listing would re-send the initial states and duplicate the first completion + (clear,) = meter.services + # Normally still inside the startup wait; a slow runner may already be streaming + queued_during_startup = not started.done() + + completed = meter.expect("clear_completions", _approx(1), STARTUP_TIMEOUT) + zeroed = meter.expect("accumulated_flow", _approx(0.0), STARTUP_TIMEOUT + 2) + await client.execute_service(clear, {}) + await _wait_for(log, acked, STARTUP_TIMEOUT) + await _gather(meter, [completed, zeroed]) + + # Queued during startup, sent only once the active stream started + if queued_during_startup: + assert CLEAR_ACCUMULATED_FLOW not in log.tx_between( + 0, log.index(ACTIVE_STARTED) + ) + assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 1 + assert log.index(CLEAR_ACCUMULATED_FLOW) < log.index(CLEAR_ACKED) + + # Two requests in a row share one command; without an answer it is reported after the 500 ms ACK timeout + sent = log.next(CLEAR_ACCUMULATED_FLOW) + not_acked = log.next(CLEAR_NOT_ACKED) + completed = meter.expect("clear_completions", _approx(3), 3.0) + meter.set_silent(True) + await asyncio.sleep(0.2) + await client.execute_service(clear, {}) + await client.execute_service(clear, {}) + sent_at = await _wait_for(log, sent, 2.0) + not_acked_at = await _wait_for(log, not_acked, 2.0) + await _gather(meter, [completed]) + assert not_acked_at - sent_at >= 0.4 + assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 2 + assert meter.states["clear_completions"] == [1.0, 2.0, 3.0] From 16a474cce5de7e3ed506fea4ffcc618cdf88cb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:40:27 +0200 Subject: [PATCH 058/651] [core] Only treat action names as actions in has_non_synchronous_actions() (#20524) Co-authored-by: Claude Opus 5.5 --- esphome/automation.py | 24 +++++++--- tests/unit_tests/test_automation.py | 70 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 0105d1e1072..fea5769050e 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1033,18 +1033,30 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: Non-synchronous actions (delay, wait_until, script.wait, etc.) store trigger args for later execution, making non-owning types like StringRef unsafe. + + Accepts an action list, a single action, or an automation. Only the keys of + action entries are looked up as action names; the options of an action are + only searched for nested action lists (``then:``, ``else:``, ...), so an + option that shares a name with an action, such as the ``delay:`` option of + ``speaker_source.set_playlist_delay``, is not mistaken for that action. """ if isinstance(actions, list): return any(has_non_synchronous_actions(item) for item in actions) if isinstance(actions, dict): - for key in actions: + for key, options in actions.items(): if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous: return True - return any( - has_non_synchronous_actions(v) - for v in actions.values() - if isinstance(v, (list, dict)) - ) + if _options_have_non_synchronous_actions(options): + return True + return False + + +def _options_have_non_synchronous_actions(options: Any) -> bool: + """Search an action's options for nested action lists, without treating option keys as actions.""" + if isinstance(options, list): + return has_non_synchronous_actions(options) + if isinstance(options, dict): + return any(_options_have_non_synchronous_actions(v) for v in options.values()) return False diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 351afe541d6..f2d46280448 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -216,6 +216,76 @@ def test_has_non_synchronous_actions_dict_input( assert has_non_synchronous_actions({"logger.log": "hello"}) is False +def test_has_non_synchronous_actions_option_named_like_action( + mock_registry: dict[str, RegistryEntry], +) -> None: + """An option that shares a name with a deferring action is not that action.""" + actions = [{"logger.log": {"message": "hello", "delay": "1s"}}] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_nested_option_named_like_action( + mock_registry: dict[str, RegistryEntry], +) -> None: + """Option keys in nested option dicts are not looked up as action names either.""" + actions = [{"logger.log": {"settings": {"wait_until": {"condition": {}}}}}] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_option_named_like_action_inside_then( + mock_registry: dict[str, RegistryEntry], +) -> None: + actions = [ + { + "if": { + "condition": {}, + "then": [{"logger.log": {"message": "hello", "delay": "1s"}}], + } + } + ] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_in_else( + mock_registry: dict[str, RegistryEntry], +) -> None: + """Action lists in any option of an action are searched, not only then:.""" + actions = [ + { + "if": { + "condition": {}, + "then": [{"logger.log": "hello"}], + "else": [{"delay": "1s"}], + } + } + ] + assert has_non_synchronous_actions(actions) is True + + +def test_has_non_synchronous_actions_automation_dict( + mock_registry: dict[str, RegistryEntry], +) -> None: + """A single automation, as validate_automation(single=True) returns it.""" + assert has_non_synchronous_actions({CONF_THEN: [{"delay": "1s"}]}) is True + assert has_non_synchronous_actions({CONF_THEN: [{"logger.log": "hi"}]}) is False + + +def test_has_non_synchronous_actions_set_playlist_delay() -> None: + """speaker_source.set_playlist_delay is synchronous despite its delay: option.""" + import esphome.components.speaker_source.media_player # noqa: F401 # registers the action + + actions = [ + { + "speaker_source.set_playlist_delay": { + CONF_ID: "player", + "pipeline": 0, + "delay": 1000, + } + } + ] + assert has_non_synchronous_actions(actions) is False + + def test_validate_no_deferring_actions_accepts_synchronous( mock_registry: dict[str, RegistryEntry], ) -> None: From 5c5737303f4f5344c9fbbb1bd7285bebc0a4611a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:41:02 +0200 Subject: [PATCH 059/651] [teleinfo] Store the frame as bytes and read with read_byte() (#20531) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 25 ++++++++++++------------ esphome/components/teleinfo/teleinfo.h | 4 ++-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 91a0d0e16f5..b4e474dea4b 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -6,30 +6,30 @@ namespace esphome::teleinfo { ESPHOME_LOG_TAG(TAG, "teleinfo"); /* Helpers */ -static int get_field(char *dest, char *buf_start, char *buf_end, int sep, int max_len) { - char *field_end; +static int get_field(char *dest, uint8_t *buf_start, uint8_t *buf_end, int sep, int max_len) { + uint8_t *field_end; int len; - field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); + field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); if (!field_end) return 0; len = field_end - buf_start; if (len >= max_len) return len; - strncpy(dest, buf_start, len); + memcpy(dest, buf_start, len); dest[len] = '\0'; return len; } /* TeleInfo methods */ -bool TeleInfo::check_crc_(const char *grp, const char *grp_end) { +bool TeleInfo::check_crc_(const uint8_t *grp, const uint8_t *grp_end) { int grp_len = grp_end - grp; if (grp_len < this->checksum_area_end_) { ESP_LOGE(TAG, "Empty or too short group"); return false; } uint8_t raw_crc = grp[grp_len - 1]; - uint8_t crc_tmp = sum_bytes(reinterpret_cast(grp), grp_len - this->checksum_area_end_); + uint8_t crc_tmp = sum_bytes(grp, grp_len - this->checksum_area_end_); crc_tmp &= 0x3F; crc_tmp += 0x20; @@ -46,7 +46,8 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { while (available() > 0 && j < 128) { j++; - received = read(); + if (!read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -87,9 +88,9 @@ void TeleInfo::loop() { state_ = END_FRAME_RECEIVED; break; case END_FRAME_RECEIVED: - char *buf_finger; - char *grp_end; - char *buf_end; + uint8_t *buf_finger; + uint8_t *grp_end; + uint8_t *buf_end; int field_len; buf_finger = buf_; @@ -116,7 +117,7 @@ void TeleInfo::loop() { * 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd * */ - while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { + while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { /* * Make sure timesamp is nullified between each tag as some tags don't * have a timestamp @@ -126,7 +127,7 @@ void TeleInfo::loop() { buf_finger += 1; /* Group len */ - grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); + grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index b1bf586e9c2..39f02874113 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -33,7 +33,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { protected: int checksum_area_end_; int separator_; - char buf_[MAX_BUF_SIZE]; + uint8_t buf_[MAX_BUF_SIZE]; uint32_t buf_index_{0}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; @@ -45,7 +45,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { END_FRAME_RECEIVED, } state_{STATE_OFF}; bool read_chars_until_(bool drop, uint8_t c); - bool check_crc_(const char *grp, const char *grp_end); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const std::string &tag, const std::string &val); }; } // namespace esphome::teleinfo From b10eef537ea70f75f8b64136756cd0dd21739ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:41:28 +0200 Subject: [PATCH 060/651] [teleinfo] Log the tag name for an invalid timestamp (#20532) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index b4e474dea4b..1d3813d59e7 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -154,7 +154,7 @@ void TeleInfo::loop() { if (std::count(buf_finger, grp_end, separator_) == 2 && strcmp(tag_, "DATE") != 0 && separator_ != 0x20) { field_len = get_field(timestamp_, buf_finger, grp_end, separator_, MAX_TIMESTAMP_SIZE); if (!field_len || field_len >= MAX_TIMESTAMP_SIZE) { - ESP_LOGE(TAG, "Invalid timestamp for tag %s", timestamp_); + ESP_LOGE(TAG, "Invalid timestamp for tag %s", tag_); continue; } From da43b6292dbc7a51a1ac8b5056e190371e424e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:41:58 +0200 Subject: [PATCH 061/651] [mk2pvrouter] Store the frame as bytes and read with read_byte() (#20530) Co-authored-by: Claude Opus 5.5 --- .../components/mk2pvrouter/mk2pvrouter.cpp | 24 +++++++++---------- esphome/components/mk2pvrouter/mk2pvrouter.h | 8 +++---- .../mk2pvrouter/mk2pvrouter_test.cpp | 15 ++++++++---- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index b759758019a..98dd0b74ece 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -19,8 +19,8 @@ constexpr uint8_t CRC_OFFSET = 0x20; // Extracts a TAB-delimited field from [buf_start, buf_end) into dest. // Returns the field length, or 0 if no TAB was found, or the (uncopied) field // length if it's >= max_len. -static size_t get_field(char *dest, const char *buf_start, const char *buf_end, size_t max_len) { - const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); +static size_t get_field(char *dest, const uint8_t *buf_start, const uint8_t *buf_end, size_t max_len) { + const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); if (!field_end) return 0; const size_t len = field_end - buf_start; @@ -34,13 +34,13 @@ static size_t get_field(char *dest, const char *buf_start, const char *buf_end, return len; } -// Calculates the CRC (checksum) for a given group of characters. -uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { - return (sum_bytes(reinterpret_cast(grp), grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; +// Calculates the CRC (checksum) for a given group of bytes. +uint8_t Mk2PVRouter::calculate_crc_(const uint8_t *grp, size_t grp_len) { + return (sum_bytes(grp, grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; } // Verifies the CRC of a group against its trailing CRC byte. -bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { +bool Mk2PVRouter::check_crc_(const uint8_t *grp, const uint8_t *grp_end) { const auto grp_len = grp_end - grp; if (grp_len < static_cast(CRC_SUFFIX_LEN)) { ESP_LOGE(TAG, "Empty or too short group"); @@ -58,7 +58,7 @@ bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { } // Validates, parses, and publishes a single tag/value group. -void Mk2PVRouter::process_group_(const char *grp, const char *grp_end) { +void Mk2PVRouter::process_group_(const uint8_t *grp, const uint8_t *grp_end) { if (!this->check_crc_(grp, grp_end)) return; @@ -83,9 +83,9 @@ bool Mk2PVRouter::read_chars_until_(bool drop, uint8_t c) { size_t j{0}; while (this->available() > 0 && j++ < MAX_ITERATIONS) { - const auto received = this->read(); - if (received < 0) - continue; + uint8_t received; + if (!this->read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -127,10 +127,10 @@ void Mk2PVRouter::loop() { // Each group: 0xa(LF) | Tag | 0x9(TAB) | Data | 0x9(TAB) | CRC | 0xd(CR) // CRC is computed over "Tag | TAB | Data | TAB". - while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { + while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { ++buf_finger; // Skip LF to the start of the group. - auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); + auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h index 1a6af4b1b39..061a9a22e94 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.h +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -57,12 +57,12 @@ class Mk2PVRouter : public Component, public uart::UARTDevice { State state_{State::WAITING_FOR_START}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; - char buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding + uint8_t buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding bool read_chars_until_(bool drop, uint8_t c); - uint8_t calculate_crc_(const char *grp, size_t grp_len); - bool check_crc_(const char *grp, const char *grp_end); - void process_group_(const char *grp, const char *grp_end); + uint8_t calculate_crc_(const uint8_t *grp, size_t grp_len); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); + void process_group_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const char *tag, const char *val); }; } // namespace esphome::mk2pvrouter diff --git a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp index 84106eb0b77..a2fb33a2a33 100644 --- a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp +++ b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp @@ -8,6 +8,9 @@ namespace esphome::mk2pvrouter::testing { namespace { +// The parser works on raw frame bytes; the groups below are written as text for readability. +const uint8_t *as_bytes(const char *str) { return reinterpret_cast(str); } + class TestListener final : public Mk2PVRouterListener { public: explicit TestListener(const char *tag) : Mk2PVRouterListener(tag) {} @@ -25,7 +28,9 @@ class Mk2PVRouterTest : public ::testing::Test { void SetUp() override { this->sut_.register_mk2pvrouter_listener(&this->listener_); } // Feeds one "tagvaluecrc" literal, as the frame parser does after END_FRAME. - template void process_(const char (&group)[N]) { this->sut_.process_group_(group, group + N - 1); } + template void process_(const char (&group)[N]) { + this->sut_.process_group_(as_bytes(group), as_bytes(group) + N - 1); + } TestableMk2PVRouter sut_; TestListener listener_{"P1"}; @@ -40,22 +45,22 @@ TEST(Mk2PVRouterListenerTest, GetTagReturnsConstructorTag) { TEST_F(Mk2PVRouterTest, CalculateCrcMatchesKnownGroup) { // "P1\t1234\t" sums to 0x3D ('=') per the mk2pvrouter CRC algorithm. const char grp[] = "P1\t1234\t="; - EXPECT_EQ(this->sut_.calculate_crc_(grp, sizeof(grp) - 1), '='); + EXPECT_EQ(this->sut_.calculate_crc_(as_bytes(grp), sizeof(grp) - 1), '='); } TEST_F(Mk2PVRouterTest, CheckCrcAcceptsMatchingCrc) { const char grp[] = "P1\t1234\t="; - EXPECT_TRUE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); + EXPECT_TRUE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp) + sizeof(grp) - 1)); } TEST_F(Mk2PVRouterTest, CheckCrcRejectsMismatchedCrc) { const char grp[] = "P1\t1234\t!"; - EXPECT_FALSE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); + EXPECT_FALSE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp) + sizeof(grp) - 1)); } TEST_F(Mk2PVRouterTest, CheckCrcRejectsEmptyGroup) { const char grp[] = ""; - EXPECT_FALSE(this->sut_.check_crc_(grp, grp)); + EXPECT_FALSE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp))); } TEST_F(Mk2PVRouterTest, ProcessGroupPublishesValidGroupToMatchingListener) { From b73f151619b2a62ce73130470987c42eccb071e4 Mon Sep 17 00:00:00 2001 From: Tobias Stanzel Date: Sun, 11 Oct 2026 21:49:06 +0200 Subject: [PATCH 062/651] [pmsx003] add support for luftmy ld15 (#15296) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pmsx003/pmsx003.cpp | 78 ++++++++++--------- esphome/components/pmsx003/pmsx003.h | 1 + esphome/components/pmsx003/sensor.py | 11 +++ tests/components/pmsx003/common-luftmy.yaml | 22 ++++++ tests/components/pmsx003/test.esp32-idf.yaml | 3 +- .../components/pmsx003/test.esp8266-ard.yaml | 3 +- tests/components/pmsx003/test.rp2040-ard.yaml | 3 +- .../pmsx003/validate-luftmy.esp32-idf.yaml | 7 ++ 8 files changed, 85 insertions(+), 43 deletions(-) create mode 100644 tests/components/pmsx003/common-luftmy.yaml create mode 100644 tests/components/pmsx003/validate-luftmy.esp32-idf.yaml diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index 14b1f60df54..f99e5adaa2c 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -189,6 +189,7 @@ bool PMSX003Component::check_payload_length_(uint16_t payload_length) { case Type::PMS5003ST: // Data 16 not set/reserved return payload_length == 36; // 2*17+2 case Type::PMS9003M: + case Type::LUFTMY: return payload_length == 28; // 2*13+2 } return false; @@ -219,40 +220,48 @@ void PMSX003Component::send_command_(Command cmd, uint16_t data) { } void PMSX003Component::parse_data_() { - // Particle Matter + // PM mass concentrations const uint16_t pm_1_0_std_concentration = this->get_16_bit_uint_(4); const uint16_t pm_2_5_std_concentration = this->get_16_bit_uint_(6); const uint16_t pm_10_0_std_concentration = this->get_16_bit_uint_(8); - const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); - const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); - const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); - - const uint16_t pm_particles_03um = this->get_16_bit_uint_(16); - const uint16_t pm_particles_05um = this->get_16_bit_uint_(18); - const uint16_t pm_particles_10um = this->get_16_bit_uint_(20); - const uint16_t pm_particles_25um = this->get_16_bit_uint_(22); - - ESP_LOGD(TAG, - "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " - "Concentration: %u µg/m³, PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 " - "Concentration: %u µg/m³", - pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration, pm_1_0_concentration, - pm_2_5_concentration, pm_10_0_concentration); - if (this->pm_1_0_std_sensor_ != nullptr) this->pm_1_0_std_sensor_->publish_state(pm_1_0_std_concentration); if (this->pm_2_5_std_sensor_ != nullptr) this->pm_2_5_std_sensor_->publish_state(pm_2_5_std_concentration); if (this->pm_10_0_std_sensor_ != nullptr) this->pm_10_0_std_sensor_->publish_state(pm_10_0_std_concentration); + ESP_LOGD(TAG, + "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " + "Concentration: %u µg/m³", + pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration); - if (this->pm_1_0_sensor_ != nullptr) - this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); - if (this->pm_2_5_sensor_ != nullptr) - this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); - if (this->pm_10_0_sensor_ != nullptr) - this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + // The Luftmy LD10, LD11, LD13, LD15 and LD16 report no atmospheric PM, so their particle counts start at byte 10. + // All PMS types have atmospheric PM at bytes 10-15, so their particle counts start at byte 16. + const bool is_luftmy = this->type_ == Type::LUFTMY; + const uint8_t pc_offset = is_luftmy ? 10 : 16; + + if (!is_luftmy) { + const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); + const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); + const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); + + ESP_LOGD(TAG, "Got PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 Concentration: %u µg/m³", + pm_1_0_concentration, pm_2_5_concentration, pm_10_0_concentration); + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + } + + // Particle counts at pc_offset: >0.3, >0.5, >1.0, >2.5µm + const uint16_t pm_particles_03um = this->get_16_bit_uint_(pc_offset); + const uint16_t pm_particles_05um = this->get_16_bit_uint_(pc_offset + 2); + const uint16_t pm_particles_10um = this->get_16_bit_uint_(pc_offset + 4); + const uint16_t pm_particles_25um = this->get_16_bit_uint_(pc_offset + 6); if (this->pm_particles_03um_sensor_ != nullptr) this->pm_particles_03um_sensor_->publish_state(pm_particles_03um); @@ -262,22 +271,17 @@ void PMSX003Component::parse_data_() { this->pm_particles_10um_sensor_->publish_state(pm_particles_10um); if (this->pm_particles_25um_sensor_ != nullptr) this->pm_particles_25um_sensor_->publish_state(pm_particles_25um); + ESP_LOGD(TAG, + "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " + "PM2.5 Particles: %u Count/0.1L", + pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - if (this->type_ == Type::PMS5003T) { - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - } else { - // Note the pm particles 50um & 100um are not returned, - // as PMS5003T uses those data values for temperature and humidity. - const uint16_t pm_particles_50um = this->get_16_bit_uint_(24); - const uint16_t pm_particles_100um = this->get_16_bit_uint_(26); + // PMS5003T uses pc_offset+8 and pc_offset+10 for temperature/humidity, not particle counts + if (this->type_ != Type::PMS5003T) { + const uint16_t pm_particles_50um = this->get_16_bit_uint_(pc_offset + 8); + const uint16_t pm_particles_100um = this->get_16_bit_uint_(pc_offset + 10); - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L, PM5.0 Particles: %u Count/0.1L, PM10.0 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um, pm_particles_50um, + ESP_LOGD(TAG, "Got PM5.0 Particles: %u Count/0.1L, PM10.0 Particles: %u Count/0.1L", pm_particles_50um, pm_particles_100um); if (this->pm_particles_50um_sensor_ != nullptr) diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index c62960e7c31..c123a42b9e6 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -15,6 +15,7 @@ enum class Type : uint8_t { PMS5003T, PMS5003ST, PMS9003M, + LUFTMY, // Luftmy LD10, LD11, LD13, LD15, LD16: standard PM + particle counts, no atmospheric PM values }; enum class Command : uint8_t { diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 5eac5415ed3..723b99a5040 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -51,6 +51,7 @@ TYPE_PMS5003S = "PMS5003S" TYPE_PMS5003T = "PMS5003T" TYPE_PMS5003ST = "PMS5003ST" TYPE_PMS9003M = "PMS9003M" +TYPE_LUFTMY = "LUFTMY" Type = pmsx003_ns.enum("Type", is_class=True) @@ -62,6 +63,7 @@ PMSX003_TYPES = { TYPE_PMS5003T: Type.PMS5003T, TYPE_PMS5003ST: Type.PMS5003ST, TYPE_PMS9003M: Type.PMS9003M, + TYPE_LUFTMY: Type.LUFTMY, } SENSORS_TO_TYPE = { @@ -73,6 +75,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5_STD: [ TYPE_PMS1003, @@ -82,6 +85,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0_STD: [ TYPE_PMS1003, @@ -91,6 +95,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0: [ TYPE_PMS1003, @@ -126,6 +131,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_0_5UM: [ TYPE_PMS1003, @@ -134,6 +140,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0UM: [ TYPE_PMS1003, @@ -142,6 +149,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5UM: [ TYPE_PMS1003, @@ -150,6 +158,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_5_0UM: [ TYPE_PMS1003, @@ -157,6 +166,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0UM: [ TYPE_PMS1003, @@ -164,6 +174,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST], CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST], diff --git a/tests/components/pmsx003/common-luftmy.yaml b/tests/components/pmsx003/common-luftmy.yaml new file mode 100644 index 00000000000..538b2481a49 --- /dev/null +++ b/tests/components/pmsx003/common-luftmy.yaml @@ -0,0 +1,22 @@ +sensor: + - platform: pmsx003 + type: LUFTMY + pm_1_0_std: + name: PM 1.0 Standard Atmospheric Concentration + pm_2_5_std: + name: PM 2.5 Standard Atmospheric Concentration + pm_10_0_std: + name: PM 10.0 Standard Atmospheric Concentration + pm_0_3um: + name: Particulate Count >0.3um + pm_0_5um: + name: Particulate Count >0.5um + pm_1_0um: + name: Particulate Count >1.0um + pm_2_5um: + name: Particulate Count >2.5um + pm_5_0um: + name: Particulate Count >5.0um + pm_10_0um: + name: Particulate Count >10.0um + update_interval: 30s diff --git a/tests/components/pmsx003/test.esp32-idf.yaml b/tests/components/pmsx003/test.esp32-idf.yaml index b415125e84b..9e5b478a31e 100644 --- a/tests/components/pmsx003/test.esp32-idf.yaml +++ b/tests/components/pmsx003/test.esp32-idf.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/esp32-idf.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/test.esp8266-ard.yaml b/tests/components/pmsx003/test.esp8266-ard.yaml index 96ab4ef6aca..8e7bd3ebe86 100644 --- a/tests/components/pmsx003/test.esp8266-ard.yaml +++ b/tests/components/pmsx003/test.esp8266-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/test.rp2040-ard.yaml b/tests/components/pmsx003/test.rp2040-ard.yaml index b28f2b5e05e..58779cc0956 100644 --- a/tests/components/pmsx003/test.rp2040-ard.yaml +++ b/tests/components/pmsx003/test.rp2040-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml b/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml new file mode 100644 index 00000000000..c0c648e0389 --- /dev/null +++ b/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + pmsx003: !include common-luftmy.yaml From f562d7a6a41ed78b166eccac29c6f2b6aaad4605 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Sun, 11 Oct 2026 15:59:44 -0400 Subject: [PATCH 063/651] [socket] Add join_multicast_group() and set_ipv6_multicast_if() helpers (#17045) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/socket/socket.cpp | 207 ++++++++++++++++++ esphome/components/socket/socket.h | 22 ++ .../socket/test-ipv6.bk72xx-ard.yaml | 5 + .../components/socket/test_multicast_host.cpp | 96 ++++++++ 4 files changed, 330 insertions(+) create mode 100644 tests/components/socket/test-ipv6.bk72xx-ard.yaml create mode 100644 tests/components/socket/test_multicast_host.cpp diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f6d1daa1240..89002cdd19e 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -11,6 +11,16 @@ #ifdef USE_HOST #include "esphome/core/wake.h" #endif +#if defined(USE_HOST) +#include +#include +#elif defined(USE_ZEPHYR) +#ifndef CLANG_TIDY // NCS 2.9.2 net_if.h has a BUILD_ASSERT clang rejects; gone in NCS 3.4 +#include +#endif +#else +#include "lwip/netif.h" +#endif namespace esphome::socket { @@ -139,6 +149,203 @@ std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { } #endif +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +// Callback must be a temporary lambda +template static bool foreach_eligible_ipv6_if(F &&callback) { +#if defined(USE_HOST) + struct ifaddrs *ifaddr; + if (getifaddrs(&ifaddr) != 0) { + return false; + } + bool found_any = false; + for (struct ifaddrs *ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == nullptr || ifa->ifa_addr->sa_family != AF_INET6) { + continue; + } + if ((ifa->ifa_flags & IFF_LOOPBACK) || !(ifa->ifa_flags & IFF_UP) || !(ifa->ifa_flags & IFF_MULTICAST)) { + continue; + } + unsigned int idx = if_nametoindex(ifa->ifa_name); + if (idx == 0) { + continue; + } + found_any = true; + if (callback(idx)) { + break; + } + } + freeifaddrs(ifaddr); + if (!found_any) { + errno = EADDRNOTAVAIL; + } + return found_any; +#elif defined(USE_ZEPHYR) && defined(CLANG_TIDY) + (void) callback; + return false; +#elif defined(USE_ZEPHYR) + // Zephyr has no getifaddrs()/ifaddrs.h; enumerate interfaces via the native net_if API. + // NET_IF_IPV6_NO_MLD is the direct analog of the LwIP branch's NETIF_FLAG_MLD6 check below. + struct EligibleIfContext { + F *callback; + bool found_any = false; + bool done = false; + }; + EligibleIfContext ctx{&callback}; + net_if_foreach( + [](struct net_if *iface, void *user_data) { + auto *ctx = static_cast(user_data); + if (ctx->done) { + return; + } + if (!net_if_flag_is_set(iface, NET_IF_UP) || !net_if_flag_is_set(iface, NET_IF_IPV6) || + net_if_flag_is_set(iface, NET_IF_IPV6_NO_MLD)) { + return; + } + int idx = net_if_get_by_iface(iface); + if (idx <= 0) { + return; + } + ctx->found_any = true; + if ((*ctx->callback)(static_cast(idx))) { + ctx->done = true; + } + }, + &ctx); + if (!ctx.found_any) { + errno = EADDRNOTAVAIL; + } + return ctx.found_any; +#else + bool found_any = false; + struct netif *netif; + NETIF_FOREACH(netif) { + if (netif->name[0] == 'l' && netif->name[1] == 'o') { + continue; + } + if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { + continue; + } + found_any = true; + if (callback(netif_get_index(netif))) { + break; + } + } + if (!found_any) { + errno = EADDRNOTAVAIL; + } + return found_any; +#endif +} + +bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out) { + if (strchr(ip_address, ':') == nullptr) { + struct in_addr ipv4mc {}; +#ifdef USE_ZEPHYR + if (zsock_inet_pton(AF_INET, ip_address, &ipv4mc) != 1) { +#else + if (inet_aton(ip_address, &ipv4mc) == 0) { +#endif + errno = EINVAL; + return false; + } +#ifdef USE_ZEPHYR + // Zephyr's setsockopt(IP_ADD_MEMBERSHIP) requires exactly sizeof(struct ip_mreqn); it has + // no plain "struct ip_mreq" at all (unlike POSIX/lwIP). + struct ip_mreqn imreq {}; + imreq.imr_address.s_addr = ESPHOME_INADDR_ANY; + imreq.imr_ifindex = 0; +#else + struct ip_mreq imreq {}; + imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY; +#endif + imreq.imr_multiaddr = ipv4mc; + if (sock->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)) < 0) { + return false; + } + if (if_index_out != nullptr) { + *if_index_out = 0; + } + return true; + } +#if USE_NETWORK_IPV6 + struct ipv6_mreq imreq6 {}; +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#ifdef USE_ZEPHYR + if (zsock_inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#else + if (inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#endif + errno = EINVAL; + return false; + } +#else + ip6_addr_t ip6; + if (!inet6_aton(ip_address, &ip6)) { + errno = EINVAL; + return false; + } + memcpy(imreq6.ipv6mr_multiaddr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); +#endif + // POSIX: interface=0 fails for link-local multicast (ff02::) and may select loopback for + // other scopes. LwIP: NETIF_NO_INDEX=0 is always rejected. + bool joined = false; + foreach_eligible_ipv6_if([&](unsigned int idx) { +#ifdef USE_ZEPHYR + // This Zephyr fork's struct ipv6_mreq has no "ipv6mr_interface" (POSIX name) and no + // IPV6_JOIN_GROUP alias -- only the RFC 3678 legacy ipv6mr_ifindex/IPV6_ADD_MEMBERSHIP names. + imreq6.ipv6mr_ifindex = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_ADD_MEMBERSHIP, &imreq6, sizeof(imreq6)) == 0) { +#else + imreq6.ipv6mr_interface = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_JOIN_GROUP, &imreq6, sizeof(imreq6)) == 0) { +#endif + joined = true; + return true; + } + return false; + }); + if (!joined) { + return false; + } + if (if_index_out != nullptr) { +#ifdef USE_ZEPHYR + *if_index_out = imreq6.ipv6mr_ifindex; +#else + *if_index_out = imreq6.ipv6mr_interface; +#endif + } + return true; +#else + errno = EINVAL; + return false; +#endif // USE_NETWORK_IPV6 +} + +#if USE_NETWORK_IPV6 +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { +#if defined(USE_ZEPHYR) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) + // Upstream lwIP (Zephyr's fork, LibreTiny) has no IPV6_MULTICAST_IF; outgoing + // multicast follows the default netif. Zephyr gains ZSOCK_IPV6_MULTICAST_IF in NCS 3.4.0. + (void) sock; + (void) if_index_in; + return true; +#else + uint32_t ifindex = if_index_in; + if (ifindex == 0) { + foreach_eligible_ipv6_if([&](unsigned int idx) { + ifindex = idx; + return true; + }); + if (ifindex == 0) { + return false; + } + } + return sock->setsockopt(IPPROTO_IPV6, IPV6_MULTICAST_IF, &ifindex, sizeof(ifindex)) == 0; +#endif +} +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port) { #if USE_NETWORK_IPV6 if (strchr(ip_address, ':') != nullptr) { diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 9d7dedbbbf9..d1a70c5aa84 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -94,6 +94,7 @@ inline bool Socket::ready() const { /// Create a socket of the given domain, type and protocol. std::unique_ptr socket(int domain, int type, int protocol); /// Create a socket in the newest available IP domain (IPv6 or IPv4) of the given type and protocol. +/// Not registered with the main loop's select() - use socket_ip_loop_monitored() if you need that. std::unique_ptr socket_ip(int type, int protocol); /// Create a socket and monitor it for data in the main loop. @@ -149,6 +150,27 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +/// Join a multicast group on the given socket. +/// @param sock The socket to join on +/// @param ip_address Null-terminated multicast address string (IPv4 or IPv6) +/// @param if_index_out If non-null, receives the interface index used (IPv4 always writes 0). For +/// link-local IPv6 groups, the caller must also copy this into sin6_scope_id +/// before bind() -- POSIX requires it, LwIP ignores it. +/// @return true on success, false on failure (errno set) +bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out = nullptr); + +#if USE_NETWORK_IPV6 +/// Configure the outgoing IPv6 multicast interface on the given socket. +/// @param sock The socket to configure +/// @param if_index_in Non-zero: use this interface index directly. Zero (default): probe for the first eligible +/// interface via foreach_eligible_ipv6_if(). On single-interface devices (typical for ESP32/ESP8266) this is always +/// correct. On multi-homed hosts the "first eligible" interface is arbitrary; pass the desired index explicitly. +/// @return true on success, false on failure (errno set). A no-op returning true on LWIP_SOCKETS and Zephyr. +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in = 0); +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + /// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or /// errno) on fd implementations and the failure the callbacks recorded on raw lwip. #ifdef USE_SOCKET_IMPL_LWIP_TCP diff --git a/tests/components/socket/test-ipv6.bk72xx-ard.yaml b/tests/components/socket/test-ipv6.bk72xx-ard.yaml new file mode 100644 index 00000000000..6075623b54a --- /dev/null +++ b/tests/components/socket/test-ipv6.bk72xx-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + network_enable_ipv6: "true" + +packages: + socket: !include common.yaml diff --git a/tests/components/socket/test_multicast_host.cpp b/tests/components/socket/test_multicast_host.cpp new file mode 100644 index 00000000000..31fe286a865 --- /dev/null +++ b/tests/components/socket/test_multicast_host.cpp @@ -0,0 +1,96 @@ +#include + +#include + +#include "esphome/components/socket/socket.h" + +#ifdef USE_HOST + +namespace esphome::socket::testing { + +// ========================================================================= +// join_multicast_group — EINVAL on malformed input +// ========================================================================= + +TEST(JoinMulticastGroup, MalformedIPv4YieldsEINVAL) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_FALSE(join_multicast_group(sock.get(), "not-an-ip")); + EXPECT_EQ(errno, EINVAL); +} + +TEST(JoinMulticastGroup, MalformedIPv6YieldsEINVAL) { + // ':' present but parse fails — EINVAL regardless of USE_NETWORK_IPV6 + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_FALSE(join_multicast_group(sock.get(), "gg::not-ipv6")); + EXPECT_EQ(errno, EINVAL); +} + +// ========================================================================= +// join_multicast_group — IPv4 IP_ADD_MEMBERSHIP +// ========================================================================= + +TEST(JoinMulticastGroup, IPv4JoinSetsIfIndexToZero) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + uint32_t if_index = 99; + // A host with no multicast route (e.g. an isolated container) cannot join + if (join_multicast_group(sock.get(), "239.0.60.53", &if_index)) { + EXPECT_EQ(if_index, 0u); + } else { + EXPECT_EQ(if_index, 99u); + } +} + +TEST(JoinMulticastGroup, IPv4JoinNullIfIndexOut) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_TRUE(join_multicast_group(sock.get(), "239.0.60.53", nullptr) || errno != 0); +} + +// ========================================================================= +// join_multicast_group — IPv6 and set_ipv6_multicast_if +// ========================================================================= + +#if USE_NETWORK_IPV6 + +TEST(JoinMulticastGroup, IPv6JoinSucceedsOrNoInterface) { + auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + uint32_t if_index = 0; + bool ok = join_multicast_group(sock.get(), "ff12::cafe", &if_index); + if (ok) { + EXPECT_GT(if_index, 0u); + } else { + EXPECT_EQ(errno, EADDRNOTAVAIL); + } +} + +TEST(SetIPv6MulticastIf, ProbeWithZeroIndexSucceedsOrNoInterface) { + auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_TRUE(set_ipv6_multicast_if(sock.get(), 0) || errno == EADDRNOTAVAIL); +} + +TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { + auto sock_join = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + auto sock_send = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock_join, nullptr); + ASSERT_NE(sock_send, nullptr); + uint32_t if_index = 0; + bool joined = join_multicast_group(sock_join.get(), "ff12::cafe", &if_index); + if (joined && if_index != 0) { + EXPECT_TRUE(set_ipv6_multicast_if(sock_send.get(), if_index)); + } +} + +#endif // USE_NETWORK_IPV6 + +} // namespace esphome::socket::testing + +#endif // USE_HOST From 153c51e39f118be0183c3703448d9c8a95fe987c Mon Sep 17 00:00:00 2001 From: sh1der Date: Sun, 11 Oct 2026 22:24:50 +0200 Subject: [PATCH 064/651] [gree] Keep display light on by default for YAN/YAA/YAC/YAC1FB9 (#18516) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/gree/gree.cpp | 2 -- esphome/components/gree/gree.h | 8 ++++++- esphome/components/gree/switch/__init__.py | 25 ++++++++++++++++------ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index f47be4aea03..0e02f20080c 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -37,7 +37,6 @@ void GreeClimate::transmit_state() { remote_state[1] = this->temperature_(); if (model == GREE_YAN) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[4] = this->vertical_swing_(); } @@ -61,7 +60,6 @@ void GreeClimate::transmit_state() { } if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index c2616295735..a85878e764d 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00; static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; +// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9. +static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10; +static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20; +static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40; +static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80; + // Model codes enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; @@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR { uint8_t preset_(); Model model_{}; - uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2] + uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise }; } // namespace esphome::gree diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 9bec3751d6b..b48d32b9001 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -17,12 +17,22 @@ CONF_HEALTH = "health" CONF_XFAN = "xfan" CONF_GREE_ID = "gree_id" -# Switch configurations: (config_key, display_name, bit_mask, icon) +# Switch configurations: (config_key, display_name, mode_bit, icon) SWITCH_CONFIGS = ( - (CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"), - (CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"), - (CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"), - (CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"), + ( + CONF_TURBO, + "Gree Turbo Switch", + gree_ns.GREE_MODE_BIT_TURBO, + "mdi:car-turbocharger", + ), + (CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"), + (CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"), + ( + CONF_XFAN, + "Gree X-FAN Switch", + gree_ns.GREE_MODE_BIT_XFAN, + "mdi:wall-sconce-flat", + ), ) SUPPORTED_MODELS = { @@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(key): switch.switch_schema( GreeModeBitSwitch, icon=icon, - default_restore_mode="RESTORE_DEFAULT_OFF", + # The display light was on before the switches existed, so it stays on by default + default_restore_mode="RESTORE_DEFAULT_ON" + if key == CONF_LIGHT + else "RESTORE_DEFAULT_OFF", device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, ) From bea0ef264aad87fa2be22b3789920d1c6b6eaf46 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:36:32 +1100 Subject: [PATCH 065/651] [lvgl] Migrate to 9.6.0 (#20257) Co-authored-by: Claude --- esphome/components/lvgl/__init__.py | 40 +++++-- esphome/components/lvgl/automation.py | 5 +- .../components/lvgl/binary_sensor/__init__.py | 4 +- esphome/components/lvgl/defines.py | 27 ++++- esphome/components/lvgl/lvgl_esphome.cpp | 35 +++++- esphome/components/lvgl/lvgl_esphome.h | 3 + esphome/components/lvgl/schemas.py | 1 - esphome/components/lvgl/trigger.py | 6 +- esphome/components/lvgl/widgets/__init__.py | 106 +++++++++--------- esphome/components/lvgl/widgets/arc.py | 4 +- esphome/components/lvgl/widgets/canvas.py | 2 +- esphome/components/lvgl/widgets/lv_list.py | 3 +- esphome/components/lvgl/widgets/meter.py | 3 +- esphome/components/lvgl/widgets/msgbox.py | 7 +- esphome/idf_component.yml | 2 +- platformio.ini | 4 +- .../lvgl/config/lv_conf_check_second.yaml | 41 +++++++ .../lvgl/config/lv_conf_check_warn.yaml | 27 +++++ .../lvgl/config/lv_conf_defaults.yaml | 26 +++++ .../lvgl/config/lv_conf_options.yaml | 29 +++++ tests/component_tests/lvgl/test_list.py | 5 +- tests/component_tests/lvgl/test_lv_conf.py | 80 +++++++++++++ .../lvgl/test_multi_conf_validate.py | 11 +- tests/components/lvgl/lvgl-package.yaml | 11 ++ .../lvgl/test-idf6.esp32-p4-idf.yaml | 22 ++++ tests/components/lvgl/test.esp32-p4-idf.yaml | 1 + tests/components/lvgl/test.host.yaml | 7 +- .../integration/test_lvgl_headless_render.py | 2 +- 28 files changed, 413 insertions(+), 101 deletions(-) create mode 100644 tests/component_tests/lvgl/config/lv_conf_check_second.yaml create mode 100644 tests/component_tests/lvgl/config/lv_conf_check_warn.yaml create mode 100644 tests/component_tests/lvgl/config/lv_conf_defaults.yaml create mode 100644 tests/component_tests/lvgl/config/lv_conf_options.yaml create mode 100644 tests/component_tests/lvgl/test_lv_conf.py create mode 100644 tests/components/lvgl/test-idf6.esp32-p4-idf.yaml diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 66aaac5301f..9b2f41e0635 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -17,6 +17,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, get_esp32_variant, + idf_version, ) from esphome.components.image import ( CONF_OPAQUE, @@ -158,7 +159,7 @@ def as_macro(macro, value): return f"#define {macro} {value}" -LVGL_VERSION = "9.5.0" +LVGL_VERSION = "9.6.0" LV_CONF_FILENAME = "lv_conf.h" LV_CONF_H_FORMAT = """\ #pragma once @@ -324,10 +325,16 @@ async def to_code(configs): # Skip compiling lvgl examples add_idf_sdkconfig_option("CONFIG_LV_BUILD_EXAMPLES", False) add_idf_sdkconfig_option("CONFIG_LV_BUILD_DEMOS", False) + # Match LV_USE_STDLIB_MALLOC in lv_conf.h. This also drops the builtin malloc + # options, which LVGL 9.5 builds leave set to deprecated values. + add_idf_sdkconfig_option("CONFIG_LV_USE_CUSTOM_MALLOC", True) if get_esp32_variant() == VARIANT_ESP32P4: add_idf_sdkconfig_option("CONFIG_LV_DRAW_BUF_ALIGN", 64) - # disable use of PPA for fills until upstream bugs fixed - df.add_define("LV_USE_PPA", "0") + # The LVGL PPA driver uses cache functions added in ESP-IDF 6.0 + if idf_version() >= cv.Version(6, 0, 0): + df.add_define("LV_USE_PPA", "1") + else: + df.add_define("LV_USE_PPA", "0") df.add_define("LV_DRAW_BUF_ALIGN", "64") else: df.add_define("LV_DRAW_BUF_ALIGN", "32") @@ -337,6 +344,8 @@ async def to_code(configs): cg.add_library("lvgl/lvgl", LVGL_VERSION) df.add_define("LV_DRAW_BUF_STRIDE_ALIGN", "1") df.add_define("LV_USE_DRAW_SW", "1") + # LVGL 9.6 rejects 0 here unless tiled rendering is disabled + df.add_define("LV_DRAW_SW_DRAW_UNIT_CNT", "1") df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM") @@ -356,15 +365,22 @@ async def to_code(configs): "LVGL_LOG_LEVEL", cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) - df.add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) + # Big-endian displays get LVGL to render directly in swapped byte order + display_format = ( + "RGB565_SWAPPED" if config_0[CONF_BYTE_ORDER] == BYTE_ORDER_BIG else "RGB565" + ) + df.add_define("LV_COLOR_FORMAT_DEFAULT", f"LV_COLOR_FORMAT_{display_format}") for font in df.get_lv_fonts_used(): df.add_define(f"LV_FONT_{font.upper()}") - - if config_0[CONF_COLOR_DEPTH] == 16: + df.add_define("LV_OBJ_STYLE_CACHE", "1") + if any(config[df.CONF_CHECK_ARGS] for config in configs): + df.add_define("LV_USE_CHECK_ARG", "1") df.add_define( - "LV_COLOR_16_SWAP", - "1" if config_0[CONF_BYTE_ORDER] == "big_endian" else "0", + "LV_CHECK_ARG_LOG_MODE", + f"LV_CHECK_ARG_LOG_MODE_{df.LV_CHECK_ARG_LOG_MODES[config_0[CONF_LOG_LEVEL]]}", ) + else: + df.add_define("LV_USE_CHECK_ARG", "0") df.add_define( "LV_COLOR_CHROMA_KEY", await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]), @@ -504,8 +520,9 @@ async def to_code(configs): if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE): df.add_define("LV_THEME_DEFAULT_DARK", "1") - # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending - lv_image_formats = {"RGB565", "ARGB8888"} + # The display buffer format is always needed, RGB565 is used by canvas buffers and + # ARGB8888 for layer blending + lv_image_formats = {"RGB565", display_format, "ARGB8888"} for image_id in get_lv_images_used(): await cg.get_variable(image_id) @@ -519,7 +536,7 @@ async def to_code(configs): if image_type == ImageRGB565: lv_image_formats.add("RGB565A8" if transparent else "RGB565") if image_type == ImageRGB: - lv_image_formats.add("ARGB8888" if transparent else "RGB8888") + lv_image_formats.add("ARGB8888" if transparent else "RGB888") if df.is_defined("LV_GRADIENT_MAX_STOPS"): lv_image_formats.add("RGB888") for fmt in lv_image_formats: @@ -595,6 +612,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), + cv.Optional(df.CONF_CHECK_ARGS, default=False): cv.boolean, cv.Optional(CONF_BYTE_ORDER): cv.one_of( "big_endian", "little_endian", lower=True ), diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index d797688a7e8..4e8698f33c0 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ACTION, CONF_GROUP, + CONF_HIDDEN, CONF_ID, CONF_POSITION, CONF_ROTATION, @@ -354,7 +355,7 @@ async def obj_enable_to_code(config, action_id, template_arg, args): ) async def obj_hide_to_code(config, action_id, template_arg, args): async def do_hide(widget: Widget): - widget.add_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, True) widgets = [widget.outer or widget for widget in await get_widgets(config)] return await action_to_code(widgets, do_hide, action_id, template_arg, args) @@ -365,7 +366,7 @@ async def obj_hide_to_code(config, action_id, template_arg, args): ) async def obj_show_to_code(config, action_id, template_arg, args): async def do_show(widget: Widget): - widget.clear_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, False) if widget.move_to_foreground: lv_obj.move_foreground(widget.obj) diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index fc7317f8b97..e658735c750 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -6,7 +6,7 @@ from esphome.components.binary_sensor import ( import esphome.config_validation as cv from esphome.const import CONF_STATE -from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant +from ..defines import CONF_CLICKABLE, CONF_WIDGET, LvConstant from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t from ..widgets import Widget, get_widgets @@ -41,7 +41,7 @@ async def to_code(config): ctx.add(sensor.publish_initial_state(test_expr)) if is_pressed: events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED] - widget.add_flag(LV_OBJ_FLAG.CLICKABLE) + widget.set_flag(CONF_CLICKABLE, True) else: events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT] ctx.add( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index c019dff96e0..854ef0fb494 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -477,6 +477,16 @@ LV_LOG_LEVELS = { "NONE": "NONE", } +# Log output for failed LVGL argument checks, chosen from the LVGL log level +LV_CHECK_ARG_LOG_MODES = { + "VERBOSE": "VERBOSE", + "DEBUG": "VERBOSE", + "INFO": "MINIMAL", + "WARN": "MINIMAL", + "ERROR": "NONE", + "NONE": "NONE", +} + LV_LONG_MODES = LvConstant( "LV_LABEL_LONG_", "WRAP", @@ -606,13 +616,20 @@ OBJ_FLAGS = ( "ignore_layout", "floating", "overflow_visible", + "send_draw_task_events", + "event_trickle", + "state_trickle", + "radio_button", + "flex_in_new_track", "layout_1", "layout_2", - "send_draw_task_events", "widget_1", "widget_2", ) -LV_OBJ_FLAG = LvConstant("LV_OBJ_FLAG_", *OBJ_FLAGS) +# Flags whose lv_obj_set_() setter has a different name +FLAG_SETTER_ALIASES = {"layout_1": "flex_in_new_track"} +# Custom flags that have no lv_obj_set_() setter +CUSTOM_FLAGS = ("layout_2", "widget_1", "widget_2") ARC_MODES = LvConstant("LV_ARC_MODE_", "NORMAL", "REVERSE", "SYMMETRICAL") BAR_MODES = LvConstant("LV_BAR_MODE_", "NORMAL", "SYMMETRICAL", "RANGE") @@ -703,6 +720,7 @@ CONF_DISP_BG_OPA = "disp_bg_opa" CONF_BODY = "body" CONF_BUTTONS = "buttons" CONF_CHANGE_RATE = "change_rate" +CONF_CLICKABLE = "clickable" CONF_CLOSE_BUTTON = "close_button" CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" @@ -727,6 +745,7 @@ CONF_FLEX_ALIGN_CROSS = "flex_align_cross" CONF_FLEX_ALIGN_TRACK = "flex_align_track" CONF_FLEX_GROW = "flex_grow" CONF_FREEZE = "freeze" +CONF_CHECK_ARGS = "check_args" CONF_DARK_MODE = "dark_mode" CONF_FULL_REFRESH = "full_refresh" CONF_GRADIENTS = "gradients" @@ -795,6 +814,7 @@ CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" CONF_SCROLL_DIR = "scroll_dir" +CONF_SCROLLABLE = "scrollable" CONF_SCROLL_SNAP_X = "scroll_snap_x" CONF_SCROLL_SNAP_Y = "scroll_snap_y" CONF_SELECTED_INDEX = "selected_index" @@ -891,7 +911,8 @@ def get_part_state_selector(part: str, state: str) -> MockObj: # fmt: off LV_COLOR_FORMATS = ( - "RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1", + "RGB565", "RGB565_SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "ARGB8888_PREMULTIPLIED", "L8", "AL88", + "A8", "I1", ) LV_DEFINES = ( diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index e35cc0c48bf..0b375404d74 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -6,6 +6,7 @@ #include "core/lv_global.h" #include "core/lv_obj_class_private.h" +#include "core/lv_obj_private.h" #include @@ -225,6 +226,36 @@ void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_ev add_event_cb(obj, callback, event3, user_data); } +void LvglComponent::lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value) { + // LVGL 9.6 keeps these flags in spec_attr->user_flags, in the same bits as its deprecated lv_obj_add_flag() + uint8_t mask; + switch (flag) { + case LV_OBJ_FLAG_LAYOUT_2: + mask = 1 << 1; + break; + case LV_OBJ_FLAG_WIDGET_1: + mask = 1 << 2; + break; + case LV_OBJ_FLAG_WIDGET_2: + mask = 1 << 3; + break; + default: + return; + } + if (value) { + lv_obj_spec_attr_t *spec_attr = lv_obj_allocate_spec_attr(obj); + if (spec_attr == nullptr) + return; + spec_attr->user_flags |= mask; + } else if (obj->spec_attr != nullptr) { + obj->spec_attr->user_flags &= ~mask; + } + if (flag == LV_OBJ_FLAG_LAYOUT_2) { + lv_obj_mark_layout_as_dirty(lv_obj_get_parent(obj)); + lv_obj_mark_layout_as_dirty(obj); + } +} + void LvglComponent::add_page(LvPageType *page) { this->pages_.push_back(page); page->set_parent(this); @@ -822,7 +853,7 @@ void LvglComponent::setup() { } this->draw_buf_ = static_cast(buffer); this->set_resolution_(); - lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_RGB565); + lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_DEFAULT); lv_display_set_flush_cb(this->disp_, static_flush_cb); lv_display_set_user_data(this->disp_, this); lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); @@ -994,7 +1025,7 @@ lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_clickable(obj, false); LV_UNUSED(class_p); } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8b7397c4cd2..4da8a6a1a30 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -269,6 +269,9 @@ class LvglComponent final : public PollingComponent { static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, lv_event_code_t event3, void *user_data = nullptr); + // Set or clear LV_OBJ_FLAG_LAYOUT_2, LV_OBJ_FLAG_WIDGET_1 or LV_OBJ_FLAG_WIDGET_2, which have no setter in LVGL 9.6 + static void lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value); + // change the state of a widget and fire an event if changed (only needed for CHECKED) static void lv_obj_set_state_value(lv_obj_t *obj, lv_state_t state, bool value) { diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 924c17fb547..2d879e8d43a 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -391,7 +391,6 @@ SET_STATE_SCHEMA = cv.Schema( ) # Setting object flags FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS}) -FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of) VALUE_TRIGGER_SCHEMA = { cv.Optional(CONF_TRIGGER, default=CONF_ON_VALUE): cv.one_of( diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 56dcf81a797..cd118595f1a 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -17,6 +17,8 @@ from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, + CONF_CLICKABLE, + CONF_SCROLLABLE, DIRECTIONS, LV_DISPLAY_EVENT_MAP, LV_DISPLAY_EVENT_TRIGGERS, @@ -72,7 +74,7 @@ async def generate_triggers(): event: conf for event, conf in config.items() if event in all_triggers }.items(): conf = conf[0] - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger(conf, w, event) for event, conf in { @@ -82,7 +84,7 @@ async def generate_triggers(): dir = event[9:].upper() dir = {"UP": "TOP", "DOWN": "BOTTOM"}.get(dir, dir) dir = DIRECTIONS.mapper(dir) - w.clear_flag("LV_OBJ_FLAG_SCROLLABLE") + w.set_flag(CONF_SCROLLABLE, False) selected = literal( f"lv_indev_get_gesture_dir(lv_indev_active()) == {dir}" ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index d69e90056fd..b48123b9022 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable import sys from typing import Any @@ -37,6 +37,8 @@ from ..defines import ( CONF_SCALE, CONF_STYLES, CONF_WIDGETS, + CUSTOM_FLAGS, + FLAG_SETTER_ALIASES, LOGGER, OBJ_FLAGS, PARTS, @@ -47,13 +49,11 @@ from ..defines import ( get_part_state_selector, get_styles_used, get_widget_map, - join_enums, literal, next_debug_outline_color, ) from ..lv_validation import lv_int from ..lvcode import ( - LvConditional, add_line_marks, lv, lv_add, @@ -295,7 +295,9 @@ class Widget: This class has a lot of methods. Adding any more runs foul of lint checks ("too many public methods"). """ - def __init__(self, var, wtype: WidgetType, config: dict = None): + def __init__( + self, var: MockObj | ID, wtype: WidgetType, config: dict | None = None + ) -> None: self.var = var self.type = wtype self.config = config @@ -314,39 +316,50 @@ class Widget: self.parent = None @staticmethod - def create(name, var, wtype: WidgetType, config: dict = None): + def create( + name: ID | str, var: MockObj, wtype: WidgetType, config: dict | None = None + ) -> "Widget": w = Widget(var, wtype, config) get_widget_map()[name] = w return w - def set_state(self, state: MockObj, value: bool | Expression): + def set_state(self, state: MockObj, value: bool | Expression) -> None: lv_add(lvgl_static.lv_obj_set_state_value(self.obj, state, value)) - def has_state(self, state: MockObj): + def has_state(self, state: MockObj) -> MockObj: return lv_expr.obj_has_state(self.obj, state) - def is_pressed(self): + def is_pressed(self) -> MockObj: return self.has_state(LV_STATE.PRESSED) - def is_checked(self): + def is_checked(self) -> MockObj: return self.has_state(LV_STATE.CHECKED) - def add_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - return lv_obj.add_flag(self.obj, literal(flag)) + def set_flag(self, flag: str, value: bool | Expression) -> None: + """ + Set or clear an object flag, given as the lower case name without the LV_OBJ_FLAG_ prefix + """ + flag = FLAG_SETTER_ALIASES.get(flag, flag) + if flag in CUSTOM_FLAGS: + lv_add( + lvgl_static.lv_obj_set_custom_flag( + self.obj, literal(f"LV_OBJ_FLAG_{flag.upper()}"), value + ) + ) + else: + lv_obj.call(f"set_{flag}", self.obj, value) - def clear_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - return lv_obj.remove_flag(self.obj, literal(flag)) - - def add_style(self, style_id, state=LV_STATE.DEFAULT): + def add_style(self, style_id: ID, state: MockObj | str = LV_STATE.DEFAULT) -> None: lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) async def set_property( - self, prop, value, animated: bool = None, lv_name=None, processor=None - ): + self, + prop: str, + value: Any, + animated: bool | None = None, + lv_name: str | None = None, + processor: LValidator | Callable[[Any], Awaitable[Any]] | None = None, + ) -> None: """ Set a property of the widget. :param prop: The property name @@ -387,11 +400,13 @@ class Widget: literal("LV_ANIM_ON" if animated else "LV_ANIM_OFF"), ) - def get_property(self, prop, ltype=None): + def get_property(self, prop: str, ltype: str | None = None) -> cg.RawExpression: ltype = ltype or self.__type_base() return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})") - def set_style(self, prop: str, value, state=LV_STATE.DEFAULT): + def set_style( + self, prop: str, value: Any, state: MockObj | str = LV_STATE.DEFAULT + ) -> None: if value is None: return get_styles_used().add(prop) @@ -399,22 +414,22 @@ class Widget: value = literal(value) lv.call(f"obj_set_style_{prop}", self.obj, value, state) - def __type_base(self): + def __type_base(self) -> str: wtype = self.type.w_type base = str(wtype) if base.startswith("Lv"): return f"{wtype}".removeprefix("Lv").removesuffix("Type").lower() return f"{wtype}".removeprefix("lv_").removesuffix("_t") - def __str__(self): + def __str__(self) -> str: return f"({self.var}, {self.type})" - def get_args(self): + def get_args(self) -> list[tuple[MockObj, str]]: if isinstance(self.type.w_type, LvType): return self.type.w_type.args.copy() return [(lv_obj_t_ptr, "obj")] - def get_value(self): + def get_value(self) -> Expression: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -422,7 +437,7 @@ class Widget: return result return self.obj - def get_values(self): + def get_values(self) -> list[Expression]: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -430,13 +445,13 @@ class Widget: return [result] return [self.obj] - def get_number_value(self): + def get_number_value(self) -> Expression: value = self.type.mock_obj.get_value(self.obj) if self.scale == 1.0: return value return value / float(self.scale) - def is_selected(self): + def is_selected(self) -> MockObj | None: """ Overridable property to determine if the widget is selected. Will be None except for matrix buttons @@ -444,7 +459,7 @@ class Widget: """ return - def get_scale(self): + def get_scale(self) -> float: return self.type.get_scale(self.config) @@ -630,29 +645,14 @@ async def set_obj_properties(w: Widget, config): group = await cg.get_variable(group) lv.group_add_obj(group, w.obj) props = parts[CONF_MAIN][CONF_DEFAULT] - lambs = {} - flag_set = set() - flag_clr = set() - for prop, value in {k: v for k, v in props.items() if k in OBJ_FLAGS}.items(): + for flag, value in props.items(): + if flag not in OBJ_FLAGS: + continue if isinstance(value, cv.Lambda): - lambs[prop] = value - elif value: - flag_set.add(prop) - else: - flag_clr.add(prop) - if flag_set: - adds = join_enums(flag_set, "LV_OBJ_FLAG_") - w.add_flag(adds) - if flag_clr: - clrs = join_enums(flag_clr, "LV_OBJ_FLAG_") - w.clear_flag(clrs) - for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) - flag = f"LV_OBJ_FLAG_{key.upper()}" - with LvConditional(call_lambda(lamb)) as cond: - w.add_flag(flag) - cond.else_() - w.clear_flag(flag) + value = call_lambda( + await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) + ) + w.set_flag(flag, value) for key, value in config.get(CONF_STATE, {}).items(): if isinstance(value, cv.Lambda): diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index ac993cc3829..4739412e09e 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -13,12 +13,12 @@ from ..defines import ( ARC_MODES, CONF_ADJUSTABLE, CONF_CHANGE_RATE, + CONF_CLICKABLE, CONF_END_ANGLE, CONF_INDICATOR, CONF_KNOB, CONF_MAIN, CONF_START_ANGLE, - LV_OBJ_FLAG, LV_PART, ) from ..lv_validation import ( @@ -85,7 +85,7 @@ class ArcType(NumberType): if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) - w.clear_flag(LV_OBJ_FLAG.CLICKABLE) + w.set_flag(CONF_CLICKABLE, False) elif CONF_GROUP not in config: # For some reason arc does not get automatically added to the default group lv.group_add_obj(lv_expr.group_get_default(), w.obj) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 2137acb3f6a..9dd5e84419e 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -99,7 +99,7 @@ class CanvasType(WidgetType): if config[CONF_TRANSPARENT]: color_format = "LV_COLOR_FORMAT_ARGB8888" else: - color_format = "LV_COLOR_FORMAT_NATIVE" + color_format = "LV_COLOR_FORMAT_RGB565" # LVGL 9.4: LV_CANVAS_BUF_SIZE(width, height, bits_per_pixel, stride) # stride is 0 for default (width * bytes_per_pixel) diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index f41cb8e4e96..98e1838f0f3 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -20,6 +20,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from ..automation import action_to_code from ..defines import ( CONF_ALIGN_TO, + CONF_CLICKABLE, CONF_MAIN, CONF_PAD_ROW, CONF_SCROLLBAR, @@ -441,7 +442,7 @@ async def _wire_dynamic_triggers(w: Widget, config: dict) -> None: for event, conf in { event: conf for event, conf in config.items() if event in LV_EVENT_TRIGGERS }.items(): - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger( conf[0], event_target, event, attach_obj=w.obj, user_data=user_data ) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index fedc79383dc..a49d58dc4db 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -38,7 +38,6 @@ from ..defines import ( CONF_SRC, CONF_START_VALUE, CONF_TICKS, - LV_OBJ_FLAG, LV_PART, LV_SCALE_MODE, add_lv_use, @@ -457,7 +456,7 @@ class MeterType(WidgetType): LV_EVENT.DRAW_TASK_ADDED, nullptr, ) - lv.obj_add_flag(scale_var, LV_OBJ_FLAG.SEND_DRAW_TASK_EVENTS) + lv_obj.set_send_draw_task_events(scale_var, True) if t == CONF_LINE: # Needle represented by a line diff --git a/esphome/components/lvgl/widgets/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index 29087009cb9..44d16bf6141 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -1,5 +1,5 @@ from esphome import codegen as cg, config_validation as cv -from esphome.const import CONF_BUTTON, CONF_ID, CONF_TEXT +from esphome.const import CONF_BUTTON, CONF_HIDDEN, CONF_ID, CONF_TEXT from esphome.core import ID from esphome.cpp_generator import MockObjClass from esphome.cpp_types import nullptr @@ -14,7 +14,6 @@ from ..defines import ( CONF_MSGBOXES, CONF_SRC, CONF_TITLE, - LV_OBJ_FLAG, TYPE_FLEX, add_lv_use, add_warning, @@ -141,7 +140,7 @@ async def msgbox_to_code(top_layer, conf): percent100 = await pixels_or_percent.process(1.0) lv_obj.set_size(outer, percent100, percent100) outer_widget.add_style(await OUTER_STYLE.get_var()) - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) lv.msgbox_add_title(msgbox, title) lv.msgbox_add_text(msgbox, text) lv_obj.set_style_align(msgbox, literal("LV_ALIGN_CENTER"), 0) @@ -153,7 +152,7 @@ async def msgbox_to_code(top_layer, conf): await widget_to_code(button, header_button_spec, msgbox) async with LambdaContext(EVENT_ARG, where=messagebox_id) as close_action: - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) if close_button: with LocalVariable( "close_btn_", lv_obj_t, lv_expr.msgbox_add_close_button(msgbox) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 8ac76cd41fa..670145cfe01 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -107,7 +107,7 @@ dependencies: rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0" lvgl/lvgl: - version: 9.5.0 + version: 9.6.0 fastled/FastLED: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b diff --git a/platformio.ini b/platformio.ini index 7534b748d1b..7b3414a32a7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -108,7 +108,7 @@ build_unflags = lib_deps = esphome/dlms_parser@2.2.0 ; dlms_meter bblanchon/ArduinoJson@7.4.3 ; json - lvgl/lvgl@9.5.0 ; lvgl + lvgl/lvgl@9.6.0 ; lvgl ; This are common settings for the ESP8266 using Arduino. [common:esp8266-arduino] @@ -646,7 +646,7 @@ extends = common platform = platformio/native lib_deps = esphome/noise-c@0.1.31 ; used by noise (api, ota) - lvgl/lvgl@9.5.0 ; lvgl + lvgl/lvgl@9.6.0 ; lvgl build_flags = ${common.build_flags} -DUSE_HOST diff --git a/tests/component_tests/lvgl/config/lv_conf_check_second.yaml b/tests/component_tests/lvgl/config/lv_conf_check_second.yaml new file mode 100644 index 00000000000..a5ccd0e68d4 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_check_second.yaml @@ -0,0 +1,41 @@ +esphome: + name: test-lv-conf-check-second + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + id: display_a + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + - platform: mipi_spi + id: display_b + data_rate: 20MHz + model: st7735 + cs_pin: GPIO4 + dc_pin: GPIO5 + +lvgl: + - id: lvgl_a + displays: display_a + widgets: + - label: + text: Hello + - id: lvgl_b + displays: display_b + check_args: true + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml b/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml new file mode 100644 index 00000000000..769032f8393 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-lv-conf-check-warn + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + check_args: true + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_defaults.yaml b/tests/component_tests/lvgl/config/lv_conf_defaults.yaml new file mode 100644 index 00000000000..94950a3b438 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_defaults.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-lv-conf-defaults + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_options.yaml b/tests/component_tests/lvgl/config/lv_conf_options.yaml new file mode 100644 index 00000000000..969091c086a --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_options.yaml @@ -0,0 +1,29 @@ +esphome: + name: test-lv-conf-options + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + byte_order: little_endian + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + check_args: true + log_level: DEBUG + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/test_list.py b/tests/component_tests/lvgl/test_list.py index 368a3a49905..e93d858ad5c 100644 --- a/tests/component_tests/lvgl/test_list.py +++ b/tests/component_tests/lvgl/test_list.py @@ -287,10 +287,7 @@ def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None: def test_add_button_with_checkable_flag(main_cpp: str) -> None: assert "lv_obj_t *dyn_button_VAR_ = lv_button_create(test_list);" in main_cpp - assert ( - "lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));" - in main_cpp - ) + assert "lv_obj_set_checkable(dyn_button_VAR_, true);" in main_cpp assert ( 'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp ) diff --git a/tests/component_tests/lvgl/test_lv_conf.py b/tests/component_tests/lvgl/test_lv_conf.py new file mode 100644 index 00000000000..8b93c011661 --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf.py @@ -0,0 +1,80 @@ +"""Tests for the LVGL configuration defines written to lv_conf.h.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.lvgl import defines as df +from esphome.components.lvgl.schemas import WIDGET_TYPES + +# LVGL 9.6 stops the build with #error when an enabled option "selects" another +# option that is disabled. ESPHome sets every unused LV_USE_* to 0, so each widget +# must list these in get_uses(). +LVGL_SELECTS = ( + ("SLIDER", "BAR"), + ("BARCODE", "CANVAS"), + ("QRCODE", "CANVAS"), + ("CANVAS", "IMAGE"), + ("DROPDOWN", "LABEL"), + ("MSGBOX", "LABEL"), + ("ROLLER", "LABEL"), + ("TEXTAREA", "LABEL"), +) + + +def test_default_lv_conf_defines( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("lv_conf_defaults.yaml")) + defines = df.get_defines() + assert defines["LV_COLOR_FORMAT_DEFAULT"] == "LV_COLOR_FORMAT_RGB565_SWAPPED" + assert defines["LV_DRAW_SW_SUPPORT_RGB565_SWAPPED"] == "1" + assert defines["LV_OBJ_STYLE_CACHE"] == "1" + assert defines["LV_USE_CHECK_ARG"] == "0" + assert "LV_CHECK_ARG_LOG_MODE" not in defines + # Both are deprecated in LVGL 9.6 and produce build warnings + assert "LV_COLOR_DEPTH" not in defines + assert "LV_COLOR_16_SWAP" not in defines + + +@pytest.mark.parametrize( + ("yaml_file", "color_format", "log_mode"), + [ + ("lv_conf_options.yaml", "RGB565", "VERBOSE"), + ("lv_conf_check_warn.yaml", "RGB565_SWAPPED", "MINIMAL"), + # Only the second LVGL instance sets check_args + ("lv_conf_check_second.yaml", "RGB565_SWAPPED", "MINIMAL"), + ], +) +def test_lv_conf_options( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + yaml_file: str, + color_format: str, + log_mode: str, +) -> None: + generate_main(component_config_path(yaml_file)) + defines = df.get_defines() + assert defines["LV_COLOR_FORMAT_DEFAULT"] == f"LV_COLOR_FORMAT_{color_format}" + assert defines["LV_USE_CHECK_ARG"] == "1" + assert defines["LV_CHECK_ARG_LOG_MODE"] == f"LV_CHECK_ARG_LOG_MODE_{log_mode}" + + +def test_check_arg_log_modes_cover_all_log_levels() -> None: + assert df.LV_CHECK_ARG_LOG_MODES.keys() == df.LV_LOG_LEVELS.keys() + + +@pytest.mark.parametrize("widget_name", sorted(WIDGET_TYPES)) +def test_widget_uses_satisfy_lvgl_selects(widget_name: str) -> None: + widget_type = WIDGET_TYPES[widget_name] + df.add_lv_use(widget_type.name, *widget_type.get_uses()) + enabled = {use.upper() for use in df.get_lv_uses()} + for option, required in LVGL_SELECTS: + if option in enabled: + assert required in enabled, ( + f"'{widget_name}' enables LV_USE_{option}, which needs LV_USE_{required}" + ) diff --git a/tests/component_tests/lvgl/test_multi_conf_validate.py b/tests/component_tests/lvgl/test_multi_conf_validate.py index c44b40317e8..9ec73ca6182 100644 --- a/tests/component_tests/lvgl/test_multi_conf_validate.py +++ b/tests/component_tests/lvgl/test_multi_conf_validate.py @@ -10,11 +10,15 @@ from esphome.config_validation import Invalid def _config( - displays: list[str], theme: dict | None = None, debug_outline: bool = False + displays: list[str], + theme: dict | None = None, + debug_outline: bool = False, + check_args: bool = False, ) -> dict: config = { df.CONF_DISPLAYS: displays, "log_level": "WARN", + df.CONF_CHECK_ARGS: check_args, "color_depth": 16, "byte_order": "big_endian", df.CONF_TRANSPARENCY_KEY: 0x000400, @@ -73,3 +77,8 @@ class TestDebugOutlineOnMultipleInstances: _config(["disp_b"], debug_outline=True), ] multi_conf_validate(configs) + + +def test_check_args_may_differ_between_instances() -> None: + configs = [_config(["disp_a"], check_args=True), _config(["disp_b"])] + multi_conf_validate(configs) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 18a7fd0913a..681eb41b5b5 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -53,6 +53,7 @@ lvgl: id: lvgl_id rotation: 90 log_level: debug + check_args: true resume_on_input: true paused: true update_when_display_idle: true @@ -976,6 +977,16 @@ lvgl: - spinner: align: right_mid send_draw_task_events: true + event_trickle: true + state_trickle: true + radio_button: true + flex_in_new_track: !lambda return true; + - spinner: + align: top_mid + layout_1: true + layout_2: true + widget_1: !lambda return false; + widget_2: false - image: id: lv_image src: cat_image diff --git a/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml b/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e0332caaed0 --- /dev/null +++ b/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml @@ -0,0 +1,22 @@ +# LVGL only uses the PPA to draw on the ESP32-P4 with ESP-IDF 6.0 or later +esp32: + framework: + version: 6.0.1 + +display: + - platform: mipi_dsi + model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C + +lvgl: + widgets: + - obj: + bg_color: 0x2040C0 + widgets: + - label: + text: PPA + +psram: + +esp_ldo: + - channel: 3 + voltage: 2.5V diff --git a/tests/components/lvgl/test.esp32-p4-idf.yaml b/tests/components/lvgl/test.esp32-p4-idf.yaml index 5fd93702554..a7a8d13ffc5 100644 --- a/tests/components/lvgl/test.esp32-p4-idf.yaml +++ b/tests/components/lvgl/test.esp32-p4-idf.yaml @@ -3,6 +3,7 @@ display: model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C lvgl: byte_order: little_endian + check_args: true rotation: 90 psram: diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 22d051da8eb..1df27d82f73 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -1,5 +1,5 @@ display: - - platform: sdl + - platform: snapshot id: sdl0 auto_clear_enabled: false dimensions: @@ -11,11 +11,6 @@ display: width: 480 height: 480 -touchscreen: - - platform: sdl - display: sdl0 - sdl_id: sdl0 - lvgl: - id: lvgl_0 default_font: space16 diff --git a/tests/integration/test_lvgl_headless_render.py b/tests/integration/test_lvgl_headless_render.py index 1c60e49604d..5a668a23027 100644 --- a/tests/integration/test_lvgl_headless_render.py +++ b/tests/integration/test_lvgl_headless_render.py @@ -30,7 +30,7 @@ EXPECTED_SHA256 = "a995b002dd1d183c47514da15ab9a60a3e7d788c2e24386a02fddd4865509 # Bundled LVGL version (esphome/components/lvgl/__init__.py, LVGL_VERSION) the hash above was # generated against. A version bump can shift anti-aliasing enough to change the hash even though # nothing is actually wrong -- if this test fails, check that first before regenerating the hash. -EXPECTED_LVGL_VERSION = "9.5.0" +EXPECTED_LVGL_VERSION = "9.6.0" @pytest.mark.asyncio From 2ab06fa937d93af213307efab50f2e8360d4f519 Mon Sep 17 00:00:00 2001 From: Max Khrul'ov Date: Sun, 11 Oct 2026 23:39:22 +0300 Subject: [PATCH 066/651] [pipsolar] Add QET/QLT total energy sensors (#17347) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston --- esphome/components/pipsolar/pipsolar.cpp | 17 +++++++++++++++++ esphome/components/pipsolar/pipsolar.h | 11 +++++++++++ .../components/pipsolar/sensor/__init__.py | 19 +++++++++++++++++++ .../pipsolar/text_sensor/__init__.py | 4 ++++ tests/components/pipsolar/common.yaml | 12 ++++++++++++ 5 files changed, 63 insertions(+) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index ade93b87359..ea7ebaed0ae 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -286,6 +286,12 @@ void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const c case POLLING_QMN: handle_qmn_(message); break; + case POLLING_QET: + handle_single_int_(message, this->last_qet_, this->total_pv_generated_energy_); + break; + case POLLING_QLT: + handle_single_int_(message, this->last_qlt_, this->total_output_load_energy_); + break; default: break; } @@ -688,6 +694,17 @@ void Pipsolar::handle_qmn_(const char *message) { } } +// QET and QLT each answer with one integer +void Pipsolar::handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value) { + if (raw) { + raw->publish_state(message); + } + + size_t pos = 0; + this->skip_start_(message, &pos); + this->read_int_sensor_(message, &pos, value); +} + void Pipsolar::skip_start_(const char *message, size_t *pos) { if (message[*pos] == '(') { (*pos)++; diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 06c920a6e49..4695e39829a 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -19,6 +19,8 @@ enum ENUMPollingCommand { POLLING_QPIWS = 4, POLLING_QT = 5, POLLING_QMN = 6, + POLLING_QET = 7, + POLLING_QLT = 8, }; struct PollingCommand { uint8_t *command; @@ -116,6 +118,12 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_SENSOR(pv_ok_condition_for_parallel, QPIRI) PIPSOLAR_SENSOR(pv_power_balance, QPIRI) + // QET value + PIPSOLAR_SENSOR(total_pv_generated_energy, QET) + + // QLT value + PIPSOLAR_SENSOR(total_output_load_energy, QLT) + // QMOD values PIPSOLAR_TEXT_SENSOR(device_mode, QMOD) @@ -175,6 +183,8 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_TEXT_SENSOR(last_qpiws, QPIWS) PIPSOLAR_TEXT_SENSOR(last_qt, QT) PIPSOLAR_TEXT_SENSOR(last_qmn, QMN) + PIPSOLAR_TEXT_SENSOR(last_qet, QET) + PIPSOLAR_TEXT_SENSOR(last_qlt, QLT) PIPSOLAR_SWITCH(output_source_priority_utility_switch, QPIRI) PIPSOLAR_SWITCH(output_source_priority_solar_switch, QPIRI) @@ -213,6 +223,7 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { void handle_qpiws_(const char *message); void handle_qt_(const char *message); void handle_qmn_(const char *message); + void handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value); void skip_start_(const char *message, size_t *pos); void skip_field_(const char *message, size_t *pos); diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 5a697157b7e..02f9e239c7c 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -17,6 +18,7 @@ from esphome.const import ( ICON_FLASH, ICON_GAUGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -80,6 +83,10 @@ CONF_BATTERY_VOLTAGE_OFFSET_FOR_FANS_ON = "battery_voltage_offset_for_fans_on" CONF_EEPROM_VERSION = "eeprom_version" CONF_PV_CHARGING_POWER = "pv_charging_power" +# QET / QLT sensors +CONF_TOTAL_PV_GENERATED_ENERGY = "total_pv_generated_energy" +CONF_TOTAL_OUTPUT_LOAD_ENERGY = "total_output_load_energy" + TYPES = { CONF_GRID_RATING_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, @@ -319,6 +326,18 @@ TYPES = { device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + CONF_TOTAL_PV_GENERATED_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_TOTAL_OUTPUT_LOAD_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/esphome/components/pipsolar/text_sensor/__init__.py b/esphome/components/pipsolar/text_sensor/__init__.py index cc7477395ba..0986120dcfc 100644 --- a/esphome/components/pipsolar/text_sensor/__init__.py +++ b/esphome/components/pipsolar/text_sensor/__init__.py @@ -15,6 +15,8 @@ CONF_LAST_QFLAG = "last_qflag" CONF_LAST_QPIWS = "last_qpiws" CONF_LAST_QT = "last_qt" CONF_LAST_QMN = "last_qmn" +CONF_LAST_QET = "last_qet" +CONF_LAST_QLT = "last_qlt" TYPES = [ CONF_DEVICE_MODE, @@ -25,6 +27,8 @@ TYPES = [ CONF_LAST_QPIWS, CONF_LAST_QT, CONF_LAST_QMN, + CONF_LAST_QET, + CONF_LAST_QLT, ] CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/tests/components/pipsolar/common.yaml b/tests/components/pipsolar/common.yaml index 819764a7eab..883b9d289d3 100644 --- a/tests/components/pipsolar/common.yaml +++ b/tests/components/pipsolar/common.yaml @@ -204,6 +204,12 @@ sensor: pv_charging_power: id: inverter0_pv_charging_power name: inverter0_pv_charging_power + total_pv_generated_energy: + id: inverter0_total_pv_generated_energy + name: inverter0_total_pv_generated_energy + total_output_load_energy: + id: inverter0_total_output_load_energy + name: inverter0_total_output_load_energy switch: - platform: pipsolar @@ -241,3 +247,9 @@ text_sensor: last_qflag: id: inverter0_last_qflag name: inverter0_last_qflag + last_qet: + id: inverter0_last_qet + name: inverter0_last_qet + last_qlt: + id: inverter0_last_qlt + name: inverter0_last_qlt From eb56ce2b7620f3ce229b045e96c8287f79b224c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:05:09 +0200 Subject: [PATCH 067/651] [haier] Reject deferring actions in on_status_message (#20539) Co-authored-by: Claude Opus 5.5 --- esphome/components/haier/climate.py | 7 ++- tests/component_tests/haier/__init__.py | 0 .../haier/test_status_message_automation.py | 61 +++++++++++++++++++ tests/components/haier/common.yaml | 6 ++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/haier/__init__.py create mode 100644 tests/component_tests/haier/test_status_message_automation.py diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index ba760536865..db915e9c7f4 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -186,7 +186,12 @@ def _base_config_schema(class_: MockObjClass) -> cv.Schema: cv.Optional( CONF_ANSWER_TIMEOUT, ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_STATUS_MESSAGE): automation.validate_automation({}), + # data points into the protocol's receive buffer, reused for the next frame + cv.Optional( + CONF_ON_STATUS_MESSAGE + ): automation.validate_synchronous_automation( + "on_status_message", "the received frame" + ), } ) .extend(uart.UART_DEVICE_SCHEMA) diff --git a/tests/component_tests/haier/__init__.py b/tests/component_tests/haier/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/haier/test_status_message_automation.py b/tests/component_tests/haier/test_status_message_automation.py new file mode 100644 index 00000000000..5f4927fe10c --- /dev/null +++ b/tests/component_tests/haier/test_status_message_automation.py @@ -0,0 +1,61 @@ +"""Tests that haier on_status_message automations reject deferring actions. + +The trigger passes a pointer into the protocol's receive buffer, which is reused for the next +frame, so the deferring-actions guard is a safety property. +""" + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.haier.climate import ( + CONF_ON_STATUS_MESSAGE, + HonClimate, + Smartair2Climate, + _base_config_schema, +) +from esphome.core import Lambda +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +CLIMATE_CLASSES = [HonClimate, Smartair2Climate] + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _trigger_schema( + class_: MockObjClass, +) -> Callable[[list[ConfigType]], list[ConfigType]]: + """The validator the protocol's schema applies to on_status_message.""" + return _base_config_schema(class_).schema[cv.Optional(CONF_ON_STATUS_MESSAGE)] + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_synchronous_automation_accepted(class_: MockObjClass) -> None: + _trigger_schema(class_)([{"then": [SYNCHRONOUS_ACTION]}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_deferring_action_rejected(class_: MockObjClass) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)([{"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_nested_deferring_action_rejected(class_: MockObjClass) -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)([{"then": actions}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_deferring_action_in_second_automation_rejected(class_: MockObjClass) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)( + [{"then": [SYNCHRONOUS_ACTION]}, {"then": [DEFERRING_ACTION]}] + ) diff --git a/tests/components/haier/common.yaml b/tests/components/haier/common.yaml index 926a1d1b7a9..ab39dfa61d5 100644 --- a/tests/components/haier/common.yaml +++ b/tests/components/haier/common.yaml @@ -43,6 +43,12 @@ climate: level: DEBUG format: "Alarm deactivated. Code: %d. Message: \"%s\"" args: [code, message] + on_status_message: + then: + - logger.log: + level: DEBUG + format: "Status message: %u bytes" + args: [(unsigned) data_size] sensor: - platform: haier From 5cdefa910f54bf7e49b9839b446d9b3173bfd6db Mon Sep 17 00:00:00 2001 From: Fabio Pugliese Ornellas Date: Sun, 11 Oct 2026 22:06:05 +0100 Subject: [PATCH 068/651] [cse7761] Add new sensors (#17531) Co-authored-by: J. Nick Koston --- esphome/components/cse7761/cse7761.cpp | 90 +++++++++++++++++++++----- esphome/components/cse7761/cse7761.h | 19 +++++- esphome/components/cse7761/sensor.py | 55 ++++++++++++---- tests/components/cse7761/common.yaml | 8 +++ 4 files changed, 143 insertions(+), 29 deletions(-) diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index c4073d27821..a6101812223 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -18,21 +18,32 @@ static constexpr int CSE7761_UREF = 42563; // RmsUc static constexpr int CSE7761_IREF = 52241; // RmsIAC static constexpr int CSE7761_PREF = 44513; // PowerPAC +// System clock (3.579545MHz) / 8, divided by Ufreq gives the line frequency in Hz +static constexpr float CSE7761_UFREQ_CLOCK = 3579545.0f / 8.0f; + static constexpr uint8_t CSE7761_REG_SYSCON = 0x00; // (2) System Control Register (0x0A04) static constexpr uint8_t CSE7761_REG_EMUCON = 0x01; // (2) Metering control register (0x0000) +static constexpr uint8_t CSE7761_REG_HFCONST = 0x02; // (2) Pulse frequency register (0x1000) static constexpr uint8_t CSE7761_REG_EMUCON2 = 0x13; // (2) Metering control register 2 (0x0001) static constexpr uint8_t CSE7761_REG_PULSE1SEL = 0x1D; // (2) Pin function output select register (0x3210) -static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) -static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register +static constexpr uint8_t CSE7761_REG_UFREQ = 0x23; // (2) Voltage frequency (0x0000) +static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) +static constexpr uint8_t CSE7761_REG_POWERFACTOR = 0x27; // (3) Power factor of the selected channel (0x7FFFFF) +static constexpr uint8_t CSE7761_REG_ENERGY_PA = 0x28; // (3) Channel A active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_ENERGY_PB = 0x29; // (3) Channel B active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register static constexpr uint8_t CSE7761_REG_COEFFCHKSUM = 0x6F; // (2) Coefficient checksum static constexpr uint8_t CSE7761_REG_RMSIAC = 0x70; // (2) Channel A effective current conversion coefficient +// Datasheet: Wh = pulses * EnergyXC * HFConst * 1000 / 2^41 with K1 = K2 = 1 +static constexpr float CSE7761_ENERGY_WH_SCALE = 1000.0f / 2199023255552.0f; + static constexpr uint8_t CSE7761_SPECIAL_COMMAND = 0xEA; // Start special command static constexpr uint8_t CSE7761_CMD_RESET = 0x96; // Reset command, after receiving the command, the chip resets static constexpr uint8_t CSE7761_CMD_CLOSE_WRITE = 0xDC; // Close write operation @@ -58,6 +69,15 @@ void CSE7761Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current 1", this->current_sensor_1_); + LOG_SENSOR(" ", "Current 2", this->current_sensor_2_); + LOG_SENSOR(" ", "Power 1", this->power_sensor_1_); + LOG_SENSOR(" ", "Power 2", this->power_sensor_2_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Power Factor 1", this->power_factor_sensor_1_); + LOG_SENSOR(" ", "Energy 1", this->energy_sensors_[0]); + LOG_SENSOR(" ", "Energy 2", this->energy_sensors_[1]); } void CSE7761Component::update() { @@ -123,17 +143,18 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { return true; } -uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { - bool result = false; // Start loop - uint8_t retry = 3; // Retry up to three times - uint32_t value = 0; // Default no value - while (!result && retry > 0) { - retry--; - if (this->read_once_(reg, size, &value)) - return value; +bool CSE7761Component::read_(uint8_t reg, uint8_t size, uint32_t *value) { + for (uint8_t retry = 0; retry < 3; retry++) { + if (this->read_once_(reg, size, value)) + return true; } ESP_LOGE(TAG, "Reading register %hhu failed!", reg); - return value; + return false; +} + +uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { + uint32_t value = 0; + return this->read_(reg, size, &value) ? value : 0; } uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) { @@ -159,12 +180,25 @@ bool CSE7761Component::chip_init_() { calc_chksum += this->data_.coefficient[i]; } calc_chksum = ~calc_chksum; + const bool energy_configured = this->energy_sensors_[0] != nullptr || this->energy_sensors_[1] != nullptr; uint16_t coeff_chksum = this->read_(CSE7761_REG_COEFFCHKSUM, 2); if ((calc_chksum != coeff_chksum) || (!calc_chksum)) { ESP_LOGD(TAG, "Default calibration"); this->data_.coefficient[RMS_IAC] = CSE7761_IREF; this->data_.coefficient[RMS_UC] = CSE7761_UREF; this->data_.coefficient[POWER_PAC] = CSE7761_PREF; + if (energy_configured) { + ESP_LOGW(TAG, "No energy calibration, energy unavailable"); + } + } else if (energy_configured) { + uint32_t hf_const = 0; + if (!this->read_(CSE7761_REG_HFCONST, 2, &hf_const)) { + ESP_LOGW(TAG, "HFConst read failed, energy unavailable"); + } + for (uint8_t channel = 0; channel < 2; channel++) { + uint32_t scale = this->data_.coefficient[ENERGY_AC + channel] * hf_const; + this->energy_[channel].wh_per_pulse = static_cast(scale) * CSE7761_ENERGY_WH_SCALE; + } } this->write_(CSE7761_SPECIAL_COMMAND, CSE7761_CMD_ENABLE_WRITE); @@ -173,7 +207,8 @@ bool CSE7761Component::chip_init_() { if (sys_status & 0x10) { // Write enable to protected registers (WREN) this->write_(CSE7761_REG_SYSCON | 0x80, 0xFF04); this->write_(CSE7761_REG_EMUCON | 0x80, 0x1183); - this->write_(CSE7761_REG_EMUCON2 | 0x80, 0x0FC1); + // WaveEN|ZxEN, needed for Ufreq + this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1); this->write_(CSE7761_REG_PULSE1SEL | 0x80, 0x3290); } else { ESP_LOGD(TAG, "Write failed at chip_init"); @@ -210,12 +245,35 @@ void CSE7761Component::get_data_() { this->voltage_sensor_->publish_state(voltage); } + uint32_t raw_reg = 0; + if (this->frequency_sensor_ != nullptr && this->read_(CSE7761_REG_UFREQ, 2, &raw_reg)) { + // Ufreq highest bit set means no signal + float frequency = (raw_reg == 0 || raw_reg >= 0x8000) ? 0.0f : CSE7761_UFREQ_CLOCK / raw_reg; + this->frequency_sensor_->publish_state(frequency); + } + + if (this->power_factor_sensor_1_ != nullptr && this->read_(CSE7761_REG_POWERFACTOR, 3, &raw_reg)) { + // ADC2ON makes the chip report channel A + int24_t pf{static_cast(raw_reg)}; + this->power_factor_sensor_1_->publish_state(static_cast(static_cast(pf)) / 0x800000); + } + for (uint8_t channel = 0; channel < 2; channel++) { // Active power = PowerPA * PowerPAC * 1000 / 0x80000000 float active_power = static_cast(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W float amps = static_cast(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps); + sensor::Sensor *energy_sensor = this->energy_sensors_[channel]; + CSE7761EnergyChannel &energy = this->energy_[channel]; + uint8_t energy_reg = (channel == 0) ? CSE7761_REG_ENERGY_PA : CSE7761_REG_ENERGY_PB; + uint32_t raw = 0; + if (energy_sensor != nullptr && energy.wh_per_pulse > 0.0f && this->read_(energy_reg, 3, &raw)) { + // 24-bit counter, not cleared on read + energy.pulses += (raw - energy.last_pulses) & 0xFFFFFF; + energy.last_pulses = raw; + energy_sensor->publish_state(energy.pulses * energy.wh_per_pulse); + } if (channel == 0) { if (this->power_sensor_1_ != nullptr) { this->power_sensor_1_->publish_state(active_power); diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index e08ebf09cc9..557de0e3422 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -3,11 +3,11 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" #include "esphome/core/component.h" +#include "esphome/core/datatypes.h" namespace esphome::cse7761 { struct CSE7761DataStruct { - uint32_t frequency = 0; uint32_t voltage_rms = 0; uint32_t current_rms[2] = {0}; int32_t active_power[2] = {0}; @@ -15,14 +15,26 @@ struct CSE7761DataStruct { bool ready = false; }; +struct CSE7761EnergyChannel { + uint32_t last_pulses{0}; + uint32_t pulses{0}; + float wh_per_pulse{0.0f}; +}; + /// This class implements support for the CSE7761 UART power sensor. class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } + void set_frequency_sensor(sensor::Sensor *frequency_sensor) { frequency_sensor_ = frequency_sensor; } + void set_power_factor_1_sensor(sensor::Sensor *power_factor_sensor_1) { + power_factor_sensor_1_ = power_factor_sensor_1; + } void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } void set_current_1_sensor(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } + void set_energy_1_sensor(sensor::Sensor *energy_sensor_1) { this->energy_sensors_[0] = energy_sensor_1; } void set_active_power_2_sensor(sensor::Sensor *power_sensor_2) { power_sensor_2_ = power_sensor_2; } void set_current_2_sensor(sensor::Sensor *current_sensor_2) { current_sensor_2_ = current_sensor_2; } + void set_energy_2_sensor(sensor::Sensor *energy_sensor_2) { this->energy_sensors_[1] = energy_sensor_2; } void setup() override; void dump_config() override; void update() override; @@ -30,14 +42,19 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice protected: // Sensors sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *frequency_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_1_{nullptr}; sensor::Sensor *power_sensor_1_{nullptr}; sensor::Sensor *current_sensor_1_{nullptr}; sensor::Sensor *power_sensor_2_{nullptr}; sensor::Sensor *current_sensor_2_{nullptr}; + sensor::Sensor *energy_sensors_[2]{nullptr, nullptr}; CSE7761DataStruct data_; + CSE7761EnergyChannel energy_[2]; void write_(uint8_t reg, uint16_t data); bool read_once_(uint8_t reg, uint8_t size, uint32_t *value); + bool read_(uint8_t reg, uint8_t size, uint32_t *value); uint32_t read_(uint8_t reg, uint8_t size); uint32_t coefficient_by_unit_(uint32_t unit); bool chip_init_(); diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 5f79be0255b..8ffbb44771a 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -2,15 +2,22 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( + CONF_FREQUENCY, CONF_ID, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, + UNIT_HERTZ, UNIT_VOLT, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -26,6 +33,9 @@ CONF_CURRENT_1 = "current_1" CONF_CURRENT_2 = "current_2" CONF_ACTIVE_POWER_1 = "active_power_1" CONF_ACTIVE_POWER_2 = "active_power_2" +CONF_ENERGY_1 = "energy_1" +CONF_ENERGY_2 = "energy_2" +CONF_POWER_FACTOR_1 = "power_factor_1" CONFIG_SCHEMA = ( cv.Schema( @@ -37,6 +47,17 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_FREQUENCY): sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR_1): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional(CONF_CURRENT_1): sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=2, @@ -61,6 +82,18 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_ENERGY_1): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_ENERGY_2): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } ) .extend(cv.polling_component_schema("60s")) @@ -83,15 +116,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - for key in [ - CONF_VOLTAGE, - CONF_CURRENT_1, - CONF_CURRENT_2, - CONF_ACTIVE_POWER_1, - CONF_ACTIVE_POWER_2, - ]: - if key not in config: - continue - conf = config[key] - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_POWER_FACTOR_1, var.set_power_factor_1_sensor) + await sensors(CONF_CURRENT_1, var.set_current_1_sensor) + await sensors(CONF_CURRENT_2, var.set_current_2_sensor) + await sensors(CONF_ACTIVE_POWER_1, var.set_active_power_1_sensor) + await sensors(CONF_ACTIVE_POWER_2, var.set_active_power_2_sensor) + await sensors(CONF_ENERGY_1, var.set_energy_1_sensor) + await sensors(CONF_ENERGY_2, var.set_energy_2_sensor) diff --git a/tests/components/cse7761/common.yaml b/tests/components/cse7761/common.yaml index 77b19957b46..ae3a642709e 100644 --- a/tests/components/cse7761/common.yaml +++ b/tests/components/cse7761/common.yaml @@ -2,6 +2,10 @@ sensor: - platform: cse7761 voltage: name: CSE7761 Voltage + frequency: + name: CSE7761 Frequency + power_factor_1: + name: CSE7761 Power Factor 1 current_1: name: CSE7761 Current 1 current_2: @@ -10,3 +14,7 @@ sensor: name: CSE7761 Active Power 1 active_power_2: name: CSE7761 Active Power 2 + energy_1: + name: CSE7761 Energy 1 + energy_2: + name: CSE7761 Energy 2 From 36fbf5bf22d21802833c8ab21c554845383bfc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:07:39 +0200 Subject: [PATCH 069/651] [emontx] Add pause/resume actions to let another component take over the UART (#18756) Co-authored-by: Claude --- esphome/components/emontx/__init__.py | 13 ++++ esphome/components/emontx/emontx.cpp | 20 +++++- esphome/components/emontx/emontx.h | 11 +++- tests/components/emontx/common-actions.yaml | 18 ++++++ tests/components/emontx/emontx_test.cpp | 67 +++++++++++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 79b0e83405b..2146545db52 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -157,3 +157,16 @@ automation.register_apply_action( CONF_COMMAND, "send_command", cg.std_string, const_fn=_plain_literal ), ) + + +# Let another component (e.g. serial_proxy) take exclusive ownership of the UART. + +EMONTX_ID_SCHEMA = automation.maybe_simple_id({cv.GenerateID(): cv.use_id(EmonTx)}) + +automation.register_apply_action( + "emontx.pause", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(true)") +) +automation.register_apply_action( + "emontx.resume", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(false)") +) +automation.register_apply_condition("emontx.is_paused", EMONTX_ID_SCHEMA, "is_paused()") diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 675744b5f58..a307ac0de2a 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -16,6 +16,20 @@ void EmonTx::setup() { this->buffer_pos_ = 0; } * 2. If line starts with '{', parse as JSON and update sensors/callbacks */ void EmonTx::loop() { + if (this->is_paused()) { + this->was_paused_ = true; + return; + } + if (this->was_paused_) { + // Drop what arrived during the pause and resync at the next line start + this->was_paused_ = false; + this->buffer_pos_ = 0; + this->discarding_line_ = true; + uint8_t dropped; + while (this->available() > 0 && this->read_byte(&dropped)) + this->discarding_line_ = dropped != '\n'; + } + // Read all available data to prevent UART buffer overflow while (this->available() > 0) { uint8_t received; @@ -45,7 +59,7 @@ void EmonTx::loop() { } } } else if (received == '\r' || this->discarding_line_) { - // Ignore CR, and the rest of a line that overflowed the buffer + // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); this->buffer_pos_ = 0; @@ -100,6 +114,10 @@ void EmonTx::dump_config() { * @param command The command string to send (LF will be appended automatically). */ void EmonTx::send_command(const char *command) { + if (this->is_paused()) { + ESP_LOGW(TAG, "Not sending command, UART is paused: %s", command); + return; + } ESP_LOGD(TAG, "Sending command to emonTx: %s", command); this->write_str(command); this->write_byte('\n'); diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index f97c5aa6cb6..83ee4fdbabe 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -8,6 +8,7 @@ #include "esphome/components/json/json_util.h" #include +#include #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" @@ -41,6 +42,12 @@ class EmonTx final : public Component, public uart::UARTDevice { void send_command(const char *command); void send_command(const std::string &command) { this->send_command(command.c_str()); } + /// Stop reading the UART and sending commands, so another component can use it. + /// Atomic, so it can be called from any task. Hold the pause for at least one loop() pass. + void set_paused(bool paused) { this->paused_.store(paused, std::memory_order_relaxed); } + + bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } + #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } void register_sensor(const char *tag_name, sensor::Sensor *sensor); @@ -55,7 +62,9 @@ class EmonTx final : public Component, public uart::UARTDevice { LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; uint16_t buffer_pos_{0}; - bool discarding_line_{false}; // Set after an overflow until the end of that line + std::atomic paused_{false}; + bool was_paused_{false}; + bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line std::array buffer_{}; }; diff --git a/tests/components/emontx/common-actions.yaml b/tests/components/emontx/common-actions.yaml index af8d44896be..294e7367fbd 100644 --- a/tests/components/emontx/common-actions.yaml +++ b/tests/components/emontx/common-actions.yaml @@ -5,3 +5,21 @@ button: - emontx.send_command: id: test_emontx command: !lambda return "l"; + - platform: template + name: Pause emontx + on_press: + - if: + condition: + emontx.is_paused: test_emontx + then: + - emontx.resume: test_emontx + else: + - emontx.pause: test_emontx + - platform: template + name: Pause emontx explicit id + on_press: + - emontx.pause: + id: test_emontx + - delay: 1s + - emontx.resume: + id: test_emontx diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 20d3adf0dc0..8530b0a0036 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -91,4 +92,70 @@ TEST_F(EmonTxTest, OverflowAcrossLoopCalls) { EXPECT_EQ(this->lines_[0], "MSG:3"); } +TEST_F(EmonTxTest, LoopLeavesBytesUntouchedWhenPaused) { + this->uart_.push_rx("{\"P1\":123}\n"); + this->sut_.set_paused(true); + this->sut_.loop(); + + EXPECT_EQ(this->uart_.available(), std::strlen("{\"P1\":123}\n")); + EXPECT_TRUE(this->lines_.empty()); +} + +TEST_F(EmonTxTest, SendCommandIsSuppressedWhilePaused) { + this->sut_.set_paused(true); + this->sut_.send_command("x"); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->sut_.set_paused(false); + this->sut_.send_command("x"); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "x\n"); +} + +// Neither the part before the pause nor the part after it may reach on_data +TEST_F(EmonTxTest, ResumeDiscardsInterruptedLine) { + this->uart_.push_rx("{\"P1\":4"); + this->sut_.loop(); + this->sut_.set_paused(true); + this->sut_.loop(); + this->sut_.set_paused(false); + + this->uart_.push_rx("56}\n"); + this->sut_.loop(); + EXPECT_TRUE(this->lines_.empty()); + + this->uart_.push_rx("{\"P1\":789}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":789}"); +} + +TEST_F(EmonTxTest, ResumeDropsBacklogReceivedWhilePaused) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":1}\n{\"P1\":2}\n{\"P1\":"); + this->sut_.set_paused(false); + + this->sut_.loop(); + EXPECT_EQ(this->uart_.available(), 0u); + EXPECT_TRUE(this->lines_.empty()); + + this->uart_.push_rx("3}\n{\"P1\":4}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":4}"); +} + +TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":1}\n"); + this->sut_.set_paused(false); + + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":2}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":2}"); +} + } // namespace esphome::emontx::testing From f2db62adf277e398b68e1ff9715587309f2a9224 Mon Sep 17 00:00:00 2001 From: Maciej Kucharek Date: Sun, 11 Oct 2026 23:08:02 +0200 Subject: [PATCH 070/651] [mipi_dsi] Add JC1060P470-V2 (#18695) Co-authored-by: J. Nick Koston --- esphome/components/mipi_dsi/models/guition.py | 57 +++++++++++++++++++ .../mipi_dsi/validate-v2.esp32-p4-idf.yaml | 18 ++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index 914361a4ac5..3ebaf53f5b7 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,3 +1,5 @@ +from esphome.components.mipi import SLPOUT, delay + from . import DsiDriverChip # fmt: off @@ -34,6 +36,61 @@ DsiDriverChip( ], ) +# JC1060P470-V2: 2026 batch of the JC1060P470 with a different 7" panel behind the same JD9165 driver. +# The rear label shows "V2" after the material number. Timings and init sequence taken from Guition's +# reference driver JC1060P470C_I_W_Y.zip (2026-07-13), +# 1-Demo/.../JC1060P470C_I_W_Y_New_Panel/lvgl_demo_v8/src/lcd/esp_lcd_jd9165.{c,h} +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 1024x600 +# * PCLK Frequency: 52 MHz +# * DSI Lane Bit Rate: 750 Mbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=24, hsync_back_porch=136, hsync_front_porch=160) +# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=21, vsync_front_porch=12) +# ---------------------------------------------------------------------------------------------------------------------- +DsiDriverChip( + "JC1060P470-V2", + width=1024, + height=600, + hsync_back_porch=136, + hsync_pulse_width=24, + hsync_front_porch=160, + vsync_back_porch=21, + vsync_pulse_width=2, + vsync_front_porch=12, + pclk_frequency="52MHz", + lane_bit_rate="750Mbps", + color_order="RGB", + # LCD_RST is GPIO0 on the JC1060P470C_I_W_Y schematic (FPC1 pin 4); the JD9165 needs 120 ms after reset + # before it accepts the vendor table and 120 ms after SLPOUT before DISPON (Guition esp_lcd_jd9165.c); + # the framework adds another 10 ms after SLPOUT. + # SLPOUT is in the init sequence with its own delay, so the framework must not append a second one. + reset_pin=0, + no_slpout=True, + initsequence=[ + delay(120), + (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x08), (0x0B, 0x11), + (0x20, 0x04), (0x1F, 0x05), (0x23, 0x38), (0x28, 0x18), (0x29, 0x29), (0x2A, 0x01), (0x2B, 0x29), + (0x2C, 0x01), (0x30, 0x02), (0x00, 0x05), (0x01, 0x22), (0x02, 0x08), (0x03, 0x12), (0x04, 0x16), + (0x05, 0x64), (0x06, 0x00), (0x07, 0x00), (0x08, 0x78), (0x09, 0x00), (0x0A, 0x04), + (0x0B, 0x16, 0x17, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x07, 0x07, 0x09), + (0x0C, 0x09, 0x1E, 0x1E, 0x1C, 0x1C, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0D, 0x0A, 0x05, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x06, 0x06, 0x08), + (0x0E, 0x08, 0x1F, 0x1F, 0x1D, 0x1D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0F, 0x0A, 0x05, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1D, 0x1D, 0x1F), + (0x10, 0x1F, 0x08, 0x08, 0x06, 0x06, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x11, 0x16, 0x17, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1C, 0x1C, 0x1E), + (0x12, 0x1E, 0x09, 0x09, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x13, 0x00, 0x00, 0x00, 0x00), (0x14, 0x00, 0x00, 0x41, 0x41), (0x15, 0x00, 0x00, 0x00, 0x00), + (0x17, 0x00), (0x18, 0x85), (0x19, 0x06, 0x09), (0x1A, 0x05, 0x08), (0x1B, 0x0A, 0x04), (0x26, 0x00), + (0x27, 0x00), (0x30, 0x06), + (0x12, 0x3F, 0x25, 0x27, 0x35, 0x1D, 0x1B, 0x1B, 0x1A, 0x18, 0x0A, 0x2A, 0x21, 0x19, 0x30), + (0x13, 0x3F, 0x26, 0x27, 0x35, 0x1E, 0x1C, 0x1C, 0x1A, 0x18, 0x0B, 0x2A, 0x21, 0x19, 0x30), + (0x30, 0x0A), (0x02, 0x4F), (0x0B, 0x40), (0x30, 0x0D), (0x0D, 0x04), (0x10, 0x05), (0x11, 0x0C), + (0x12, 0x05), (0x13, 0x0C), (0x30, 0x00), + (SLPOUT,), delay(120), + ], +) + # JC4880P443 Driver Configuration (ST7701) # Using parameters from esp_lcd_st7701.h and the working full init sequence diff --git a/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml b/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml new file mode 100644 index 00000000000..bbb4a7269a8 --- /dev/null +++ b/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml @@ -0,0 +1,18 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-p4-idf.yaml + +esp_ldo: + - id: ldo_id + channel: 3 + voltage: 2.5V + +display: + - platform: mipi_dsi + model: JC1060P470-V2 + enable_pin: GPIO22 + +#light: + #- platform: mipi_dsi + #id: backlight_id + +psram: From 99988490091efe78a10e64c4ebfa4289075a9848 Mon Sep 17 00:00:00 2001 From: heikkikii <11836264+heikkikii@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:10:50 +1100 Subject: [PATCH 071/651] [seeed_mr24hpc1] Wait for the module to boot before sending UART traffic (#18860) Co-authored-by: J. Nick Koston --- .../components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 15 ++++++++++++--- .../components/seeed_mr24hpc1/seeed_mr24hpc1.h | 1 + .../components/seeed_mr24hpc1/test.esp32-idf.yaml | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 tests/components/seeed_mr24hpc1/test.esp32-idf.yaml diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 1bc3e0aeb42..8d5103cbbaf 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -9,6 +9,8 @@ namespace esphome::seeed_mr24hpc1 { ESPHOME_LOG_TAG(TAG, "seeed_mr24hpc1"); +static constexpr uint32_t STARTUP_DELAY_MS = 1000; + // Prints the component's configuration data. dump_config() prints all of the component's configuration // items in an easy-to-read format, including the configuration key-value pairs. void MR24HPC1Component::dump_config() { @@ -76,8 +78,7 @@ void MR24HPC1Component::setup() { this->custom_mode_end_text_sensor_->publish_state("Not in custom mode"); } #endif - this->set_custom_end_mode(); - this->poll_time_base_func_check_ = true; + this->poll_time_base_func_check_ = false; this->check_dev_inf_sign_ = true; this->sg_start_query_data_ = STANDARD_FUNCTION_QUERY_PRODUCT_MODE; this->sg_data_len_ = 0; @@ -92,7 +93,13 @@ void MR24HPC1Component::setup() { memset(this->sg_frame_prase_buf_, 0, FRAME_BUF_MAX_SIZE); memset(this->sg_frame_buf_, 0, FRAME_BUF_MAX_SIZE); - this->set_interval(8000, [this]() { this->update_(); }); + // The module locks up when queried during its own boot, so hold all TX for a second + this->set_timeout(STARTUP_DELAY_MS, [this]() { + this->module_ready_ = true; + this->set_custom_end_mode(); + this->poll_time_base_func_check_ = true; + this->set_interval(8000, [this]() { this->update_(); }); + }); } // Timed polling of radar data @@ -680,6 +687,8 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) { // Sending data frames void MR24HPC1Component::send_query_(const uint8_t *query, size_t string_length) { + if (!this->module_ready_) + return; this->write_array(query, string_length); } diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b231bab33e5..23cecfc9863 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -153,6 +153,7 @@ class MR24HPC1Component final : public Component, int sg_start_query_data_; bool check_dev_inf_sign_; bool poll_time_base_func_check_; + bool module_ready_{false}; void update_(); void r24_split_data_frame_(uint8_t value); diff --git a/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml new file mode 100644 index 00000000000..3853bf3e8dc --- /dev/null +++ b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr24hpc1: !include common.yaml From 9b34db14d44d531b1d99eec31f9da742b0d28a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:15:15 +0200 Subject: [PATCH 072/651] [mqtt] Reject deferring actions in on_json_message (#20538) Co-authored-by: Claude Opus 5.5 --- esphome/components/mqtt/__init__.py | 21 ++++---- tests/component_tests/mqtt/__init__.py | 0 .../mqtt/test_json_message_automation.py | 52 +++++++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/mqtt/__init__.py create mode 100644 tests/component_tests/mqtt/test_json_message_automation.py diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index b21dc2e2f00..826d4e32ef2 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -224,6 +224,17 @@ def _consume_mqtt_sockets(config: ConfigType) -> ConfigType: return config +# The parsed JSON document is freed once the automation returns +JSON_MESSAGE_AUTOMATION_SCHEMA = automation.validate_synchronous_automation( + "on_json_message", + "the parsed JSON document", + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTJsonMessageTrigger), + cv.Required(CONF_TOPIC): cv.subscribe_topic, + cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, + }, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -304,15 +315,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PAYLOAD): cv.string_strict, } ), - cv.Optional(CONF_ON_JSON_MESSAGE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - MQTTJsonMessageTrigger - ), - cv.Required(CONF_TOPIC): cv.subscribe_topic, - cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, - } - ), + cv.Optional(CONF_ON_JSON_MESSAGE): JSON_MESSAGE_AUTOMATION_SCHEMA, cv.Optional(CONF_PUBLISH_NAN_AS_NONE, default=False): cv.boolean, cv.Optional(CONF_WAIT_FOR_CONNECTION, default=False): cv.boolean, } diff --git a/tests/component_tests/mqtt/__init__.py b/tests/component_tests/mqtt/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mqtt/test_json_message_automation.py b/tests/component_tests/mqtt/test_json_message_automation.py new file mode 100644 index 00000000000..a6bb84c7c71 --- /dev/null +++ b/tests/component_tests/mqtt/test_json_message_automation.py @@ -0,0 +1,52 @@ +"""Tests that mqtt on_json_message automations reject deferring actions. + +The trigger passes a view into the parsed JSON document, which is freed once the automation +returns, so the deferring-actions guard is a safety property. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.mqtt import CONFIG_SCHEMA, JSON_MESSAGE_AUTOMATION_SCHEMA +from esphome.const import CONF_ON_JSON_MESSAGE +from esphome.core import Lambda +from esphome.types import ConfigType + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _automation(*actions: ConfigType) -> ConfigType: + return {"topic": "test/topic", "then": list(actions)} + + +def test_schema_wired_to_on_json_message() -> None: + schema = CONFIG_SCHEMA.validators[0].schema + assert schema[cv.Optional(CONF_ON_JSON_MESSAGE)] is JSON_MESSAGE_AUTOMATION_SCHEMA + + +def test_synchronous_automation_accepted() -> None: + JSON_MESSAGE_AUTOMATION_SCHEMA([_automation(SYNCHRONOUS_ACTION)]) + + +def test_deferring_action_rejected() -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA( + [_automation(SYNCHRONOUS_ACTION, DEFERRING_ACTION)] + ) + + +def test_nested_deferring_action_rejected() -> None: + action = {"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}} + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA([_automation(action)]) + + +def test_deferring_action_in_second_automation_rejected() -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA( + [_automation(SYNCHRONOUS_ACTION), _automation(DEFERRING_ACTION)] + ) From 3035a08b0a61055c4ae8b6b52523495223b953cf Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:19:43 +0200 Subject: [PATCH 073/651] [zigbee] Improve basic cluster and add manufacturer and app version setting (#20519) --- esphome/components/zigbee/__init__.py | 64 ++++++++++++++++++- esphome/components/zigbee/const.py | 2 + esphome/components/zigbee/zigbee_esp32.cpp | 72 +++++++++++++--------- esphome/components/zigbee/zigbee_esp32.h | 6 +- esphome/components/zigbee/zigbee_esp32.py | 10 ++- esphome/components/zigbee/zigbee_zephyr.py | 14 +++-- tests/components/zigbee/common.yaml | 8 +++ tests/components/zigbee/common_esp32.yaml | 2 + tests/components/zigbee/common_nrf52.yaml | 2 + 9 files changed, 140 insertions(+), 40 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index d01eddb9ff7..1c85553c57d 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import DOMAIN as ESP32_DOMAIN, only_on_variant from esphome.components.esp32.const import ( VARIANT_ESP32C5, @@ -14,14 +15,19 @@ from esphome.components.esp32.const import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ESPHOME, CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START, CONF_POLLING_INTERVAL, + CONF_PROJECT, + CONF_VERSION, + __version__ as ESPHOME_VERSION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +import esphome.final_validate as fv from esphome.types import ConfigType from .const import ( @@ -36,6 +42,8 @@ from .const import ( CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, REPORT, ZigbeeComponent, @@ -143,12 +151,33 @@ def _validate_router_sleepy(config: ConfigType) -> ConfigType: return config +def _validate_ascii(value): + """Validate that a string contains only ascii characters.""" + if not isinstance(value, str): + raise cv.Invalid("expected string") + if not value.isascii(): + raise cv.Invalid("Only ASCII characters are allowed") + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent), cv.Optional(CONF_MODEL, default=CORE.name): cv.All( - cv.string, cv.Length(max=31) + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional( + CONF_MANUFACTURER, default="esphome", visibility=cv.Visibility.YAML_ONLY + ): cv.All( + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional(CONF_VERSION, default=0): cv.All( + cv.int_range(min=0x00, max=0xFF), ), cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), @@ -207,7 +236,7 @@ CONFIG_SCHEMA = cv.All( def validate_number_of_ep(config: ConfigType) -> ConfigType: if not CORE.is_nrf52: return config - if KEY_ZIGBEE not in CORE.data: + if KEY_EP_NUMBER not in CORE.data.get(KEY_ZIGBEE, {}): raise cv.Invalid("At least one zigbee device need to be included") count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) if count == 1: @@ -220,7 +249,38 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType: return config +def final_validate_strings(config: ConfigType) -> ConfigType: + version_str = ESPHOME_VERSION + if ( + proj_version := fv.full_config.get() + .get(CONF_ESPHOME, {}) + .get(CONF_PROJECT, {}) + .get(CONF_VERSION) + ): + version_str = proj_version.encode("ascii", "ignore")[:15].decode() + if version_str != proj_version: + _LOGGER.warning( + "esphome.project.version %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + proj_version, + version_str, + ) + area_str = "" + if CORE.area: + area_str = CORE.area.encode("ascii", "ignore")[:15].decode() + if area_str != CORE.area: + _LOGGER.warning( + "esphome.area %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + CORE.area, + area_str, + ) + data: dict[str, Any] = CORE.data.setdefault(KEY_ZIGBEE, {}) + data[KEY_ZIGBEE_VERSION_STR] = version_str + data[KEY_ZIGBEE_AREA_STR] = area_str + return config + + FINAL_VALIDATE_SCHEMA = cv.All( + final_validate_strings, validate_number_of_ep, final_validate_esp32, ) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index aad2b5c0e39..109b953de5c 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -85,6 +85,8 @@ POWER_SOURCE = { } KEY_ZIGBEE = "zigbee" +KEY_ZIGBEE_AREA_STR = "zigbee_area_str" +KEY_ZIGBEE_VERSION_STR = "zigbee_version_str" # BACnet engineering units mapping (ZCL uses BACnet unit codes) # See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 7c197133c2b..ffb0d7ec7a7 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -238,7 +238,8 @@ void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", endpoint_id, cluster_id, role); } -void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { +void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, const char *location, + const char *sw_build_id, uint8_t power_source, uint8_t app_version) { char date_buf[16]; time_t time_val = App.get_build_time(); struct tm *timeinfo = localtime(&time_val); @@ -247,7 +248,10 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu .model = get_zcl_string(model, 31), .manufacturer = get_zcl_string(manufacturer, 31), .date = get_zcl_string(date_buf, 15), + .location = get_zcl_string(location, 15), + .sw_build_id = get_zcl_string(sw_build_id, 15), .power_source = power_source, + .app_version = app_version, }; } @@ -267,6 +271,16 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_APPLICATION_VERSION_ID, + &(this->basic_cluster_data_.app_version)); + uint8_t stack_version = 0; + uint8_t hw_version = 0; + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_STACK_VERSION_ID, &stack_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_HW_VERSION_ID, &hw_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_LOCATION_DESCRIPTION_ID, + this->basic_cluster_data_.location); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_SW_BUILD_ID_ID, + this->basic_cluster_data_.sw_build_id); } bool ZigbeeComponent::register_device() { @@ -420,37 +434,35 @@ void ZigbeeComponent::loop() { } void ZigbeeComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %.*s\n" + " Manufacturer: %.*s\n" +#ifdef CONFIG_ZB_ZCZR + " Router: %s\n" +#else + " Sleepy end device: %s\n" + " Poll interval: %" PRIu32 " ms\n" +#endif + " App Version: %u", + this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), + this->basic_cluster_data_.manufacturer[0], + reinterpret_cast(this->basic_cluster_data_.manufacturer + 1), +#ifdef CONFIG_ZB_ZCZR + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), +#else + YESNO(this->sleepy_), this->keep_alive_, +#endif + this->basic_cluster_data_.app_version); if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - ESP_LOGCONFIG( - TAG, - "Zigbee\n" - " Model: %.*s\n" -#ifdef CONFIG_ZB_ZCZR - " Router: %s\n" -#else - " Sleepy end device: %s\n" - " Poll interval: %" PRIu32 " ms\n" -#endif - " Device is joined to the network: %s\n" - " Current channel: %d\n" - " Short addr: 0x%04X\n" - " Short pan id: 0x%04X", - this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), -#ifdef CONFIG_ZB_ZCZR - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), -#else - YESNO(this->sleepy_), this->keep_alive_, -#endif - YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); - esp_zigbee_lock_release(); - } else { ESP_LOGCONFIG(TAG, - "Zigbee\n" - " Model: %.*s\n" - " Router: %s\n", - this->basic_cluster_data_.model[0], - reinterpret_cast(this->basic_cluster_data_.model + 1), - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), + ezb_nwk_get_panid()); + esp_zigbee_lock_release(); } } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 28ea64c8cea..f5c7ff8de3b 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -44,7 +44,8 @@ class ZigbeeComponent final : public Component { void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } void set_keep_alive(uint32_t keep_alive) { this->keep_alive_ = keep_alive; } - void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); + void set_basic_cluster(const char *model, const char *manufacturer, const char *location, const char *sw_build_id, + uint8_t power_source, uint8_t app_version); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); bool register_device(); @@ -80,7 +81,10 @@ class ZigbeeComponent final : public Component { uint8_t *model; uint8_t *manufacturer; uint8_t *date; + uint8_t *location; + uint8_t *sw_build_id; uint8_t power_source; + uint8_t app_version; } basic_cluster_data_; #ifdef CONFIG_ZB_ZED ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 68136d57192..fec8ceba744 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -4,6 +4,7 @@ import re from typing import Any import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( CONF_PARTITIONS, add_idf_component, @@ -28,6 +29,7 @@ from esphome.const import ( CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, + CONF_VERSION, CONF_WIFI, DEVICE_CLASS_OUTLET, ) @@ -49,6 +51,8 @@ from .const import ( CONF_SLEEPY, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, ZigbeeAttribute, ) @@ -418,12 +422,14 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": cg.add(var.set_sleepy(True)) if (keep_alive := config.get(CONF_POLLING_INTERVAL, 3000)) != 3000: cg.add(var.set_keep_alive(keep_alive)) - cg.add( var.set_basic_cluster( config[CONF_MODEL], - "esphome", + config[CONF_MANUFACTURER], + zb_data[KEY_ZIGBEE_AREA_STR], + zb_data[KEY_ZIGBEE_VERSION_STR], POWER_SOURCE[config[CONF_POWER_SOURCE]], + config[CONF_VERSION], ) ) for ep_num, ep in ep_dict.items(): diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 8197796b6db..d468c9881d0 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -2,6 +2,7 @@ import datetime import random import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( @@ -9,7 +10,7 @@ from esphome.const import ( CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, - __version__, + CONF_VERSION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.cpp_generator import ( @@ -27,6 +28,8 @@ from .const import ( CONF_SLEEPY, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, AnalogAttrs, AnalogAttrsOutput, @@ -158,15 +161,16 @@ async def zephyr_to_code(config: ConfigType) -> "MockObj": async def _attr_to_code(config: ConfigType) -> None: # Create the basic attributes structure and attribute list + zb_data = CORE.data.get(KEY_ZIGBEE, {}) basic_attrs = zigbee_new_variable("zigbee_basic_attrs", ZB_ZCL_BASIC_ATTRS_EXT_T) zigbee_new_attr_list( "zigbee_basic_attrib_list", "ZB_ZCL_DECLARE_BASIC_ATTRIB_LIST_EXT", zigbee_assign(basic_attrs.zcl_version, cg.RawExpression("ZB_ZCL_VERSION")), - zigbee_assign(basic_attrs.app_version, 0), + zigbee_assign(basic_attrs.app_version, config[CONF_VERSION]), zigbee_assign(basic_attrs.stack_version, 0), zigbee_assign(basic_attrs.hw_version, 0), - zigbee_set_string(basic_attrs.mf_name, "esphome"), + zigbee_set_string(basic_attrs.mf_name, config[CONF_MANUFACTURER]), zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( basic_attrs.date_code, @@ -178,11 +182,11 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.power_source, POWER_SOURCE[config[CONF_POWER_SOURCE]], ), - zigbee_set_string(basic_attrs.location_id, ""), + zigbee_set_string(basic_attrs.location_id, zb_data[KEY_ZIGBEE_AREA_STR]), zigbee_assign( basic_attrs.ph_env, cg.RawExpression("ZB_ZCL_BASIC_ENV_UNSPECIFIED") ), - zigbee_set_string(basic_attrs.sw_ver, __version__), + zigbee_set_string(basic_attrs.sw_ver, zb_data[KEY_ZIGBEE_VERSION_STR]), ) # Create the identify attributes structure and attribute list diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index 8ef49570729..4851ec7dea0 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -1,3 +1,11 @@ +esphome: + area: + id: testing_area + name: Testing Area + project: + name: esphome.test + version: "1.1" + binary_sensor: - platform: template name: "Garage Door Open 1" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 84b79bb84f0..9df43d957bb 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -58,6 +58,8 @@ switch: zigbee: model: zigbee_test + manufacturer: test + version: 1 sleepy: true polling_interval: 2s power_source: BATTERY diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml index 2da65706182..a99f6bd92e3 100644 --- a/tests/components/zigbee/common_nrf52.yaml +++ b/tests/components/zigbee/common_nrf52.yaml @@ -3,6 +3,8 @@ packages: zigbee: model: zigbee_test + manufacturer: test + version: 1 wipe_on_boot: true on_join: then: From 2ef95bbc28f1fa112d13d292b315f9f2e4264011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:22:52 +0200 Subject: [PATCH 074/651] [core] Copy trigger arguments in wait_until and script.wait (#20536) Co-authored-by: Claude Opus 5.5 --- esphome/components/script/script.h | 8 +- esphome/core/automation.h | 8 ++ esphome/core/base_automation.h | 21 ++-- .../core/test_wait_until_action.cpp | 109 ++++++++++++++++++ .../script/test_script_wait_action.cpp | 96 +++++++++++++++ 5 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 tests/components/core/test_wait_until_action.cpp create mode 100644 tests/components/script/test_script_wait_action.cpp diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 97b27af498d..f00ad7f4afa 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -306,7 +306,7 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { auto ¶ms = this->param_queue_.front(); - this->play_next_tuple_(params, std::make_index_sequence{}); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); this->param_queue_.pop_front(); } else { // Queue is now empty - disable loop until next play_complex @@ -323,12 +323,8 @@ template class ScriptWaitAction final : public Action void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->play_next_(std::get(tuple)...); - } - C *script_; - std::list> param_queue_; + std::list...>> param_queue_; }; } // namespace esphome::script diff --git a/esphome/core/automation.h b/esphome/core/automation.h index d3f1fe59e59..35c2a17c247 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -8,6 +8,7 @@ #include "esphome/core/string_ref.h" #include #include +#include #include #include @@ -494,6 +495,13 @@ template class TemplatableValue { }; }; +/// How deferring actions store a trigger argument: a copy, since a referenced object may be gone once the trigger +/// returns, unless the type cannot be copied (e.g. an entity, which outlives the wait). A copyable `Base &` would be +/// stored sliced, so entity-like argument types must not be copyable. +template +using stored_trigger_arg_t = + std::conditional_t>, std::remove_cvref_t, T>; + /** Base class for all automation conditions. * * @tparam Ts The template parameters to pass when executing. diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 8762cbe7110..69def222da2 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -500,7 +501,7 @@ template class WaitUntilAction : public Action, public Co // Store for later processing auto now = millis(); auto timeout = this->timeout_value_.optional_value(x...); - this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...)); + this->var_queue_.emplace_back(now, timeout, x...); // Do immediate check with fresh timestamp - don't call loop() synchronously! // Let the event loop call it to avoid reentrancy issues @@ -532,7 +533,13 @@ template class WaitUntilAction : public Action, public Co } protected: - using QueueItem = std::tuple, std::tuple>; + struct QueueItem { + QueueItem(uint32_t start, optional timeout, const Ts &...x) + : start(start), timeout(timeout), args(x...) {} + uint32_t start; + optional timeout; + std::tuple...> args; + }; // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items @@ -546,14 +553,14 @@ template class WaitUntilAction : public Action, public Co std::list pending; while (!queue.empty()) { auto it = queue.begin(); - auto start = std::get(*it); - auto timeout = std::get>(*it); + auto start = it->start; + auto timeout = it->timeout; // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + if (!expired && !std::apply([this](auto &...args) { return this->condition_->check(args...); }, it->args)) { pending.splice(pending.end(), queue, it); continue; } @@ -564,7 +571,7 @@ template class WaitUntilAction : public Action, public Co std::list completed; completed.splice(completed.begin(), queue, it); uint8_t generation = this->stop_generation_; - this->play_next_tuple_(std::get>(completed.front())); + std::apply([this](auto &...args) { this->play_next_(args...); }, completed.front().args); if (generation != this->stop_generation_) { // stop() ran inside the chain - the items still held locally were cancelled pending.clear(); @@ -581,7 +588,7 @@ template class WaitUntilAction : public Action, public Co Condition *condition_; std::list var_queue_{}; - // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_() uint8_t stop_generation_{0}; }; diff --git a/tests/components/core/test_wait_until_action.cpp b/tests/components/core/test_wait_until_action.cpp new file mode 100644 index 00000000000..56cfa10d513 --- /dev/null +++ b/tests/components/core/test_wait_until_action.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include + +#include "esphome/core/base_automation.h" + +namespace esphome::testing { + +class FlagCondition : public Condition { + public: + bool check(const std::string & /*value*/) override { return this->ready; } + bool ready{false}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(WaitUntilActionTest, KeepsCopyOfReferenceArguments) { + FlagCondition condition; + WaitUntilAction wait(&condition); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + condition.ready = true; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class CounterCondition : public Condition { + public: + bool check(Counter & /*counter*/) override { return this->ready; } + bool ready{false}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +// A non-const reference argument used to fail to compile +TEST(WaitUntilActionTest, AcceptsMutableReferenceArguments) { + CounterCondition condition; + WaitUntilAction wait(&condition); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + condition.ready = true; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityCondition : public Condition { + public: + bool check(Entity & /*entity*/) override { return this->ready; } + bool ready{false}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(WaitUntilActionTest, KeepsReferenceToNonCopyableArguments) { + EntityCondition condition; + WaitUntilAction wait(&condition); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + condition.ready = true; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::testing diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp new file mode 100644 index 00000000000..17f553996ea --- /dev/null +++ b/tests/components/script/test_script_wait_action.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/script/script.h" + +namespace esphome::script::testing { + +class FakeScript { + public: + bool is_running() const { return this->running; } + bool running{true}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(ScriptWaitActionTest, KeepsCopyOfReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +TEST(ScriptWaitActionTest, KeepsCopyOfMutableReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + script.running = false; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + script.running = false; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::script::testing From 7d7db6bf90c8f716d857e294efcdeb17dcab7471 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:31:49 +0200 Subject: [PATCH 075/651] [zigbee] add report action on esp32 (#20488) Co-authored-by: J. Nick Koston --- esphome/components/zigbee/__init__.py | 4 +-- .../zigbee/zigbee_attribute_esp32.cpp | 11 ++++++++ .../zigbee/zigbee_attribute_esp32.h | 1 + esphome/components/zigbee/zigbee_ep_esp32.py | 21 +------------- esphome/components/zigbee/zigbee_esp32.cpp | 6 ++++ esphome/components/zigbee/zigbee_esp32.h | 1 + esphome/components/zigbee/zigbee_esp32.py | 28 +++++++++++++++++-- tests/components/zigbee/common_esp32.yaml | 4 +++ 8 files changed, 52 insertions(+), 24 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 1c85553c57d..44f83696f25 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -78,9 +78,9 @@ CONFLICTS_WITH = ["openthread"] def _check_report_deprecation(value: str) -> str: - if str(value).lower() in ("coordinator", "enable"): + if str(value).lower() == "coordinator": _LOGGER.warning( - "Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead." + "Report option 'coordinator' is deprecated and will be removed in a future release. Use 'default' instead." ) return value diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index b73c26a8361..e2ff542976b 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -62,6 +62,9 @@ void ZigbeeAttribute::report_(bool has_lock) { } void ZigbeeAttribute::set_report(ZigbeeReportT report) { + if (report == ZigbeeReportT::ZIGBEE_REPORT_DEFAULT || report == ZigbeeReportT::ZIGBEE_REPORT_COORDINATOR) { + return; + } this->report_enabled = true; if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { this->force_report_ = true; @@ -73,6 +76,14 @@ void ZigbeeAttribute::set_report(ZigbeeReportT report) { }); } +void ZigbeeAttribute::report() { + if (!this->report_enabled) { + return; + } + this->report_requested_ = true; + this->enable_loop(); +} + void ZigbeeAttribute::loop() { if (this->set_attr_requested_) { this->set_attr_(); diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 305febf171a..adaa60646d1 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -53,6 +53,7 @@ class ZigbeeAttribute final : public Component { template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); + void report(); template void add_on_value_callback(F &&callback) { on_value_callback_.add(std::forward(callback)); } void on_value(ezb_zcl_attribute_t attribute) { diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 88f49ef3c16..1616ce0cebf 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -27,13 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, Lambda -from .const import ( - CONF_MAX_EP_NUMBER, - CONF_REPORT, - CONF_USE_DEVICE_TYPE, - KEY_ZIGBEE, - REPORT, -) +from .const import CONF_MAX_EP_NUMBER, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE from .const_esp32 import ( ALLOWED_UNITS, CONF_ATTRIBUTE_ID, @@ -58,7 +52,6 @@ ANALOG_INPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -87,7 +80,6 @@ ANALOG_OUTPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -117,7 +109,6 @@ BINARY_INPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -146,7 +137,6 @@ BINARY_OUTPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -178,7 +168,6 @@ def _pressure_ep(device_type: bool = False) -> dict[str, Any]: { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "INT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: { UNIT_HECTOPASCAL: 1, @@ -208,7 +197,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "INT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), SCALE: 100, CONNECT: True, }, @@ -226,7 +214,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), SCALE: 100, CONNECT: True, }, @@ -247,7 +234,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: { UNIT_LITRE_PER_HOUR: 0.01, @@ -269,7 +255,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_LAMBDA: cv.lambda_( Lambda( "if (x < 0.0f || std::isnan(x)) return 0xFFFF;" # NaN @@ -294,7 +279,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -316,7 +300,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0000, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: 0.000001, }, @@ -339,7 +322,6 @@ BINARY_SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "MAP8", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -369,7 +351,6 @@ SWITCH_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, ], diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index ffb0d7ec7a7..68c0d005b57 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -49,6 +49,12 @@ void ZigbeeComponent::factory_reset() { esp_zigbee_lock_release(); } +void ZigbeeComponent::report() { + for (const auto &[_, attribute] : this->attributes_) { + attribute->report(); + } +} + void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 100, diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index f5c7ff8de3b..d5ed7439bd2 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -61,6 +61,7 @@ class ZigbeeComponent final : public Component { static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); void factory_reset(); + void report(); template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } template void add_on_start_callback(F &&cb) { this->start_cb_.add(std::forward(cb)); } diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index fec8ceba744..99f075ecb2f 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -3,10 +3,12 @@ import logging import re from typing import Any +from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( CONF_PARTITIONS, + DOMAIN as ESP32_DOMAIN, add_idf_component, add_idf_sdkconfig_option, add_partition, @@ -55,6 +57,7 @@ from .const import ( KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, ZigbeeAttribute, + ZigbeeComponent, ) from .const_esp32 import ( ALLOWED_UNITS, @@ -204,8 +207,11 @@ def setup_attributes(config: ConfigType, clusters: list[dict[str, Any]]) -> None attr[CONF_VALUE] = str(name) attr[CONF_MAX_LENGTH] = len(str(name)) if attr.get(CONNECT): # connect device - if CONF_REPORT in config: - attr[CONF_REPORT] = config[CONF_REPORT] + if (report := config.get(CONF_REPORT, "default")) not in ( + "default", + "coordinator", + ): + attr[CONF_REPORT] = report attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None) attr_list = config.setdefault(KEY_ZIGBEE_ATTRIBUTES, []) attr_list.append(attr) @@ -460,3 +466,21 @@ async def add_component(entity: cg.MockObj, config: ConfigType) -> None: cg.add(zb_attr.connect(template_arg, entity, lambda_)) else: cg.add(zb_attr.connect(template_arg, entity)) + + +ZIGBEE_ACTION_SCHEMA_ESP32 = automation.maybe_simple_id( + cv.All( + cv.Schema( + { + cv.GenerateID(): cv.use_id(ZigbeeComponent), + }, + ), + cv.requires_component(ESP32_DOMAIN), + ) +) + +automation.register_apply_action( + "zigbee.report", + ZIGBEE_ACTION_SCHEMA_ESP32, + automation.ApplyCall("report()"), +) diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 9df43d957bb..2b6fc3b6c54 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -16,6 +16,9 @@ binary_sensor: name: "Garage Door Open 13" endpoint: 2 use_device_type: false + on_press: + then: + - zigbee.report sensor: - platform: template @@ -26,6 +29,7 @@ sensor: endpoint: 1 use_device_type: true cluster: device_class + report: enable - platform: template name: "Light Sensor" lambda: return 100.0; From 8060dadd6c87d7944222875a9aeba23c8ee08921 Mon Sep 17 00:00:00 2001 From: embe <31192394+mbrudka@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:49:49 +0200 Subject: [PATCH 076/651] [micro_wake_word] Add opt-in internal RAM placement (#20529) Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 6 ++ .../micro_wake_word/micro_wake_word.cpp | 7 ++- .../micro_wake_word/micro_wake_word.h | 4 +- .../micro_wake_word/streaming_model.cpp | 56 +++++++++++++++++-- .../micro_wake_word/streaming_model.h | 16 ++++-- esphome/core/defines.h | 1 + tests/components/micro_wake_word/common.yaml | 1 + .../test-internal.esp32-idf.yaml | 9 +++ .../micro_wake_word/test.esp32-s3-idf.yaml | 4 +- 9 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 tests/components/micro_wake_word/test-internal.esp32-idf.yaml diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index f04f784ebcf..9477ebc850e 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -44,6 +44,7 @@ DOMAIN = "micro_wake_word" CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" +CONF_PREFER_INTERNAL_RAM = "prefer_internal_ram" CONF_PROBABILITY_CUTOFF = "probability_cutoff" CONF_SLIDING_WINDOW_AVERAGE_SIZE = "sliding_window_average_size" CONF_SLIDING_WINDOW_SIZE = "sliding_window_size" @@ -426,6 +427,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." @@ -519,6 +521,8 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + if config[CONF_PREFER_INTERNAL_RAM]: + cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() if config.get(CONF_TASK_STACK_IN_PSRAM): @@ -569,6 +573,7 @@ async def to_code(config): cg.add( var.add_vad_model( prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_MICRO][CONF_TENSOR_ARENA_SIZE], @@ -581,6 +586,7 @@ async def to_code(config): model_parameters[CONF_ID], str(model_parameters[CONF_ID]), prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_WAKE_WORD], diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6d76e399868..4ae66d69bf0 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -386,9 +386,10 @@ WakeWordModel *MicroWakeWord::get_model_by_id(const std::string &model_id) { } #ifdef USE_MICRO_WAKE_WORD_VAD -void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { - this->vad_model_ = make_unique(model_start, probability_cutoff, sliding_window_size, tensor_arena_size); +void MicroWakeWord::add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { + this->vad_model_ = + make_unique(model_start, model_size, probability_cutoff, sliding_window_size, tensor_arena_size); } #endif diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 03f4a86fd47..862d40733bf 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -93,8 +93,8 @@ class MicroWakeWord final : public Component uint8_t get_features_step_size() const { return this->features_step_size_; } #ifdef USE_MICRO_WAKE_WORD_VAD - void add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + void add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); // Intended for the voice assistant component to fetch VAD status bool get_vad_state() { return this->vad_state_; } diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 7cbd11711a2..14e6046ba04 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -4,6 +4,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM +#include +#endif ESPHOME_LOG_TAG(TAG, "micro_wake_word"); @@ -62,16 +66,43 @@ bool StreamingModel::load_model_() { } if (this->tensor_arena_ == nullptr) { +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + RAMAllocator preferred_allocator(RAMAllocator::PREFER_INTERNAL); + this->tensor_arena_ = preferred_allocator.allocate(this->tensor_arena_size_); + if (this->tensor_arena_ != nullptr && !esp_ptr_internal(this->tensor_arena_)) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for tensor arena; using default memory", + this->tensor_arena_size_); + } +#else this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_); +#endif if (this->tensor_arena_ == nullptr) { ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena."); return false; } } +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (!esp_ptr_internal(this->model_start_) && this->model_copy_ == nullptr) { + RAMAllocator internal_allocator(RAMAllocator::ALLOC_INTERNAL); + this->model_copy_ = internal_allocator.make_unique_array_for_overwrite(this->model_size_); + if (this->model_copy_ == nullptr) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for model; using original model data", + this->model_size_); + } else { + std::memcpy(this->model_copy_.get(), this->model_start_, this->model_size_); + } + } +#endif + if (this->interpreter_ == nullptr) { + const uint8_t *model_data = this->model_start_; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (this->model_copy_ != nullptr) + model_data = this->model_copy_.get(); +#endif this->interpreter_ = - make_unique(tflite::GetModel(this->model_start_), this->streaming_op_resolver_, + make_unique(tflite::GetModel(model_data), this->streaming_op_resolver_, this->tensor_arena_, this->tensor_arena_size_, this->mrv_); if (this->interpreter_->AllocateTensors() != kTfLiteOk) { ESP_LOGE(TAG, "Failed to allocate tensors for the streaming model"); @@ -176,6 +207,9 @@ size_t StreamingModel::probe_arena_size_() { void StreamingModel::unload_model() { this->interpreter_.reset(); +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_copy_.reset(); +#endif RAMAllocator arena_allocator; @@ -255,11 +289,15 @@ void StreamingModel::reset_probabilities() { this->ignore_windows_ = -MIN_SLICES_BEFORE_DETECTION; } -WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only) { +WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, + const std::string &wake_word, size_t tensor_arena_size, bool default_enabled, + bool internal_only) { this->id_ = id; this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_average_size; @@ -291,6 +329,9 @@ WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr m // null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is // never loaded. this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = this->model_data_ ? this->model_data_->size() : 0; +#endif if (this->model_start_ == nullptr) { ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str()); } @@ -354,9 +395,12 @@ DetectionEvent WakeWordModel::determine_detected() { return detection_event; } -VADModel::VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { +VADModel::VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_size; diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 1cb9d6eba54..730afe32bf7 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -5,6 +5,7 @@ #include "preprocessor_settings.h" #include "model_data.h" +#include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include @@ -96,6 +97,10 @@ class StreamingModel { std::vector recent_streaming_probabilities_; const uint8_t *model_start_{nullptr}; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + size_t model_size_{0}; + RAMUniquePtr model_copy_; +#endif uint8_t *tensor_arena_{nullptr}; uint8_t *var_arena_{nullptr}; std::unique_ptr interpreter_; @@ -108,6 +113,7 @@ class WakeWordModel final : public StreamingModel { /// @brief Constructs a wake word model object with compile-time model data /// @param id (std::string) identifier for this model /// @param model_start (const uint8_t *) pointer to the start of the model's TFLite FlatBuffer + /// @param model_size (size_t) size of the compiled-in model data /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said /// @param sliding_window_average_size (size_t) the length of the sliding window computing the mean rolling /// probability @@ -115,9 +121,9 @@ class WakeWordModel final : public StreamingModel { /// @param tensor_arena_size (size_t) Size in bytes for allocating the tensor arena /// @param default_enabled (bool) If true, it will be enabled by default on first boot /// @param internal_only (bool) If true, the model will not be exposed to HomeAssistant as an available model - WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only); + WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, const std::string &wake_word, + size_t tensor_arena_size, bool default_enabled, bool internal_only); /// @brief Constructs a wake word model object with a runtime-downloaded model /// @param id (std::string) identifier for this model @@ -173,8 +179,8 @@ class WakeWordModel final : public StreamingModel { class VADModel final : public StreamingModel { public: - VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); void log_model_config() override; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 730e17fb53a..c4c20e80a6d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -523,6 +523,7 @@ #ifdef USE_ESP32 #define USE_MICRO_WAKE_WORD +#define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index 9ac1056ba49..2b9df607c87 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -13,6 +13,7 @@ microphone: micro_wake_word: microphone: echo_microphone task_stack_in_psram: true + vad: on_wake_word_detected: - logger.log: "Wake word detected" - micro_wake_word.stop: diff --git a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml new file mode 100644 index 00000000000..bddab918c2a --- /dev/null +++ b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + mic_din_pin: GPIO36 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + micro_wake_word: !include common.yaml + +micro_wake_word: + prefer_internal_ram: true diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index a1b7b754236..8806d8046d9 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -3,5 +3,7 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + micro_wake_word: !include common.yaml -<<: !include common.yaml +micro_wake_word: + prefer_internal_ram: true From 61d98534f64ec63ecea0c6bf919b13aba3f16c70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 11:56:21 -1000 Subject: [PATCH 077/651] [uart] Stop ESP8266 software serial RX ISR from blocking for a whole byte (#18519) --- .../uart/software_serial_rx_decoder.h | 179 +++++++++++ .../uart/uart_component_esp8266.cpp | 75 +++-- .../components/uart/uart_component_esp8266.h | 21 +- .../uart/test_software_serial_rx_decoder.cpp | 285 ++++++++++++++++++ 4 files changed, 533 insertions(+), 27 deletions(-) create mode 100644 esphome/components/uart/software_serial_rx_decoder.h create mode 100644 tests/components/uart/test_software_serial_rx_decoder.cpp diff --git a/esphome/components/uart/software_serial_rx_decoder.h b/esphome/components/uart/software_serial_rx_decoder.h new file mode 100644 index 00000000000..d80dbf1a854 --- /dev/null +++ b/esphome/components/uart/software_serial_rx_decoder.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +/// Software serial RX decoder: on_edge() counts bits from the time between edges; +/// finalize() completes a byte whose idle high tail has no closing edge. +/// Times are in any steady tick unit. +class SoftwareSerialRxDecoder { + public: + static constexpr uint8_t RX_IDLE = 0xFF; + + /// Configure framing and buffer; drops all state. Follow with reset(). + void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer, + size_t buffer_size) { + this->bit_ticks_ = bit_ticks; + this->data_bits_ = data_bits; + this->stop_bit_ = data_bits + (parity ? 1 : 0); + this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2); + this->buffer_ = buffer; + this->buffer_size_ = buffer_size; + this->in_pos_ = 0; + this->out_pos_ = 0; + this->reset(0, true); + } + + /// Forget any partial frame; `level` is the current line level. + void reset(uint32_t now, bool level) { + this->bit_ = RX_IDLE; + this->cur_byte_ = 0; + this->last_level_ = level; + this->last_edge_ = now; + } + + /// ISR. True to wake the loop: a byte was pushed, or the line went high in an + /// open frame (a data 1 and the idle tail look alike) so finalize() may be needed. + bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) { + const bool last_level = this->last_level_; + // Collapsed edge + if (level == last_level) + return false; + // LX106 has no divider, so count + uint32_t delta = now - this->last_edge_; + if (delta > this->max_run_ticks_) + delta = this->max_run_ticks_; + delta += this->bit_ticks_ / 2; + uint32_t bits = 0; + while (delta >= this->bit_ticks_) { + delta -= this->bit_ticks_; + bits++; + } + const bool pushed = this->consume_run_(bits, last_level); + this->last_edge_ = now; + this->last_level_ = level; + return pushed || (level && this->bit_ != RX_IDLE); + } + + bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; } + + bool finalize_due(uint32_t now) const { + const uint8_t bit = this->bit_; + if (bit == RX_IDLE) + return false; + return now - this->last_edge_ >= this->tail_ticks_(bit); + } + + /// Call with the ISR masked. + void finalize(uint32_t now) { + if (!this->last_level_ || !this->finalize_due(now)) + return; + // Remaining bits are all ones + const uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + if (bit < this->data_bits_) + cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1); + this->push_byte(cur); + this->bit_ = RX_IDLE; + } + + /// Drops the byte when full; holds buffer_size - 1 bytes. + bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) { + size_t in = this->in_pos_; + size_t next = in + 1; + if (next == this->buffer_size_) + next = 0; + if (next == this->out_pos_) + return false; + this->buffer_[in] = data; + this->in_pos_ = next; + return true; + } + + uint8_t *buffer() const { return this->buffer_; } + size_t buffer_size() const { return this->buffer_size_; } + + size_t available() const { + // Read volatile in_pos_ once to avoid TOCTOU race with ISR. + size_t in = this->in_pos_; + if (in >= this->out_pos_) + return in - this->out_pos_; + return this->buffer_size_ - this->out_pos_ + in; + } + uint8_t peek_byte() const { + if (this->in_pos_ == this->out_pos_) + return 0; + return this->buffer_[this->out_pos_]; + } + uint8_t read_byte() { + if (this->in_pos_ == this->out_pos_) + return 0; + uint8_t data = this->buffer_[this->out_pos_]; + size_t next = this->out_pos_ + 1; + this->out_pos_ = next == this->buffer_size_ ? 0 : next; + return data; + } + + protected: + uint32_t tail_ticks_(uint8_t bit) const { + return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2; + } + + bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) { + uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + bool pushed = false; + while (bits > 0) { + if (bit == RX_IDLE) { + if (level) + break; + // Start bit + bit = 0; + cur = 0; + bits--; + } else if (bit < this->data_bits_) { + uint8_t n = this->data_bits_ - bit; + if (n > bits) + n = bits; + if (level) + cur |= ((1U << n) - 1) << bit; + bit += n; + bits -= n; + } else if (bit < this->stop_bit_) { + // Parity, not checked + bit++; + bits--; + } else { + // Stop bit; low is a framing error + if (level) + pushed = this->push_byte(cur); + bit = RX_IDLE; + break; + } + } + this->bit_ = bit; + this->cur_byte_ = cur; + return pushed; + } + + // Members ordered largest to smallest to minimize padding + uint32_t bit_ticks_{0}; + uint32_t max_run_ticks_{0}; + volatile uint32_t last_edge_{0}; + uint8_t *buffer_{nullptr}; + size_t buffer_size_{0}; + volatile size_t in_pos_{0}; + size_t out_pos_{0}; // reader only + /// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE. + volatile uint8_t bit_{RX_IDLE}; + volatile uint8_t cur_byte_{0}; + volatile bool last_level_{true}; + uint8_t data_bits_{8}; + uint8_t stop_bit_{8}; +}; + +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index a32677b3daa..b81c0af85f1 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -16,6 +16,12 @@ namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); +// Edge decoder up to this baud rate; at 38400 WiFi interrupt latency corrupts bytes. +static constexpr uint32_t SW_SERIAL_EDGE_MODE_MAX_BAUD = 19200; +// Edge decoder time base: 1/16 us from the 1 MHz system timer, which keeps its rate during a +// CpuFrequencyBoost; the cycle counter does not. +static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000; +__attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; } bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint32_t ESP8266UartComponent::get_config() { @@ -158,6 +164,11 @@ void ESP8266UartComponent::dump_config() { "\n Wake on data RX: ENABLED" #endif ); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX decoder: %s", + this->baud_rate_ <= SW_SERIAL_EDGE_MODE_MAX_BAUD ? LOG_STR_LITERAL("edge") + : LOG_STR_LITERAL("start bit sampler")); + } } this->check_logger_conflict(); } @@ -239,8 +250,10 @@ UARTFlushResult ESP8266UartComponent::flush() { void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity, size_t rx_buffer_size) { + // load_settings() re-enters here + if (this->gpio_rx_pin_ != nullptr) + this->gpio_rx_pin_->detach_interrupt(); this->bit_time_ = F_CPU / baud_rate; - this->rx_buffer_size_ = rx_buffer_size; this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; @@ -254,8 +267,19 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); rx_pin_ = gpio_rx_pin_->to_isr(); - rx_buffer_ = new uint8_t[this->rx_buffer_size_]; // NOLINT - gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + uint8_t *buffer = this->rx_.buffer(); + if (this->rx_.buffer_size() != rx_buffer_size) { + delete[] buffer; // NOLINT + buffer = new uint8_t[rx_buffer_size]; // NOLINT + } + this->rx_.setup((RX_TICKS_PER_SECOND + baud_rate / 2) / baud_rate, data_bits, parity != UART_CONFIG_PARITY_NONE, + stop_bits, buffer, rx_buffer_size); + this->rx_.reset(rx_now(), this->rx_pin_.digital_read()); + if (baud_rate <= SW_SERIAL_EDGE_MODE_MAX_BAUD) { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr_edge, this, gpio::INTERRUPT_ANY_EDGE); + } else { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + } } } // A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles @@ -288,8 +312,7 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { if (arg->stop_bits_ == 2) arg->wait_(&wait, start, bit_time); - arg->rx_buffer_[arg->rx_in_pos_] = rec; - arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; + arg->rx_.push_byte(rec); // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); #ifdef USE_UART_WAKE_LOOP_ON_RX @@ -299,6 +322,27 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { wake_loop_isrsafe(); #endif } +void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr_edge(ESP8266SoftwareSerial *arg) { + const uint32_t now = rx_now(); + const bool level = arg->rx_pin_.digital_read(); + [[maybe_unused]] const bool wake = arg->rx_.on_edge(now, level); +#ifdef USE_UART_WAKE_LOOP_ON_RX + if (wake) + wake_loop_isrsafe(); +#endif +} +void ESP8266SoftwareSerial::rx_finalize_pending_() { + if (!this->rx_.finalize_due(rx_now())) { +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Tail not elapsed; wake again only once drained + if (this->rx_.available() == 0) + wake_loop_threadsafe(); +#endif + return; + } + InterruptLock lock; + this->rx_.finalize(rx_now()); +} void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { ESP_LOGE(TAG, "UART doesn't have TX pins set!"); @@ -350,28 +394,19 @@ void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const this->wait_(wait, start, bit_time); } uint8_t ESP8266SoftwareSerial::read_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - uint8_t data = this->rx_buffer_[this->rx_out_pos_]; - this->rx_out_pos_ = (this->rx_out_pos_ + 1) % this->rx_buffer_size_; - return data; + this->rx_sync_(); + return this->rx_.read_byte(); } uint8_t ESP8266SoftwareSerial::peek_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - return this->rx_buffer_[this->rx_out_pos_]; + this->rx_sync_(); + return this->rx_.peek_byte(); } void ESP8266SoftwareSerial::flush() { // Flush is a NO-OP with software serial, all bytes are written immediately. } size_t ESP8266SoftwareSerial::available() { - // Read volatile rx_in_pos_ once to avoid TOCTOU race with ISR. - // When in >= out, data is contiguous: [out..in). - // When in < out, data wraps: [out..buf_size) + [0..in). - size_t in = this->rx_in_pos_; - if (in >= this->rx_out_pos_) - return in - this->rx_out_pos_; - return this->rx_buffer_size_ - this->rx_out_pos_ + in; + this->rx_sync_(); + return this->rx_.available(); } } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 0098e752b2d..3d4a2ba85bc 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -7,6 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "software_serial_rx_decoder.h" #include "uart_component.h" namespace esphome::uart { @@ -26,24 +27,30 @@ class ESP8266SoftwareSerial { size_t available(); protected: + /// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR. static void gpio_intr(ESP8266SoftwareSerial *arg); + static void gpio_intr_edge(ESP8266SoftwareSerial *arg); void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void rx_finalize_pending_(); + void ESPHOME_ALWAYS_INLINE rx_sync_() { + if (this->rx_.pending()) + this->rx_finalize_pending_(); + } + + // Members ordered largest to smallest to minimize padding uint32_t bit_time_{0}; - uint8_t *rx_buffer_{nullptr}; - size_t rx_buffer_size_; - volatile size_t rx_in_pos_{0}; - size_t rx_out_pos_{0}; - uint8_t stop_bits_; - uint8_t data_bits_; - UARTParityOptions parity_; InternalGPIOPin *gpio_tx_pin_{nullptr}; ISRInternalGPIOPin tx_pin_; InternalGPIOPin *gpio_rx_pin_{nullptr}; ISRInternalGPIOPin rx_pin_; + SoftwareSerialRxDecoder rx_; + UARTParityOptions parity_; + uint8_t stop_bits_; + uint8_t data_bits_; }; class ESP8266UartComponent final : public UARTComponent, public Component { diff --git a/tests/components/uart/test_software_serial_rx_decoder.cpp b/tests/components/uart/test_software_serial_rx_decoder.cpp new file mode 100644 index 00000000000..b65e8a659fd --- /dev/null +++ b/tests/components/uart/test_software_serial_rx_decoder.cpp @@ -0,0 +1,285 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/uart/software_serial_rx_decoder.h" + +namespace esphome::uart::testing { + +namespace { + +// Firmware time base: micros() << 4 +constexpr uint32_t TICKS_PER_SECOND = 16000000; +constexpr uint32_t TICKS_PER_US = 16; + +// Simulated line: frames become edges at exact baud timing plus jitter, truncated +// to whole microseconds like micros(); poll() emulates the main loop. + +class LineSim { + public: + LineSim(uint32_t baud, uint8_t data_bits, bool parity, bool odd, uint8_t stop_bits, size_t buffer_size = 64) + : data_bits_(data_bits), parity_(parity), odd_(odd), stop_bits_(stop_bits), buffer_(buffer_size) { + this->bit_ = (TICKS_PER_SECOND + baud / 2) / baud; + this->exact_bit_ = static_cast(TICKS_PER_SECOND) / baud; + this->dec_.setup(this->bit_, data_bits, parity, stop_bits, this->buffer_.data(), buffer_size); + this->dec_.reset(this->now_, true); + } + + uint32_t bit_ticks() const { return this->bit_; } + SoftwareSerialRxDecoder &decoder() { return this->dec_; } + const std::vector &received() const { return this->received_; } + + void edge(uint32_t at, bool level) { + this->now_ = at; + this->line_ = level; + this->dec_.on_edge(at, level); + } + + // Main loop pass at cycle `at`. + void poll(uint32_t at) { + this->now_ = at; + if (this->dec_.pending() && this->dec_.finalize_due(at)) + this->dec_.finalize(at); + while (this->dec_.available() > 0) + this->received_.push_back(this->dec_.read_byte()); + } + + // Emit one frame at `start` with per edge `jitter` in ticks; returns the end tick. + uint32_t send( + uint8_t value, uint32_t start, const std::function &jitter = [] { return 0; }) { + std::vector bits; + bits.push_back(false); + int ones = 0; + for (int i = 0; i < this->data_bits_; i++) { + bool b = (value >> i) & 1; + bits.push_back(b); + ones += b; + } + if (this->parity_) + bits.push_back(this->odd_ ? !(ones & 1) : (ones & 1)); + for (int i = 0; i < this->stop_bits_; i++) + bits.push_back(true); + double t = start; + for (bool b : bits) { + if (b != this->line_) { + const auto at = static_cast(static_cast(t) + jitter()); + this->edge(at / TICKS_PER_US * TICKS_PER_US, b); + } + t += this->exact_bit_; + } + return static_cast(t); + } + + protected: + uint8_t data_bits_; + bool parity_; + bool odd_; + uint8_t stop_bits_; + uint32_t bit_{0}; + double exact_bit_{0}; + uint32_t now_{1000}; + bool line_{true}; + std::vector buffer_; + std::vector received_; + SoftwareSerialRxDecoder dec_; +}; + +struct FrameFormat { + uint32_t baud; + uint8_t data_bits; + bool parity; + bool odd; + uint8_t stop_bits; +}; + +// Random bytes, back to back or with idle gaps, with bounded edge jitter. +void run_stream(const FrameFormat &f, double jitter_bits, uint32_t gap_bits, int count, unsigned seed) { + LineSim sim(f.baud, f.data_bits, f.parity, f.odd, f.stop_bits); + std::mt19937 rng(seed); + std::uniform_real_distribution jit(-jitter_bits, jitter_bits); + const uint8_t mask = static_cast((1U << f.data_bits) - 1); + std::vector sent; + uint32_t t = 5000; + for (int n = 0; n < count; n++) { + uint8_t b = rng() & mask; + sent.push_back(b); + t = sim.send(b, t, [&] { return static_cast(jit(rng) * sim.bit_ticks()); }); + if (gap_bits != 0 && n % 3 == 2) { + t += gap_bits * sim.bit_ticks(); + sim.poll(t); + } else if (rng() % 4 == 0) { + sim.poll(t); + } + } + sim.poll(t + 20 * sim.bit_ticks()); + EXPECT_EQ(sim.received(), sent) << "baud " << f.baud << " jitter " << jitter_bits; +} + +} // namespace + +TEST(SoftwareSerialRxDecoder, DecodesBackToBackFramesAtCommonBaudRates) { + for (uint32_t baud : {2400U, 4800U, 9600U, 19200U, 38400U}) { + run_stream({baud, 8, false, false, 1}, 0.0, 0, 300, baud); + } +} + +TEST(SoftwareSerialRxDecoder, ToleratesEdgeJitterUpToAQuarterBit) { + // A quarter bit per edge keeps each run within the half bit rounding budget. + run_stream({9600, 8, false, false, 1}, 0.24, 0, 2000, 1); + run_stream({9600, 8, false, false, 1}, 0.24, 7, 2000, 2); + run_stream({19200, 8, false, false, 1}, 0.24, 4, 2000, 3); // edge decoder limit +} + +TEST(SoftwareSerialRxDecoder, HandlesParityDataBitsAndStopBits) { + run_stream({9600, 8, true, false, 1}, 0.2, 3, 1000, 4); // 8E1 + run_stream({4800, 8, true, true, 2}, 0.2, 2, 1000, 5); // 8O2 + run_stream({19200, 7, false, false, 2}, 0.2, 5, 1000, 6); // 7N2 + run_stream({2400, 5, false, false, 1}, 0.2, 1, 1000, 7); // 5N1 +} + +TEST(SoftwareSerialRxDecoder, AllOnesByteCompletesOnlyByFinalize) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + // 0xFF: start bit, then the line stays high with no closing edge. + sim.edge(t, false); + sim.edge(t + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.poll(t + bit + 8 * bit); + EXPECT_TRUE(sim.received().empty()); + EXPECT_FALSE(sim.decoder().finalize_due(t + bit + 8 * bit)); + sim.poll(t + bit + 10 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xFF); + EXPECT_FALSE(sim.decoder().pending()); +} + +TEST(SoftwareSerialRxDecoder, LastByteOfBurstIsFinalizedThenNextFrameDecodes) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = sim.send(0xA5, 5000); + t = sim.send(0xF0, t); // ends high, needs finalize + EXPECT_TRUE(sim.received().empty()); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 2u); + EXPECT_EQ(sim.received()[0], 0xA5); + EXPECT_EQ(sim.received()[1], 0xF0); + // The stale last edge must not confuse the next start bit. + t = sim.send(0x3C, t + 50 * bit); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 3u); + EXPECT_EQ(sim.received()[2], 0x3C); +} + +TEST(SoftwareSerialRxDecoder, BreakConditionIsDroppedAndResyncs) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + sim.edge(t, false); + t += 25 * bit; // line held low for far longer than a frame + sim.edge(t, true); + t += 3 * bit; + sim.poll(t); + EXPECT_TRUE(sim.received().empty()); + t = sim.send(0xA5, t); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xA5); +} + +TEST(SoftwareSerialRxDecoder, CollapsedEdgeIsIgnoredAndStreamRealignsAtIdle) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + // 0x31: lose the rising edge of bit 4, so the next edge repeats the last level. + uint32_t t = 5000; + sim.edge(t, false); // start + sim.edge(t + 1 * bit, true); // bit0 = 1 + sim.edge(t + 2 * bit, false); // bits1..3 = 0 + sim.decoder().on_edge(t + 7 * bit, false); // should have been bit6 falling edge; level still low + sim.edge(t + 9 * bit, true); // stop + t += 10 * bit; + // Next frame decodes correctly once the line has idled. + t = sim.send(0x5A, t + 12 * bit); + sim.poll(t + 2 * bit); + ASSERT_FALSE(sim.received().empty()); + EXPECT_EQ(sim.received().back(), 0x5A); +} + +TEST(SoftwareSerialRxDecoder, DropsBytesWhenBufferIsFullAndKeepsOldest) { + LineSim sim(9600, 8, false, false, 1, 8); + uint32_t t = 5000; + for (int n = 0; n < 20; n++) + t = sim.send(static_cast(n), t); + sim.poll(t + 20 * sim.bit_ticks()); + ASSERT_EQ(sim.received().size(), 7u); // capacity is size - 1 + for (int n = 0; n < 7; n++) + EXPECT_EQ(sim.received()[n], n); +} + +TEST(SoftwareSerialRxDecoder, SetupAgainDropsStaleStateAndUsesNewBufferAndFraming) { + // Mirrors load_settings(): buffered 8N1 bytes and an open frame, then setup() as 5E2. + LineSim sim(9600, 8, false, false, 1); + uint32_t t = 5000; + for (int n = 0; n < 10; n++) + t = sim.send(static_cast(0x40 + n), t); + sim.edge(t + 8 * sim.bit_ticks(), false); // open a frame, never closed + SoftwareSerialRxDecoder &dec = sim.decoder(); + ASSERT_GE(dec.available(), 9u); + + std::vector small(4, 0xEE); + const uint32_t bit = TICKS_PER_SECOND / 2400; + dec.setup(bit, 5, true, 2, small.data(), small.size()); + EXPECT_EQ(dec.available(), 0u); + EXPECT_FALSE(dec.pending()); + EXPECT_EQ(dec.read_byte(), 0); + + // Capacity of the 4 byte buffer is 3; the spare slot must stay untouched. + auto send_5e2 = [&](uint8_t value, uint32_t start) { + bool line = true; + uint32_t at = start; + auto put = [&](bool b) { + if (b != line) { + dec.on_edge(at, b); + line = b; + } + at += bit; + }; + put(false); + int ones = 0; + for (int i = 0; i < 5; i++) { + bool b = (value >> i) & 1; + ones += b; + put(b); + } + put(ones & 1); + put(true); + put(true); + return at; + }; + uint32_t t2 = 5000; + for (int n = 1; n <= 6; n++) + t2 = send_5e2(static_cast(n), t2); + dec.finalize(t2 + 20 * bit); + ASSERT_EQ(dec.available(), 3u); + EXPECT_EQ(dec.read_byte(), 1); + EXPECT_EQ(dec.read_byte(), 2); + EXPECT_EQ(dec.read_byte(), 3); + EXPECT_EQ(small[3], 0xEE); // capacity slot is never written +} + +TEST(SoftwareSerialRxDecoder, ResetDiscardsPartialFrame) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + sim.edge(5000, false); + sim.edge(5000 + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.decoder().reset(5000 + 2 * bit, true); + EXPECT_FALSE(sim.decoder().pending()); + sim.poll(5000 + 30 * bit); + EXPECT_TRUE(sim.received().empty()); +} + +} // namespace esphome::uart::testing From 4155b82f84dd6d6035355658dd627cb5b1221c0a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:58:31 -0400 Subject: [PATCH 078/651] [usb_uart] Fix invalid stop bits and parity enum mappings (#20408) --- esphome/components/uart/uart.cpp | 4 +- esphome/components/uart/uart_component.h | 3 ++ .../components/uart/uart_component_host.cpp | 2 + .../uart/uart_component_libretiny.cpp | 2 + esphome/components/usb_uart/__init__.py | 8 ++- esphome/components/usb_uart/ch34x.cpp | 13 ++--- esphome/components/usb_uart/cp210x.cpp | 4 +- esphome/components/usb_uart/ft23xx.cpp | 30 +---------- esphome/components/usb_uart/pl2303.cpp | 29 +--------- esphome/components/usb_uart/usb_uart.cpp | 50 +++++++++++++---- esphome/components/usb_uart/usb_uart.h | 53 +++++++++---------- 11 files changed, 91 insertions(+), 107 deletions(-) diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 20f6385b6a7..56b8e6401e8 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -10,8 +10,8 @@ namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); -// UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD -PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN"); +// UART parity strings indexed by UARTParityOptions enum (0-4): NONE, EVEN, ODD, MARK, SPACE +PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "MARK", "SPACE", "UNKNOWN"); void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity, uint8_t data_bits) { diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 2543cb54768..6d446fd6a6f 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -18,6 +18,9 @@ enum UARTParityOptions { UART_CONFIG_PARITY_NONE, UART_CONFIG_PARITY_EVEN, UART_CONFIG_PARITY_ODD, + // Only USB serial adapters (usb_uart) support these + UART_CONFIG_PARITY_MARK, + UART_CONFIG_PARITY_SPACE, }; #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 9e2f273f47c..7fb704536dd 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -158,6 +158,8 @@ void HostUartComponent::setup() { options.c_cflag |= PARENB; options.c_cflag |= PARODD; break; + default: + break; }; // Set stop bits if (this->stop_bits_ == 2) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 5a77bf26d9e..802a88ba80d 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -38,6 +38,8 @@ uint16_t LibreTinyUARTComponent::get_config() { case UART_CONFIG_PARITY_ODD: config |= SERIAL_PARITY_ODD; break; + default: + break; } config |= (this->data_bits_ - 4) << 8; diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 2898576b323..581727b326a 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,7 +1,12 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant -from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent +from esphome.components.uart import ( + CONF_DEBUG_PREFIX, + CONF_FLUSH_TIMEOUT, + UARTComponent, + UARTParityOptions, +) from esphome.components.usb_host import ( get_max_packet_size, register_usb_client, @@ -39,7 +44,6 @@ def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool: ) -UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 00c5e0b0694..ed5a034b82f 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -132,15 +132,12 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo factor = 256 - factor; uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + if (channel->line_stop_bits_() == LineStopBits::LINE_STOP_BITS_2) value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; - } + // Parity enable, then 0 = odd, 1 = even, 2 = mark, 3 = space + const LineParity parity = channel->line_parity_(); + if (parity != LineParity::LINE_PARITY_NONE) + value |= 8 | ((static_cast(parity) - 1) << 4); value |= channel->data_bits_ - 5; value <<= 8; value |= 0x8C; diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5551abe1a1d..e920da6c267 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -107,8 +107,8 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); return true; case 1: { - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; + uint16_t line_control = static_cast(channel->line_stop_bits_()); + line_control |= static_cast(channel->line_parity_()) << 4; line_control |= channel->data_bits_ << 8; ESP_LOGD(TAG, "Line control value 0x%X", line_control); this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index fcebf0fbd95..e10da78aaf0 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -362,34 +362,8 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b } case 2: { // set line properties (data bits / parity / stop bits) uint16_t value = channel->data_bits_; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - switch (channel->stop_bits_) { - default: // 1 bit - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } + value |= static_cast(channel->line_parity_()) << 8; + value |= static_cast(channel->line_stop_bits_()) << 11; value |= (0x00 << 14); this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, channel->cdc_dev_.bulk_interface_number + 1); diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index db177fd308d..36107c093c4 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -261,34 +261,9 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b encode_baud_divisor(line_coding, baud); } - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } + line_coding[4] = static_cast(channel->line_stop_bits_()); - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } + line_coding[5] = static_cast(channel->line_parity_()); // Data bits line_coding[6] = channel->get_data_bits(); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 86dfa6d729f..f61642566cb 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -270,9 +270,10 @@ void USBUartComponent::dump_config() { " Flush Timeout: %" PRIu32 " ms\n" " Debug: %s\n" " Dummy receiver: %s", - channel->index_, channel->baud_rate_, channel->data_bits_, PARITY_NAMES[channel->parity_], - STOP_BITS_NAMES[channel->stop_bits_], channel->flush_timeout_ms_, YESNO(channel->debug_), - YESNO(channel->dummy_receiver_)); + channel->index_, channel->baud_rate_, channel->data_bits_, + PARITY_NAMES[static_cast(channel->line_parity_())], + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], channel->flush_timeout_ms_, + YESNO(channel->debug_), YESNO(channel->dummy_receiver_)); } } void USBUartComponent::start_input(USBUartChannelBase *channel) { @@ -515,14 +516,17 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // sends RSTACK. uint32_t baud = channel->baud_rate_; std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits + static_cast(baud & 0xFF), + static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), + static_cast((baud >> 24) & 0xFF), + static_cast(channel->line_stop_bits_()), // bCharFormat + static_cast(channel->line_parity_()), // bParityType + static_cast(channel->data_bits_), // bDataBits }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%s parity=%s data=%u", (unsigned) baud, + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], + PARITY_NAMES[static_cast(channel->line_parity_())], channel->data_bits_); this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, line_coding); return true; @@ -675,6 +679,32 @@ bool USBUartComponent::run_config_machine_() { return true; } +LineStopBits USBUartChannelBase::line_stop_bits_() const { + switch (this->stop_bits_) { + case UART_CONFIG_STOP_BITS_1_5: + return LineStopBits::LINE_STOP_BITS_1_5; + case UART_CONFIG_STOP_BITS_2: + return LineStopBits::LINE_STOP_BITS_2; + default: + return LineStopBits::LINE_STOP_BITS_1; + } +} + +LineParity USBUartChannelBase::line_parity_() const { + switch (this->parity_) { + case uart::UART_CONFIG_PARITY_ODD: + return LineParity::LINE_PARITY_ODD; + case uart::UART_CONFIG_PARITY_EVEN: + return LineParity::LINE_PARITY_EVEN; + case uart::UART_CONFIG_PARITY_MARK: + return LineParity::LINE_PARITY_MARK; + case uart::UART_CONFIG_PARITY_SPACE: + return LineParity::LINE_PARITY_SPACE; + default: + return LineParity::LINE_PARITY_NONE; + } +} + void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index c6ae0b4eef5..4e52fb60e20 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -71,21 +71,33 @@ enum CH34xChipType : uint8_t { CHIP_UNKNOWN = 0xFF, }; -enum UARTParityOptions { - UART_CONFIG_PARITY_NONE = 0, - UART_CONFIG_PARITY_ODD, - UART_CONFIG_PARITY_EVEN, - UART_CONFIG_PARITY_MARK, - UART_CONFIG_PARITY_SPACE, -}; - +// 1 and 2 are the stop bit count the uart component uses, so a count set through the +// UARTComponent API (as serial_proxy does) means the same here. 1.5 has no count. enum UARTStopBitsOptions { - UART_CONFIG_STOP_BITS_1 = 0, - UART_CONFIG_STOP_BITS_1_5, - UART_CONFIG_STOP_BITS_2, + UART_CONFIG_STOP_BITS_1 = 1, + UART_CONFIG_STOP_BITS_2 = 2, + UART_CONFIG_STOP_BITS_1_5 = 3, }; +// Stop bits and parity as adapters take them on the wire: CDC SET_LINE_CODING, and the +// CP210x, FTDI and PL2303 requests, all use these values +enum class LineStopBits : uint8_t { + LINE_STOP_BITS_1 = 0, + LINE_STOP_BITS_1_5 = 1, + LINE_STOP_BITS_2 = 2, +}; + +enum class LineParity : uint8_t { + LINE_PARITY_NONE = 0, + LINE_PARITY_ODD = 1, + LINE_PARITY_EVEN = 2, + LINE_PARITY_MARK = 3, + LINE_PARITY_SPACE = 4, +}; + +// Indexed by LineParity static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE"}; +// Indexed by LineStopBits static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; class RingBuffer { @@ -156,22 +168,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; - // Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value. - // MARK/SPACE have no uart:: equivalent and report as NONE. - switch (parity) { - case UART_CONFIG_PARITY_EVEN: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN); - break; - case UART_CONFIG_PARITY_ODD: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD); - break; - default: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE); - break; - } - } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } @@ -194,6 +190,8 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented output_queue_; @@ -205,7 +203,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented input_started_{true}; From 94894d60b77e36f7c6e72dc74116b175fbbb7b4b Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 12 Oct 2026 00:15:23 +0200 Subject: [PATCH 079/651] [nrf52] fix zigbee time component (#20527) Co-authored-by: Claude --- esphome/components/time/__init__.py | 8 +++++++ esphome/components/zigbee/time/__init__.py | 2 ++ .../zigbee/time/zigbee_time_zephyr.cpp | 22 ++++++++++++++----- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index a017109e7d3..2ad3cd9c314 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -26,6 +26,8 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -455,6 +457,12 @@ async def register_time(time_var, config): async def to_code(config): if CORE.using_zephyr: zephyr_add_prj_conf("POSIX_CLOCK", True) + # From sdk-nrf 2.8.0 newlib time() gets the clock through gettimeofday(), + # which Zephyr only provides with XSI_SINGLE_PROCESS. Without it time() + # always returns -1. + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 8, 0): + zephyr_add_prj_conf("POSIX_SINGLE_PROCESS", True) + zephyr_add_prj_conf("XSI_SINGLE_PROCESS", True) cg.add_define("USE_TIME") cg.add_global(time_ns.using) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 74f81df2e96..0572e591cfc 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -14,6 +14,7 @@ from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, get_slot_index, + zigbee_assign, zigbee_new_attr_list, zigbee_new_cluster_list, zigbee_new_variable, @@ -81,6 +82,7 @@ async def _add_time_zephyr(config: ConfigType) -> None: # Create the binary attributes structure time_attrs = zigbee_new_variable(attrs_name, "zb_zcl_time_attrs_t") + zigbee_assign(time_attrs.time, cg.RawExpression("ZB_ZCL_TIME_TIME_INVALID_VALUE")) attr_list = zigbee_new_attr_list( attr_list_name, "ZB_ZCL_DECLARE_TIME_ATTR_LIST", diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 7f0a5f979ce..0de0ee0ee48 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,6 +1,7 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" +#include namespace esphome::zigbee { @@ -9,12 +10,14 @@ ESPHOME_LOG_TAG(TAG, "zigbee.time"); // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). constexpr time_t EPOCH_2000 = 946684800; +// Largest ZCL time that still fits the 32-bit epoch. Also rejects ZB_ZCL_TIME_TIME_INVALID_VALUE. +constexpr uint32_t MAX_ZCL_TIME = std::numeric_limits::max() - EPOCH_2000; ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t short_addr, zb_uint8_t endpoint, zb_uint32_t nw_time) { - if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT) { + if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT && nw_time <= MAX_ZCL_TIME) { global_time->set_epoch_time(nw_time + EPOCH_2000); } else if (status != RET_TIMEOUT || !global_time->has_time_) { ESP_LOGE(TAG, "Status: %d, auth_level: %u, short_addr: %d, endpoint: %d, nw_time: %u", status, auth_level, @@ -25,7 +28,6 @@ void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t void ZigbeeTime::setup() { global_time = this; this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); - synchronize_epoch_(EPOCH_2000); this->parent_->add_on_join_callback([this](bool x) { zb_zcl_time_server_synchronize(this->endpoint_, sync_time); }); } @@ -38,8 +40,12 @@ void ZigbeeTime::dump_config() { } void ZigbeeTime::update() { - time_t time = timestamp_now(); - this->cluster_attributes_->time = time - EPOCH_2000; + const time_t now = this->timestamp_now(); + if (now < EPOCH_2000) { + this->cluster_attributes_->time = ZB_ZCL_TIME_TIME_INVALID_VALUE; + } else { + this->cluster_attributes_->time = static_cast(now - EPOCH_2000); + } } void ZigbeeTime::set_epoch_time(uint32_t epoch) { @@ -61,8 +67,12 @@ void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { if (cluster_id == ZB_ZCL_CLUSTER_ID_TIME) { if (attr_id == ZB_ZCL_ATTR_TIME_TIME_ID) { zb_uint32_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data32; - ESP_LOGI(TAG, "Synchronize time to %u", value); - this->defer([this, value]() { synchronize_epoch_(value + EPOCH_2000); }); + if (value <= MAX_ZCL_TIME) { + ESP_LOGI(TAG, "Synchronize time to %u", value); + this->defer([this, value]() { this->synchronize_epoch_(value + EPOCH_2000); }); + } else { + ESP_LOGW(TAG, "Ignoring invalid time %u", value); + } } else if (attr_id == ZB_ZCL_ATTR_TIME_TIME_STATUS_ID) { zb_uint8_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data8; ESP_LOGI(TAG, "Time status %hd", value); From 96eaf423366847d8e1e9380c36785950352420fd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:22:29 +1300 Subject: [PATCH 080/651] [api] Let a wizard input pick multiple entities (#20537) --- esphome/components/api/api.proto | 21 +++- esphome/components/api/api_wizard.cpp | 56 +++++++++-- esphome/components/api/api_wizard.h | 35 ++++++- esphome/components/api/wizard.py | 55 +++++++++-- esphome/core/defines.h | 1 + tests/component_tests/api/test_wizard.py | 118 ++++++++++++++++++++++- tests/components/api/__init__.py | 3 +- tests/components/api/common-wizard.yaml | 20 ++++ tests/components/api/test_wizard.cpp | 73 ++++++++++++++ 9 files changed, 360 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d4ae82fd9f1..1b06348e2ce 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -444,7 +444,7 @@ message DeviceWizardRequest { // {"version": 1, // "pages": [{"title": "...", "description": "...", // "entities": [{"key": 123, "device_id": 456, "description": "..."}], -// "inputs": [{"key": 789, "description": "...", +// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, // "entity_filters": [{"integration": "...", "domain": ["..."], // "device_class": ["..."], "supported_features": ["..."]}]}]}]} // @@ -468,6 +468,14 @@ message DeviceWizardRequest { // an entity matches a filter when it satisfies every field that is set, and an entity // that matches any one of the filters is accepted. // +// An input with "multiple": true lets the user pick several entities. The client sends +// them in one WizardInputSetRequest as a comma separated list with no spaces +// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the +// list without them. "max_length" (only on a multiple input, 255 when left out) is the +// longest list in bytes the device accepts. The version stays 1: a client that does not +// know "multiple" ignores it, shows a single picker and sends one entity id, which a +// multiple input accepts. +// // The device keeps the entity id of an input in RAM only and does not store it across // restarts. The client therefore stores the choice itself and, after every connection // and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest @@ -483,8 +491,13 @@ message DeviceWizardResponse { // Sets the entity id of a wizard input (see WizardInputField.key). Clients send // this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no -// reply. The device ignores a request whose key matches no input or whose entity_id -// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM, +// reply. The device ignores a request whose key matches no input. A single input is +// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple +// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no +// spaces, the device trims spaces around each id and stores the list without them. The +// whole request is ignored when any id is empty or has no '.', or when the list without +// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id +// or list in RAM, // not across restarts, and when the input belongs to a homeassistant platform entity // it subscribes to that entity's state. Clients send this for every input right // after connecting, before SubscribeHomeAssistantStatesRequest. A request that @@ -496,7 +509,7 @@ message WizardInputSetRequest { option (ifdef) = "USE_API_WIZARD_INPUTS"; fixed32 key = 1; - string entity_id = 2 [(max_data_length) = 255]; + string entity_id = 2 [(max_data_length) = 4095]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index 10a5cbc70cc..9467efcfa0c 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -14,26 +14,68 @@ namespace esphome::api { ESPHOME_LOG_TAG(TAG, "api.wizard"); #ifdef USE_API_WIZARD_INPUTS +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined +// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr. +static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) { + size_t joined = 0; + const char *end = value + length; + while (true) { + const char *comma = static_cast(memchr(value, ',', end - value)); + const char *id_end = comma != nullptr ? comma : end; + while (value < id_end && *value == ' ') + value++; + while (id_end > value && id_end[-1] == ' ') + id_end--; + size_t id_length = id_end - value; + if (id_length == 0 || memchr(value, '.', id_length) == nullptr) + return 0; + if (joined != 0) { + if (out != nullptr) + out[joined] = ','; + joined++; + } + if (out != nullptr) + memcpy(out + joined, value, id_length); + joined += id_length; + if (comma == nullptr) + return joined; + value = comma + 1; + } +} +#endif // USE_API_WIZARD_MULTIPLE_INPUTS + static bool wizard_entity_id_valid(const char *entity_id, size_t length) { return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; } const char *wizard_set_input(const WizardInputSetRequest &msg) { - if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) { - ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input"); - return nullptr; - } + const char *value = msg.entity_id.c_str(); + const size_t length = msg.entity_id.size(); for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) { // The table is in flash, which ESP8266 can only read through progmem_memcpy WizardInputEntry entry; progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry)); if (entry.key != msg.key) continue; - memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size()); - entry.entity_id[msg.entity_id.size()] = '\0'; +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + if (entry.size != 0) { + // Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged + const size_t joined = wizard_join_entity_ids(value, length, nullptr); + if (joined == 0 || joined >= entry.size) + break; + wizard_join_entity_ids(value, length, entry.entity_id); + entry.entity_id[joined] = '\0'; + return entry.entity_id; + } +#endif + if (!wizard_entity_id_valid(value, length)) + break; + memcpy(entry.entity_id, value, length); + entry.entity_id[length] = '\0'; return entry.entity_id; } - ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input"); + ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid"); return nullptr; } #endif // USE_API_WIZARD_INPUTS diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h index 7d5a566b550..6bdc1654bcb 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -6,6 +6,9 @@ #include #include +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +#include +#endif #include "esphome/core/hal.h" #include "proto.h" @@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM; /// Where the entity id of an input is kept, found by the key the client uses for it. struct WizardInputEntry { uint32_t key; // FNV-1 hash of the ESPHome id of the input - char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity + char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + uint16_t size; // Buffer size of a multiple input, 0 for a single input +#endif }; /// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long. @@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg); class WizardInput { public: explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {} - /// The Home Assistant entity ID, empty until the wizard sets one. + /// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole + /// comma separated list, which homeassistant.action data can use as it is. StringRef entity_id() const { return StringRef(this->entity_id_); } bool has_entity_id() const { return this->entity_id_[0] != '\0'; } +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + /// The number of entity IDs in the list, 0 until the wizard sets one. + size_t entity_id_count() const { + if (!this->has_entity_id()) + return 0; + size_t count = 1; + for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++) + count++; + return count; + } + /// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is + /// not null terminated, so print it with "%.*s". + template void for_each_entity_id(F &&callback) const { + // The device stores the list without spaces or empty entries + const char *start = this->entity_id_; + while (*start != '\0') { + const char *end = strchr(start, ','); + if (end == nullptr) + end = start + strlen(start); + callback(StringRef(start, end - start)); + start = *end == ',' ? end + 1 : end; + } + } +#endif protected: const char *entity_id_; diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 3ab80a063ee..8e5c808e24c 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -20,6 +20,8 @@ from esphome.const import ( CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL, + CONF_MAX_LENGTH, + CONF_MULTIPLE, CONF_NAME, CONF_PAGES, CONF_PLATFORM, @@ -51,6 +53,10 @@ WizardInput = _API.class_("WizardInput") WIZARD_ENTITY_ID_BUFFER_SIZE = ( 256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes ) +WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1 +# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266; +# api.proto max_data_length documents the same value +WIZARD_MULTIPLE_MAX_LENGTH = 4095 # One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes, # Noise) and footer (16 bytes, Noise MAC) WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16 @@ -106,6 +112,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema( ) +def _validate_multiple(conf: ConfigType) -> ConfigType: + if conf[CONF_MULTIPLE] and CONF_ENTITY in conf: + raise cv.Invalid( + "A linked input follows the state of exactly one Home Assistant entity, so multiple " + "is only for standalone inputs (id)", + path=[CONF_MULTIPLE], + ) + if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]: + raise cv.Invalid( + "max_length only applies to an input with multiple: true", + path=[CONF_MAX_LENGTH], + ) + return conf + + # An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no # entity_id of its own: Home Assistant sets it WIZARD_INPUT_SCHEMA = cv.All( @@ -114,6 +135,10 @@ WIZARD_INPUT_SCHEMA = cv.All( cv.Optional(CONF_ID): cv.declare_id(WizardInput), cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase), cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_MULTIPLE, default=False): cv.boolean, + cv.Optional(CONF_MAX_LENGTH): cv.int_range( + min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH + ), cv.Optional(CONF_TARGET): cv.Schema( { cv.Required(CONF_ENTITY): cv.All( @@ -124,6 +149,7 @@ WIZARD_INPUT_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_ID, CONF_ENTITY), + _validate_multiple, ) WIZARD_PAGE_SCHEMA = cv.All( @@ -248,6 +274,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]: defines.add("USE_API_WIZARD_LINKED_INPUTS") if any(CONF_ID in conf for conf in inputs): defines.add("USE_API_WIZARD_STANDALONE_INPUTS") + if any(conf.get(CONF_MULTIPLE) for conf in inputs): + defines.add("USE_API_WIZARD_MULTIPLE_INPUTS") return defines @@ -371,6 +399,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)} if description := conf.get(CONF_DESCRIPTION): document[CONF_DESCRIPTION] = description + if conf.get(CONF_MULTIPLE): + document["multiple"] = True + if (max_length := conf.get(CONF_MAX_LENGTH)) is not None: + document["max_length"] = max_length if filters := _wizard_input_filters(conf, config): document["entity_filters"] = [dict(entity_filter) for entity_filter in filters] return document @@ -384,7 +416,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi {"version": 1, "pages": [{"title": "...", "description": "...", "entities": [{"key": 123, "device_id": 456, "description": "..."}], - "inputs": [{"key": 789, "description": "...", + "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, "entity_filters": [{"integration": "...", "domain": ["..."], "device_class": ["..."], "supported_features": ["..."]}]}]}]} @@ -396,6 +428,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi the device it belongs to (esphome/core/config.py), and is left out for the main device. - An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets. + - multiple means the user may pick several entities. The client sends them in one + WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the + device trims spaces around each id and stores the list without them. The whole request is + ignored when any id is empty or has no '.', or when the list without spaces does not fit + max_length (only on a multiple input, 255 when left out). A single input is ignored when the id + is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know + multiple ignores it, shows a single picker and sends one entity id, which a multiple input + accepts. - entity_filters are the filters of the input, or the default filters of a linked switch, number, text, select or button. """ @@ -441,14 +481,15 @@ async def to_code(wizard: ConfigType) -> None: input_id = _wizard_input_id(conf) # Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id. buffer = _wizard_buffer_name(input_id) - cg.add_global( - cg.RawStatement( - f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";' - ) - ) + size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1 + cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";')) if CONF_ID in conf: cg.new_Pvariable(input_id, cg.RawExpression(buffer)) - entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}")) + key = fnv1_hash(input_id.id) + if conf[CONF_MULTIPLE]: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}")) + else: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}")) if entries: cg.extern_progmem_array( "esphome::api::API_WIZARD_INPUTS", diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c4c20e80a6d..d219296ca57 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -282,6 +282,7 @@ #define USE_API_WIZARD #define USE_API_WIZARD_INPUTS #define USE_API_WIZARD_LINKED_INPUTS +#define USE_API_WIZARD_MULTIPLE_INPUTS #define USE_API_WIZARD_STANDALONE_INPUTS #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index 112092482b2..f55a6974e7f 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -490,6 +490,7 @@ WIZARD_DEFINES = { "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", } @@ -550,9 +551,28 @@ WIZARD_DEFINES = { ), pytest.param( "- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]", - WIZARD_DEFINES, + WIZARD_DEFINES - {"USE_API_WIZARD_MULTIPLE_INPUTS"}, id="everything", ), + pytest.param( + "- inputs: [{id: calendars, multiple: true}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", + }, + id="multiple-input", + ), + pytest.param( + "- inputs: [{id: calendars, multiple: false}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + }, + id="multiple-false", + ), ], ) def test_only_the_defines_the_wizard_needs_are_emitted( @@ -925,3 +945,99 @@ def test_an_entity_with_an_entity_id_that_is_no_input_is_valid( assert "api_wizard_input_ha_plain_sensor" not in main_cpp # Every buffer starts empty assert '] = "sensor.' not in main_cpp.split("API_WIZARD_INPUTS")[0] + + +@pytest.mark.parametrize( + "input_conf", + [ + "{id: cals, multiple: true}", + "{id: cals, multiple: true, max_length: 255}", + "{id: cals, multiple: true, max_length: 4095}", + "{id: cals, multiple: false}", + ], +) +def test_multiple_input_is_valid(tmp_path: Path, input_conf: str) -> None: + api = wizard_inputs(f"- {input_conf}") + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +@pytest.mark.parametrize( + ("inputs", "message"), + [ + pytest.param( + "- {entity: ha_sensor, multiple: true}", + "multiple is only for standalone inputs", + id="multiple-linked", + ), + pytest.param( + "- {id: cals, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-without-multiple", + ), + pytest.param( + "- {id: cals, multiple: false, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-multiple-false", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 254}", + "value must be at least 255", + id="max-length-too-small", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 4096}", + "value must be at most 4095", + id="max-length-too-big", + ), + ], +) +def test_invalid_multiple_inputs_are_rejected( + tmp_path: Path, inputs: str, message: str +) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_inputs(inputs))) + + assert any(message in error for error in errors), errors + + +def test_multiple_input_document_and_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + api = wizard_inputs( + """ + - {id: cals, multiple: true, max_length: 1023} + - {id: cals_default, multiple: true} + - {id: single} + """ + ) + main_cpp = generate_main(write_config(tmp_path, ESP32_HEADER, api)) + + document = json.loads(zstd_module().decompress(blob_in(main_cpp))) + inputs = document["pages"][0]["inputs"] + # multiple and max_length are only there when set + assert inputs == [ + {"key": fnv1_hash("cals"), "multiple": True, "max_length": 1023}, + {"key": fnv1_hash("cals_default"), "multiple": True}, + {"key": fnv1_hash("single")}, + ] + # Only a multiple entry names its buffer size + assert 'static char api_wizard_input_cals[1024] = "";' in main_cpp + assert 'static char api_wizard_input_cals_default[256] = "";' in main_cpp + assert 'static char api_wizard_input_single[256] = "";' in main_cpp + assert f"{{{fnv1_hash('cals')}u, api_wizard_input_cals, 1024}}" in main_cpp + assert ( + f"{{{fnv1_hash('cals_default')}u, api_wizard_input_cals_default, 256}}" + in main_cpp + ) + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + + +def test_inputs_without_multiple_keep_the_two_field_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main( + write_config(tmp_path, ESP32_HEADER, wizard_inputs("- id: single")) + ) + + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + assert "MULTIPLE_INPUTS" not in {d.name for d in CORE.defines} diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index bd84c8d9bb4..79feb398bda 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -23,11 +23,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", ): cg.add_define(define) cg.add_define("API_WIZARD_DATA_SIZE", 200) - cg.add_define("API_WIZARD_INPUT_COUNT", 2) + cg.add_define("API_WIZARD_INPUT_COUNT", 3) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") diff --git a/tests/components/api/common-wizard.yaml b/tests/components/api/common-wizard.yaml index 5e1936a042d..d658da1c1f2 100644 --- a/tests/components/api/common-wizard.yaml +++ b/tests/components/api/common-wizard.yaml @@ -41,6 +41,13 @@ api: entity: - domain: weather - id: unset_input + - id: calendars_input + description: Calendars + multiple: true + max_length: 1023 + target: + entity: + - domain: calendar switch: - platform: template @@ -106,3 +113,16 @@ interval: data: type: daily entity_id: !lambda 'return id(weather_input).entity_id().c_str();' + - if: + condition: + api.wizard.input_is_set: calendars_input + then: + - homeassistant.action: + action: calendar.get_events + data: + duration: "24:00:00" + entity_id: !lambda 'return id(calendars_input).entity_id().c_str();' + - lambda: |- + ESP_LOGD("wizard", "%u calendars", (unsigned) id(calendars_input).entity_id_count()); + id(calendars_input).for_each_entity_id( + [](StringRef calendar) { ESP_LOGD("wizard", "Calendar %.*s", (int) calendar.size(), calendar.c_str()); }); diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 033c8b627e5..54aeb4b4ed6 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -23,8 +23,15 @@ static char *wizard_input_other() { return buffer; } +static constexpr size_t CALENDARS_SIZE = 64; +static char *wizard_input_calendars() { + static char buffer[CALENDARS_SIZE] = ""; + return buffer; +} + static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d; static constexpr uint32_t OTHER_KEY = 0x11223344; +static constexpr uint32_t CALENDARS_KEY = 0x55667788; // The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two // bytes as a varint. @@ -43,6 +50,7 @@ const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = { const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = { {WEATHER_KEY, wizard_input_weather()}, {OTHER_KEY, wizard_input_other()}, + {CALENDARS_KEY, wizard_input_calendars(), CALENDARS_SIZE}, }; using Bytes = std::vector; @@ -144,5 +152,70 @@ TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) { EXPECT_EQ(input.entity_id(), "weather.home"); } +TEST(DeviceWizard, MultipleInputStoresTheList) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + // One id is a list of one + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.c"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.c"); +} + +TEST(DeviceWizard, MultipleInputStoresTheListWithoutSpaces) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a , calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + EXPECT_EQ(set_input(CALENDARS_KEY, " calendar.a , calendar.b ,calendar.c "), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b,calendar.c"); +} + +TEST(DeviceWizard, MultipleInputIgnoresTheWholeRequestForABadEntry) { + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.keep"), wizard_input_calendars()); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,nodot"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,,calendar.b"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ",calendar.a"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, " "), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ""), nullptr); + EXPECT_STREQ(wizard_input_calendars(), "calendar.keep"); +} + +TEST(DeviceWizard, MultipleInputIsLimitedByItsBuffer) { + // Pad the last id so the list fills the buffer + std::string fits = "calendar.a,calendar.b,calendar." + std::string(CALENDARS_SIZE - 1 - 31, 'x'); + ASSERT_EQ(fits.size(), CALENDARS_SIZE - 1); + EXPECT_EQ(set_input(CALENDARS_KEY, fits.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + + std::string too_long = fits + "x"; + EXPECT_EQ(set_input(CALENDARS_KEY, too_long.c_str()), nullptr); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + // Spaces do not count towards the limit + std::string spaced = fits; + spaced.replace(spaced.find(','), 1, " , "); + EXPECT_EQ(set_input(CALENDARS_KEY, spaced.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); +} + +TEST(DeviceWizard, MultipleInputHelpersWalkTheList) { + strcpy(wizard_input_calendars(), ""); + WizardInput input(wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 0u); + std::vector seen; + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_TRUE(seen.empty()); + + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 1u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a"})); + + seen.clear(); + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a, calendar.b,calendar.c"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 3u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a", "calendar.b", "calendar.c"})); + // The raw list is what homeassistant.action data receives + EXPECT_EQ(input.entity_id(), "calendar.a,calendar.b,calendar.c"); +} + } // namespace esphome::api #endif // USE_HOST From 9a35c5229b2d11acae95428e209404d2611de427 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:36:06 +0200 Subject: [PATCH 081/651] [uart] Reuse the ISR pin argument on an ESP8266 software serial reload (#20541) --- esphome/components/uart/uart_component_esp8266.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index b81c0af85f1..0819a1bfb00 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -257,16 +257,19 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; + // to_isr() allocates, so call it only for a new or changed pin if (tx_pin != nullptr) { + if (tx_pin != this->gpio_tx_pin_) + this->tx_pin_ = tx_pin->to_isr(); gpio_tx_pin_ = tx_pin; gpio_tx_pin_->setup(); - tx_pin_ = gpio_tx_pin_->to_isr(); tx_pin_.digital_write(true); } if (rx_pin != nullptr) { + if (rx_pin != this->gpio_rx_pin_) + this->rx_pin_ = rx_pin->to_isr(); gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); - rx_pin_ = gpio_rx_pin_->to_isr(); uint8_t *buffer = this->rx_.buffer(); if (this->rx_.buffer_size() != rx_buffer_size) { delete[] buffer; // NOLINT From cf569702c40e22a0440fc4a83fb0605fdd1209eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 15:42:45 -1000 Subject: [PATCH 082/651] [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 ff5564e8150..7835156b01f 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 620d08fe3b4..854c39a9ce1 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/651] [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 14c01842f0c..00000000000 --- 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 e52329d7ea2..628b63d4e97 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 607e82dfa94..00000000000 --- 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 904a9167896..00000000000 --- 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 16a25ced2a9..00000000000 --- 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 d454dbadc39..00000000000 --- 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 f1551fef603..c99dfaf5281 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 36f1b5365f1..d454dbadc39 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 00000000000..a2c62e4a526 --- /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/651] [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 b8032fcec8c..8ad2f7521e5 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 1c45b0f4afa..7414677175e 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/651] [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 1433dada1ff..af8cf66708d 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 ecda6255219..00000000000 --- 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 4709a9e45ce..00000000000 --- 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 0cbf8549520..00000000000 --- 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 dade44d145b..3b32457cf40 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 dade44d145b..3b32457cf40 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 dade44d145b..7f25fafadbb 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 dade44d145b..3b32457cf40 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 ae7c4d0add7..fdfbf47139a 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 dade44d145b..3b32457cf40 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 00000000000..ac662302fc7 --- /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/651] [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 48fd72d1da7..82e00ff8976 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/651] [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 cc458d5d664..26907936c17 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 7e9b09b2b93..e63e0849deb 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 88a1a17b333..4da644d6798 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/651] [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 88454f7bdbf..cfd77e84f4a 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/651] [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 c14341330f5..eb3d7471dc1 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 fd982c0d312..594e59827aa 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/651] [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 b4ae6244048..141b5f12b38 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 3a8658a55fe..6560f200a10 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 a8b52a3839f..31327aa0439 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 369c1c6668b..7dd7317949e 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 70cd03a4eb5..c7610b30067 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 c01fb9e52e0..c594a6fe269 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/651] [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 3827fb6ed45..cccb78d3bf0 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/651] [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 86d485777ca..631959530eb 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 f5ab04809e4..d4f84e30f24 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 00000000000..7c66cd3361b --- /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/651] [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 c825deaefd9..73078dc19fd 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 00000000000..1230a566814 --- /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 5d630fec6cc..433c8aee740 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 00000000000..1d237e7f059 --- /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/651] [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 26907936c17..663f2c6d5d7 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 4da644d6798..718d754f016 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/651] [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 b69edd04a13..b1a35bf4c02 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 00000000000..e69de29bb2d 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 00000000000..9624531862f --- /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 00000000000..effe7b5b0bd --- /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/651] 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 0282011f504..9edbd6e4f9b 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/651] [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 d4f84e30f24..ed7069a5403 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/651] [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 502f83cd5d4..838c8ba54a8 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/651] [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 94532958ed1..317cec8236b 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 1a1df68f4de..8d572dba968 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/651] [openthread] add mesh_local_eid openthread_info sensor (#13924) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../openthread_info_text_sensor.cpp | 1 + .../openthread_info_text_sensor.h | 18 ++++++++++++++++++ .../components/openthread_info/text_sensor.py | 8 ++++++++ .../openthread_info/test.esp32-c6-idf.yaml | 2 ++ 4 files changed, 29 insertions(+) diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.cpp b/esphome/components/openthread_info/openthread_info_text_sensor.cpp index 44eb5468502..68ee263e7c5 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_text_sensor.cpp @@ -16,6 +16,7 @@ void Eui64OpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Eui64", this); } void NetworkNameOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Name", this); } void NetworkKeyOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Key", this); } void PanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "PAN ID", this); } +void MeshLocalEidOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Mesh Local EID", this); } void ExtPanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Extended PAN ID", this); } } // namespace esphome::openthread_info diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index ef7c5cc8e9f..1f97c577805 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -134,6 +134,24 @@ class ChannelOpenThreadInfo final : public OpenThreadInstancePollingComponent, p uint8_t last_channel_; }; +class MeshLocalEidOpenThreadInfo final : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance(otInstance *instance) override { + const otIp6Address *addr = otThreadGetMeshLocalEid(instance); + if (otIp6IsAddressEqual(addr, &this->last_addr_)) { + return; + } + this->last_addr_ = *addr; + char buf[OT_IP6_ADDRESS_STRING_SIZE]; + otIp6AddressToString(addr, buf, sizeof(buf)); + this->publish_state(buf); + } + void dump_config() override; + + protected: + otIp6Address last_addr_{}; +}; + class DatasetOpenThreadInfo : public OpenThreadInstancePollingComponent { public: void update_instance(otInstance *instance) override { diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index 4d3e8cd6c37..c7e8a602987 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -14,6 +14,7 @@ from esphome.types import ConfigType CONF_RLOC16 = "rloc16" CONF_EUI64 = "eui64" CONF_EXT_ADDR = "ext_addr" +CONF_MESH_LOCAL_EID = "mesh_local_eid" DEPENDENCIES = ["openthread"] @@ -46,6 +47,9 @@ NetworkKeyOpenThreadInfo = openthread_info_ns.class_( PanIdOpenThreadInfo = openthread_info_ns.class_( "PanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) +MeshLocalEidOpenThreadInfo = openthread_info_ns.class_( + "MeshLocalEidOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) ExtPanIdOpenThreadInfo = openthread_info_ns.class_( "ExtPanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) @@ -80,6 +84,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_PAN_ID): text_sensor.text_sensor_schema( # noqa: F821 PanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_MESH_LOCAL_EID): text_sensor.text_sensor_schema( + MeshLocalEidOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), cv.Optional(CONF_EXT_PAN_ID): text_sensor.text_sensor_schema( ExtPanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), @@ -103,4 +110,5 @@ async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_NETWORK_NAME) await setup_conf(config, CONF_NETWORK_KEY) await setup_conf(config, CONF_PAN_ID) + await setup_conf(config, CONF_MESH_LOCAL_EID) await setup_conf(config, CONF_EXT_PAN_ID) diff --git a/tests/components/openthread_info/test.esp32-c6-idf.yaml b/tests/components/openthread_info/test.esp32-c6-idf.yaml index 8c55546ce60..a1a91e9c675 100644 --- a/tests/components/openthread_info/test.esp32-c6-idf.yaml +++ b/tests/components/openthread_info/test.esp32-c6-idf.yaml @@ -26,6 +26,8 @@ text_sensor: name: "Network Key" pan_id: name: "PAN ID" + mesh_local_eid: + name: "Mesh Local EID" ext_pan_id: name: "Extended PAN ID" From 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/651] [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 b5138c06120..3fe111b6282 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 67aad11d0f1..5a6a01f6695 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 b51d4cb7953..6a23836c8ab 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -1,10 +1,32 @@ import esphome.codegen as cg from esphome.components import sensor +import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, CONF_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, ICON_FLASH, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_MILLIVOLT, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -13,19 +35,125 @@ from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, telein TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = sensor.sensor_schema( - TeleInfoSensor, - unit_of_measurement=UNIT_WATT_HOURS, - icon=ICON_FLASH, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, -).extend(TELEINFO_LISTENER_SCHEMA) + +def _preset(unit: str, device_class: str, state_class: str) -> dict[str, str]: + return { + CONF_UNIT_OF_MEASUREMENT: unit, + CONF_DEVICE_CLASS: device_class, + CONF_STATE_CLASS: state_class, + } + + +_ENERGY = _preset(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING) +_REACTIVE_ENERGY = _preset( + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, +) +_CURRENT = _preset(UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT) +_VOLTAGE = _preset(UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT) +_APPARENT_POWER_VA = _preset( + UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_APPARENT_POWER_KVA = _preset( + UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT) + +# Legacy defaults, kept for tags without a known preset +_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH} + +# Units that are valid for each device class, used to decide whether a preset's +# device class can be kept when the user overrides the unit (and vice versa) +_COMPATIBLE_UNITS = { + DEVICE_CLASS_ENERGY: {UNIT_WATT_HOURS, UNIT_KILOWATT_HOURS}, + DEVICE_CLASS_REACTIVE_ENERGY: { + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + }, + DEVICE_CLASS_CURRENT: {UNIT_AMPERE, UNIT_MILLIAMP}, + DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT}, + DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS}, + DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT}, +} + +# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant. +TIC_TAG_CONFIGS = { + # Standard mode + "EA": _ENERGY, + "ER": _REACTIVE_ENERGY, + "IRMS": _CURRENT, + "URMS": _VOLTAGE, + "UMOY": _VOLTAGE, + "SINST": _APPARENT_POWER_VA, + "SMAX": _APPARENT_POWER_VA, + "CC": _POWER, + "PREF": _APPARENT_POWER_KVA, + "PCOUP": _APPARENT_POWER_KVA, + # Historical mode + "BASE": _ENERGY, + "HCH": _ENERGY, + "EJP": _ENERGY, + "BBRH": _ENERGY, + "IINST": _CURRENT, + "IMAX": _CURRENT, + "ISOUSC": _CURRENT, + "ADPS": _CURRENT, + "ADIR": _CURRENT, + "PAPP": _APPARENT_POWER_VA, + "PMAX": _POWER, +} + + +def apply_tag_config(config: ConfigType) -> ConfigType: + """Apply preset configurations based on the tag name. + + Only keys not set by the user are filled in. For known tags, a preset unit or + device class is dropped if it is incompatible with the one set by the user. + Unknown tags get the legacy defaults, merged key by key as before. + """ + if CONF_TAG_NAME not in config: + return config + + # Runs before cv.string, so the raw value may not be a string yet + tag_name = str(config[CONF_TAG_NAME]) + preset = next( + (p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)), + None, + ) + + config = dict(config) + if preset is None: + for key, value in _LEGACY_DEFAULT.items(): + config.setdefault(key, value) + return config + + unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT]) + device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS]) + compatible = unit in _COMPATIBLE_UNITS.get(device_class, ()) + + for key, value in preset.items(): + if key in config: + continue + if key in (CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS) and not compatible: + continue + config[key] = value + return config + + +CONFIG_SCHEMA = cv.All( + apply_tag_config, + sensor.sensor_schema( + TeleInfoSensor, + accuracy_decimals=0, + ).extend(TELEINFO_LISTENER_SCHEMA), +) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await sensor.register_sensor(var, config) + teleinfo = await cg.get_variable(config[CONF_TELEINFO_ID]) cg.add(teleinfo.register_teleinfo_listener(var)) diff --git a/tests/component_tests/teleinfo/__init__.py b/tests/component_tests/teleinfo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/teleinfo/test_sensor.py b/tests/component_tests/teleinfo/test_sensor.py new file mode 100644 index 00000000000..a8f9420deac --- /dev/null +++ b/tests/component_tests/teleinfo/test_sensor.py @@ -0,0 +1,149 @@ +"""Tests for teleinfo sensor tag presets.""" + +import pytest + +from esphome.components.teleinfo import CONF_TAG_NAME +from esphome.components.teleinfo.sensor import apply_tag_config +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, + ICON_FLASH, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, + UNIT_WATT_HOURS, +) + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class", "state_class"), + [ + ("EAST", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ( + "ERQ1", + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, + ), + ("IRMS1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("URMS1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT), + ( + "SINSTS", + UNIT_VOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ( + "PREF", + UNIT_KILOVOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ("CCASN", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ("HCHC", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("BBRHCJB", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT), + ("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ], +) +def test_known_tag_preset( + tag: str, unit: str, device_class: str, state_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + assert config[CONF_STATE_CLASS] == state_class + assert CONF_ICON not in config + + +def test_unknown_tag_keeps_legacy_defaults() -> None: + config = apply_tag_config({CONF_TAG_NAME: "MYTAG"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_user_unit_keeps_legacy_device_class() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "MYTAG", CONF_UNIT_OF_MEASUREMENT: UNIT_KILOWATT_HOURS} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KILOWATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_starting_with_u_is_not_voltage() -> None: + config = apply_tag_config({CONF_TAG_NAME: "UNKNOWN"}) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +def test_numeric_tag_name_does_not_crash() -> None: + config = apply_tag_config({CONF_TAG_NAME: 1234}) + assert config[CONF_TAG_NAME] == 1234 + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class"), + [ + ("HCHP", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("EAST", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("PREF", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER), + ("IINST1", UNIT_MILLIAMP, DEVICE_CLASS_CURRENT), + ], +) +def test_user_compatible_unit_keeps_preset_device_class( + tag: str, unit: str, device_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag, CONF_UNIT_OF_MEASUREMENT: unit}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + + +def test_user_incompatible_unit_skips_preset_device_class() -> None: + config = apply_tag_config({CONF_TAG_NAME: "PAPP", CONF_UNIT_OF_MEASUREMENT: "W"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == "W" + assert CONF_DEVICE_CLASS not in config + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + + +def test_user_incompatible_device_class_skips_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_POWER} + ) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert CONF_UNIT_OF_MEASUREMENT not in config + + +def test_user_same_device_class_keeps_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_VOLT_AMPS + + +def test_user_state_class_is_kept() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "HCHP", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT} + ) + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS diff --git a/tests/components/teleinfo/common.yaml b/tests/components/teleinfo/common.yaml index dcb2cb14bdf..7f73bca62c2 100644 --- a/tests/components/teleinfo/common.yaml +++ b/tests/components/teleinfo/common.yaml @@ -22,11 +22,95 @@ teleinfo: update_interval: 60s sensor: + # Historical mode tags - auto-configured via apply_tag_config - platform: teleinfo name: hchc tag_name: HCHC teleinfo_id: test_teleinfo - unit_of_measurement: Wh + - platform: teleinfo + name: base + tag_name: BASE + teleinfo_id: test_teleinfo + - platform: teleinfo + name: iinst1 + tag_name: IINST1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: papp + tag_name: PAPP + teleinfo_id: test_teleinfo + - platform: teleinfo + name: bbrhcjb + tag_name: BBRHCJB + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ejphn + tag_name: EJPHN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pmax + tag_name: PMAX + teleinfo_id: test_teleinfo + - platform: teleinfo + name: imax1 + tag_name: IMAX1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: isousc + tag_name: ISOUSC + teleinfo_id: test_teleinfo + - platform: teleinfo + name: adps + tag_name: ADPS + teleinfo_id: test_teleinfo + # Standard mode tags - auto-configured via apply_tag_config + - platform: teleinfo + name: east + tag_name: EAST + teleinfo_id: test_teleinfo + - platform: teleinfo + name: erqt + tag_name: ERQT + teleinfo_id: test_teleinfo + - platform: teleinfo + name: irms1 + tag_name: IRMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: urms1 + tag_name: URMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: sinsts + tag_name: SINSTS + teleinfo_id: test_teleinfo + - platform: teleinfo + name: smaxsn + tag_name: SMAXSN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ccasn + tag_name: CCASN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pref + tag_name: PREF + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pcoup + tag_name: PCOUP + teleinfo_id: test_teleinfo + # Explicit override - user-specified unit should not be overwritten + - platform: teleinfo + name: hchp_override + tag_name: HCHP + teleinfo_id: test_teleinfo + unit_of_measurement: kWh + # Unknown tag - no auto-config match, should still compile + - platform: teleinfo + name: custom_tag + tag_name: MYTAG + teleinfo_id: test_teleinfo text_sensor: - platform: teleinfo From 97154f9e5f656285a246dc53d242b0c96570bce4 Mon Sep 17 00:00:00 2001 From: jabla Date: Sat, 10 Oct 2026 19:28:40 +0200 Subject: [PATCH 102/651] [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 effb817f353..c09cb73f8cf 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 854c39a9ce1..202ddd970ce 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/651] [core] Fix the native ESP8266 and host builds on Windows without ccache (#20468) --- esphome/build_gen/arduino8266.py | 2 +- esphome/build_gen/host.py | 2 +- esphome/build_helpers/ninja_gen.py | 21 ++++++++++++------- .../unit_tests/build_gen/test_arduino8266.py | 3 ++- tests/unit_tests/build_gen/test_host.py | 5 ++++- .../build_helpers/test_ninja_gen.py | 18 ++++++++++++++++ 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 44369136a01..b0ba7663927 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1057,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: cxx = (toolchain_tool(paths.toolchain, "g++"),) lines = [ *tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), *pch_rule_lines(), "rule link", diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py index f1e53e1ccb2..3a8587891b2 100644 --- a/esphome/build_gen/host.py +++ b/esphome/build_gen/host.py @@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: lines = [ *tool_lines(compilers.cc, compilers.cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index a2ee244a29f..c74262fdeea 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -74,33 +74,40 @@ def tool_lines( # path gets f"python = {_q(strip_win_long_path_prefix(sys.executable))}", f"buildtool = {_q(_BUILD_TOOL)}", - f"ccache = {_q(ccache) if ccache else ''}", + *([f"ccache = {_q(ccache)}"] if ccache else []), "", ] -def compile_rule_lines() -> list[str]: - """The compile rules; ``$own_includes`` is empty unless an edge sets it.""" +def compile_rule_lines(ccache: str | None) -> list[str]: + """The compile rules; ``$own_includes`` is empty unless an edge sets it. + + ``ccache`` is the launcher ``tool_lines`` was given; the same test here + keeps the variable and the rules in step. It is spliced in rather than + left as an empty variable: a leading space on the command line makes + CreateProcess fail on Windows. + """ + launcher = "$ccache " if ccache else "" return [ "rule c", - " command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CC $out", "rule cxx", - " command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", + f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CXX $out", "rule aspp", - " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = AS $out", # Plain assembler, as SCons's ASCOM: no preprocessor, so no # depfile and no $flags (defines/includes) either "rule asm", - " command = $ccache $cc -x assembler $asflags -c $in -o $out", + f" command = {launcher}$cc -x assembler $asflags -c $in -o $out", " description = AS $out", ] diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 2ea4ffabe6e..4c60b4e4764 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -888,8 +888,9 @@ def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None _set_flags() content = _write_ninja(paths) assert "lowlevel.s.o: asm " in content + assert "rule asm\n command = $cc -x assembler $asflags -c $in -o $out" in content assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in ( - content + _write_ninja(paths, ccache="/cc/ccache") ) diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py index 9e40698c91c..c3936ee65a8 100644 --- a/tests/unit_tests/build_gen/test_host.py +++ b/tests/unit_tests/build_gen/test_host.py @@ -308,7 +308,10 @@ def test_write_project_pch_skipped_for_a_force_include_in_the_compiler( def test_write_project_without_ccache(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") _changed, ninja = _render() - assert "ccache = \n" in ninja + # No launcher variable, and no leading space on the commands either: + # CreateProcess on Windows rejects a command line that starts with one + assert "$ccache" not in ninja + assert " command = $cxx -MMD -MF $out.d" in ninja def test_write_project_routes_user_link_flags(tmp_path: Path) -> None: diff --git a/tests/unit_tests/build_helpers/test_ninja_gen.py b/tests/unit_tests/build_helpers/test_ninja_gen.py index e7f9b96af2e..d51110efad8 100644 --- a/tests/unit_tests/build_helpers/test_ninja_gen.py +++ b/tests/unit_tests/build_helpers/test_ninja_gen.py @@ -55,3 +55,21 @@ def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None: tmp_path / "b.cpp", tmp_path / "sub" / "c.S", ] + + +@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None]) +def test_compile_rule_lines_splice_the_launcher(ccache: str | None) -> None: + """Without ccache no command keeps a leading space, which CreateProcess + on Windows rejects; with it every compile rule starts with the launcher.""" + commands = [ + line.removeprefix(" command = ") + for line in ninja_gen.compile_rule_lines(ccache) + if line.startswith(" command = ") + ] + assert len(commands) == 4 + for command in commands: + if ccache: + assert command.startswith("$ccache $c") + else: + assert command.startswith("$c") + assert "$ccache" not in command From 93c8796ae872dffe71220cf4f3a53e73d2d92cc7 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 10 Oct 2026 19:49:35 +0200 Subject: [PATCH 104/651] [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 631959530eb..bc535ebfdb2 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/651] [tests] Close leftover server connections in the tcp_uart bridge and TCP client link tests (#20215) Co-authored-by: J. Nick Koston --- tests/integration/test_socket_tcp_client_link.py | 6 ++++++ tests/integration/test_tcp_uart_bridge.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/integration/test_socket_tcp_client_link.py b/tests/integration/test_socket_tcp_client_link.py index 16b5f544f79..89a6ff42347 100644 --- a/tests/integration/test_socket_tcp_client_link.py +++ b/tests/integration/test_socket_tcp_client_link.py @@ -35,6 +35,7 @@ async def test_socket_tcp_client_link( link_down = asyncio.Event() second_link_up = asyncio.Event() link_up_count = 0 + writers: list[asyncio.StreamWriter] = [] def on_log_line(line: str) -> None: nonlocal link_up_count @@ -48,6 +49,7 @@ async def test_socket_tcp_client_link( async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: + writers.append(writer) if not echo_done.is_set(): writer.write(PAYLOAD) await writer.drain() @@ -98,5 +100,9 @@ async def test_socket_tcp_client_link( "Second session echo wrong; stale bytes from the first session?" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() diff --git a/tests/integration/test_tcp_uart_bridge.py b/tests/integration/test_tcp_uart_bridge.py index fb22a26b558..70befc90498 100644 --- a/tests/integration/test_tcp_uart_bridge.py +++ b/tests/integration/test_tcp_uart_bridge.py @@ -41,12 +41,14 @@ async def test_tcp_uart_bridge( service_byte_ok = asyncio.Event() reconnect_echo_ok = asyncio.Event() sessions = 0 + writers: list[asyncio.StreamWriter] = [] async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: nonlocal sessions sessions += 1 + writers.append(writer) if sessions == 1: writer.write(GREETING) await writer.drain() @@ -98,5 +100,9 @@ async def test_tcp_uart_bridge( reconnect_echo_ok, 15.0, "Bridge did not reconnect and echo again" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() From 4bae89bd4a47fa9d489a8feda2cf6ef32d5c8e83 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Sat, 10 Oct 2026 21:17:01 +0000 Subject: [PATCH 106/651] [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 bc535ebfdb2..f8649f968ed 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 f74a49ea897..5edcddfd326 100644 --- a/esphome/components/nrf52/ble_logger.py +++ b/esphome/components/nrf52/ble_logger.py @@ -4,6 +4,7 @@ import re from typing import Final from bleak import BleakClient, BleakScanner, BLEDevice +from bleak.backends.scanner import AdvertisementData from bleak.exc import ( BleakCharacteristicNotFoundError, BleakDBusError, @@ -19,15 +20,31 @@ NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" MAC_ADDRESS_PATTERN: Final = re.compile( r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE ) +# macOS identifies BLE peripherals by a CoreBluetooth UUID instead of a MAC address +UUID_PATTERN: Final = re.compile( + r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", + flags=re.IGNORECASE, +) -def is_mac_address(value: str) -> bool: - return MAC_ADDRESS_PATTERN.match(value) +def is_ble_address(value: str) -> bool: + return bool(MAC_ADDRESS_PATTERN.match(value) or UUID_PATTERN.match(value)) + + +def _name_matches(device: BLEDevice, adv: AdvertisementData, name: str) -> bool: + # device.name can be a stale cached name on macOS; adv.local_name is what is on the air now + return name in (device.name, adv.local_name) + + +async def find_device_by_name(name: str, timeout: float = 10.0) -> BLEDevice | None: + return await BleakScanner.find_device_by_filter( + lambda device, adv: _name_matches(device, adv, name), timeout=timeout + ) async def logger_scan(name: str) -> BLEDevice | None: _LOGGER.info("Scanning bluetooth for %s...", name) - device = await BleakScanner.find_device_by_name(name) + device = await find_device_by_name(name) if not device: _LOGGER.error("%s Bluetooth LE device was not found!", name) return device diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index cafeda64785..9115e218747 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -4,7 +4,6 @@ import json import logging from pathlib import Path -from bleak import BleakScanner from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient @@ -23,9 +22,8 @@ from smpclient.transport.serial import SMPSerialTransport from esphome.core import EsphomeError from esphome.espota2 import ProgressBar -from .ble_logger import is_mac_address +from .ble_logger import find_device_by_name, is_ble_address -SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84" BLE_SCAN_TIMEOUT = 10.0 # seconds RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute @@ -45,12 +43,12 @@ def _json_state(o: object) -> object: async def smpmgr_scan(name: str) -> str: _LOGGER.info("Scanning bluetooth for %s...", name) - for device in await BleakScanner.discover( - timeout=BLE_SCAN_TIMEOUT, service_uuids=[SMP_SERVICE_UUID] - ): - if device.name == name: - return device.address - raise EsphomeError(f"BLE device {name} with OTA service not found") + # No service filter: the SMP UUID only fits in the scan response, which macOS does not filter on. + # The SMP service is checked on connect. + device = await find_device_by_name(name, timeout=BLE_SCAN_TIMEOUT) + if device is None: + raise EsphomeError(f"BLE device {name} not found") + return device.address async def smpmgr_upload(device: str, firmware: Path) -> None: @@ -88,7 +86,7 @@ def _get_image_tlv_sha256(file: Path) -> bytes: async def _smpmgr_upload(device: str, firmware: Path) -> None: image_tlv_sha256 = _get_image_tlv_sha256(firmware) - if is_mac_address(device): + if is_ble_address(device): smp_client = SMPClient(SMPBLETransport(), device) else: smp_client = SMPClient(SMPSerialTransport(), device) @@ -105,7 +103,8 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: ) from exc raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: - raise EsphomeError(f"Connection error with {device}") from exc + # Reached when a device with the right name has no SMP service, among other causes + raise EsphomeError(f"Connection error with {device}: {exc}") from exc _LOGGER.info("Connected %s...", device) try: diff --git a/tests/unit_tests/test_nrf52_ota.py b/tests/unit_tests/test_nrf52_ota.py new file mode 100644 index 00000000000..c0b5e202f70 --- /dev/null +++ b/tests/unit_tests/test_nrf52_ota.py @@ -0,0 +1,121 @@ +"""Tests for nRF52 BLE device discovery and OTA transport selection.""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from esphome.components.nrf52 import ble_logger, ota, show_logs +from esphome.core import EsphomeError + +MAC = "AA:BB:CC:DD:EE:FF" +# macOS/CoreBluetooth identifies peripherals by UUID instead of MAC address +UUID = "0FA1B2C3-D4E5-F607-1829-3A4B5C6D7E8F" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (MAC, True), + (MAC.lower(), True), + (UUID, True), + ("/dev/ttyACM0", False), + ("COM3", False), + ], +) +def test_is_ble_address(value: str, expected: bool) -> None: + assert ble_logger.is_ble_address(value) is expected + + +def _scan_result( + monkeypatch, devices: list[tuple[str, str | None, str | None]] +) -> AsyncMock: + """Stub the bleak scan so the filter is applied to the given (address, name, local_name) devices.""" + + async def find_device_by_filter(filterfunc, timeout): + for address, name, local_name in devices: + device = SimpleNamespace(address=address, name=name) + if filterfunc(device, SimpleNamespace(local_name=local_name)): + return device + return None + + mock = AsyncMock(side_effect=find_device_by_filter) + monkeypatch.setattr(ble_logger.BleakScanner, "find_device_by_filter", mock) + return mock + + +def test_scan_matches_advertised_local_name(monkeypatch) -> None: + """On macOS device.name is cached from an earlier connection; the live name is adv.local_name.""" + _scan_result(monkeypatch, [(UUID, "stale-old-name", "my-device")]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == UUID + + +def test_scan_matches_device_name(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "my-device", None)]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == MAC + + +def test_scan_raises_when_no_device_found(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "someone-else", "someone-else")]) + with pytest.raises(EsphomeError, match="not found"): + asyncio.run(ota.smpmgr_scan("my-device")) + + +@pytest.mark.parametrize( + ("device", "transport"), + [("/dev/ttyACM0", "serial"), ("COM3", "serial"), (MAC, "ble"), (UUID, "ble")], +) +def test_upload_transport(monkeypatch, device: str, transport: str) -> None: + """Serial ports go to the serial transport; MAC addresses and macOS UUIDs to BLE.""" + captured: dict = {} + + class FakeClient: + def __init__(self, transport, address): + captured["transport"] = transport + captured["address"] = address + + async def connect(self) -> None: + pass + + async def disconnect(self) -> None: + pass + + monkeypatch.setattr(ota, "_get_image_tlv_sha256", lambda firmware: b"") + monkeypatch.setattr(ota, "_smpmgr_upload_connected", AsyncMock()) + monkeypatch.setattr(ota, "SMPSerialTransport", lambda: "serial") + monkeypatch.setattr(ota, "SMPBLETransport", lambda: "ble") + monkeypatch.setattr(ota, "SMPClient", FakeClient) + + asyncio.run(ota._smpmgr_upload(device, Path("firmware.bin"))) # pylint: disable=protected-access + assert captured == {"transport": transport, "address": device} + + +@pytest.mark.parametrize( + ("device", "connected_to"), + [("BLE", UUID), (MAC, MAC), (UUID, UUID), ("my-device.local", None)], +) +def test_show_logs_routing(monkeypatch, device: str, connected_to: str | None) -> None: + """A scanned device, a MAC or a UUID is handed to the BLE logger; anything else falls through.""" + scan = AsyncMock(return_value=SimpleNamespace(address=UUID)) + connect = AsyncMock(return_value=0) + monkeypatch.setattr(ble_logger, "logger_scan", scan) + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + handled = show_logs(config={}, args=None, devices=[device]) + + assert handled is (connected_to is not None) + if connected_to is None: + connect.assert_not_called() + else: + connect.assert_awaited_once_with(connected_to) + + +def test_show_logs_returns_when_the_scan_finds_nothing(monkeypatch) -> None: + monkeypatch.setattr(ble_logger, "logger_scan", AsyncMock(return_value=None)) + connect = AsyncMock() + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + assert show_logs(config={}, args=None, devices=["BLE"]) is True + connect.assert_not_called() From 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/651] Bump bundled esphome-device-builder to 1.23.1 (#20497) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9edbd6e4f9b..c9aa3dba62c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1 RUN \ platformio settings set enable_telemetry No \ From ec58e864f55da38908fcaf3b06d2ec0802a8342a Mon Sep 17 00:00:00 2001 From: fakuivan Date: Sat, 10 Oct 2026 22:07:36 -0300 Subject: [PATCH 108/651] [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 b7de235dd18..00435bf18e0 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 c5ba59a645a..114230500da 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 1bc4d6e3455..b5ccd9911a6 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 3ba3564608a..b1a2442d827 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/651] [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 c09cb73f8cf..5b32646e04c 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 202ddd970ce..9f921066761 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/651] [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 17d3d6c0999..97405c45408 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/651] [uart_tcp] Prove the accept has run before checking what a new client receives (#20489) --- tests/integration/test_uart_tcp_bridge.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_uart_tcp_bridge.py b/tests/integration/test_uart_tcp_bridge.py index 33d32f4c07e..d2e1603bd15 100644 --- a/tests/integration/test_uart_tcp_bridge.py +++ b/tests/integration/test_uart_tcp_bridge.py @@ -84,13 +84,16 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 1) + # The accept log precedes the stale discard in the same loop pass, so + # prove the device is past it with a client to UART round trip before + # writing the bytes the client must see. + writer.write(b"down1") + await writer.drain() + assert await read_uart(5) == b"down1" os.write(controller_fd, b"live!") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live!", ( "First bytes to the client were not the live payload" ) - writer.write(b"down1") - await writer.drain() - assert await read_uart(5) == b"down1" # Drop the client; bytes while no client is connected are discarded # when the next one is accepted. @@ -101,13 +104,13 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 2) + writer.write(b"down2") + await writer.drain() + assert await read_uart(5) == b"down2" os.write(controller_fd, b"live2") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live2", ( "Second client received stale bytes from the gap" ) - writer.write(b"down2") - await writer.drain() - assert await read_uart(5) == b"down2" writer.close() # A peer outside the allow list is rejected and closed. From bd2e8d184d4580ceca8fad3e40440dfda7e4ee62 Mon Sep 17 00:00:00 2001 From: sh1der Date: Sun, 11 Oct 2026 22:24:50 +0200 Subject: [PATCH 112/651] [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 e59f186521b..da843a008a1 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -37,7 +37,6 @@ void GreeClimate::transmit_state() { remote_state[1] = this->temperature_(); if (model == GREE_YAN) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[4] = this->vertical_swing_(); } @@ -61,7 +60,6 @@ void GreeClimate::transmit_state() { } if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index c2616295735..a85878e764d 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00; static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; +// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9. +static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10; +static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20; +static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40; +static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80; + // Model codes enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; @@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR { uint8_t preset_(); Model model_{}; - uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2] + uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise }; } // namespace esphome::gree diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 9bec3751d6b..b48d32b9001 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -17,12 +17,22 @@ CONF_HEALTH = "health" CONF_XFAN = "xfan" CONF_GREE_ID = "gree_id" -# Switch configurations: (config_key, display_name, bit_mask, icon) +# Switch configurations: (config_key, display_name, mode_bit, icon) SWITCH_CONFIGS = ( - (CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"), - (CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"), - (CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"), - (CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"), + ( + CONF_TURBO, + "Gree Turbo Switch", + gree_ns.GREE_MODE_BIT_TURBO, + "mdi:car-turbocharger", + ), + (CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"), + (CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"), + ( + CONF_XFAN, + "Gree X-FAN Switch", + gree_ns.GREE_MODE_BIT_XFAN, + "mdi:wall-sconce-flat", + ), ) SUPPORTED_MODELS = { @@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(key): switch.switch_schema( GreeModeBitSwitch, icon=icon, - default_restore_mode="RESTORE_DEFAULT_OFF", + # The display light was on before the switches existed, so it stays on by default + default_restore_mode="RESTORE_DEFAULT_ON" + if key == CONF_LIGHT + else "RESTORE_DEFAULT_OFF", device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, ) From 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/651] [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 a0dc7ae4cb1..0d095b53842 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 b231bab33e5..23cecfc9863 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -153,6 +153,7 @@ class MR24HPC1Component final : public Component, int sg_start_query_data_; bool check_dev_inf_sign_; bool poll_time_base_func_check_; + bool module_ready_{false}; void update_(); void r24_split_data_frame_(uint8_t value); diff --git a/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml new file mode 100644 index 00000000000..3853bf3e8dc --- /dev/null +++ b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr24hpc1: !include common.yaml From 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/651] [core] Copy trigger arguments in wait_until and script.wait (#20536) Co-authored-by: Claude Opus 5.5 --- esphome/components/script/script.h | 8 +- esphome/core/automation.h | 8 ++ esphome/core/base_automation.h | 21 ++-- .../core/test_wait_until_action.cpp | 109 ++++++++++++++++++ .../script/test_script_wait_action.cpp | 96 +++++++++++++++ 5 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 tests/components/core/test_wait_until_action.cpp create mode 100644 tests/components/script/test_script_wait_action.cpp diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 97b27af498d..f00ad7f4afa 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -306,7 +306,7 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { auto ¶ms = this->param_queue_.front(); - this->play_next_tuple_(params, std::make_index_sequence{}); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); this->param_queue_.pop_front(); } else { // Queue is now empty - disable loop until next play_complex @@ -323,12 +323,8 @@ template class ScriptWaitAction final : public Action void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->play_next_(std::get(tuple)...); - } - C *script_; - std::list> param_queue_; + std::list...>> param_queue_; }; } // namespace esphome::script diff --git a/esphome/core/automation.h b/esphome/core/automation.h index f5921168e02..8820d2a63af 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 8762cbe7110..69def222da2 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -500,7 +501,7 @@ template class WaitUntilAction : public Action, public Co // Store for later processing auto now = millis(); auto timeout = this->timeout_value_.optional_value(x...); - this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...)); + this->var_queue_.emplace_back(now, timeout, x...); // Do immediate check with fresh timestamp - don't call loop() synchronously! // Let the event loop call it to avoid reentrancy issues @@ -532,7 +533,13 @@ template class WaitUntilAction : public Action, public Co } protected: - using QueueItem = std::tuple, std::tuple>; + struct QueueItem { + QueueItem(uint32_t start, optional timeout, const Ts &...x) + : start(start), timeout(timeout), args(x...) {} + uint32_t start; + optional timeout; + std::tuple...> args; + }; // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items @@ -546,14 +553,14 @@ template class WaitUntilAction : public Action, public Co std::list pending; while (!queue.empty()) { auto it = queue.begin(); - auto start = std::get(*it); - auto timeout = std::get>(*it); + auto start = it->start; + auto timeout = it->timeout; // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + if (!expired && !std::apply([this](auto &...args) { return this->condition_->check(args...); }, it->args)) { pending.splice(pending.end(), queue, it); continue; } @@ -564,7 +571,7 @@ template class WaitUntilAction : public Action, public Co std::list completed; completed.splice(completed.begin(), queue, it); uint8_t generation = this->stop_generation_; - this->play_next_tuple_(std::get>(completed.front())); + std::apply([this](auto &...args) { this->play_next_(args...); }, completed.front().args); if (generation != this->stop_generation_) { // stop() ran inside the chain - the items still held locally were cancelled pending.clear(); @@ -581,7 +588,7 @@ template class WaitUntilAction : public Action, public Co Condition *condition_; std::list var_queue_{}; - // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_() uint8_t stop_generation_{0}; }; diff --git a/tests/components/core/test_wait_until_action.cpp b/tests/components/core/test_wait_until_action.cpp new file mode 100644 index 00000000000..56cfa10d513 --- /dev/null +++ b/tests/components/core/test_wait_until_action.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include + +#include "esphome/core/base_automation.h" + +namespace esphome::testing { + +class FlagCondition : public Condition { + public: + bool check(const std::string & /*value*/) override { return this->ready; } + bool ready{false}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(WaitUntilActionTest, KeepsCopyOfReferenceArguments) { + FlagCondition condition; + WaitUntilAction wait(&condition); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + condition.ready = true; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class CounterCondition : public Condition { + public: + bool check(Counter & /*counter*/) override { return this->ready; } + bool ready{false}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +// A non-const reference argument used to fail to compile +TEST(WaitUntilActionTest, AcceptsMutableReferenceArguments) { + CounterCondition condition; + WaitUntilAction wait(&condition); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + condition.ready = true; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityCondition : public Condition { + public: + bool check(Entity & /*entity*/) override { return this->ready; } + bool ready{false}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(WaitUntilActionTest, KeepsReferenceToNonCopyableArguments) { + EntityCondition condition; + WaitUntilAction wait(&condition); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + condition.ready = true; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::testing diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp new file mode 100644 index 00000000000..17f553996ea --- /dev/null +++ b/tests/components/script/test_script_wait_action.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/script/script.h" + +namespace esphome::script::testing { + +class FakeScript { + public: + bool is_running() const { return this->running; } + bool running{true}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(ScriptWaitActionTest, KeepsCopyOfReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +TEST(ScriptWaitActionTest, KeepsCopyOfMutableReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + script.running = false; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + script.running = false; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::script::testing From fb1ef29d54ba7220f8084b304e8b94444ded864d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 11:56:21 -1000 Subject: [PATCH 115/651] [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 00000000000..d80dbf1a854 --- /dev/null +++ b/esphome/components/uart/software_serial_rx_decoder.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +/// Software serial RX decoder: on_edge() counts bits from the time between edges; +/// finalize() completes a byte whose idle high tail has no closing edge. +/// Times are in any steady tick unit. +class SoftwareSerialRxDecoder { + public: + static constexpr uint8_t RX_IDLE = 0xFF; + + /// Configure framing and buffer; drops all state. Follow with reset(). + void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer, + size_t buffer_size) { + this->bit_ticks_ = bit_ticks; + this->data_bits_ = data_bits; + this->stop_bit_ = data_bits + (parity ? 1 : 0); + this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2); + this->buffer_ = buffer; + this->buffer_size_ = buffer_size; + this->in_pos_ = 0; + this->out_pos_ = 0; + this->reset(0, true); + } + + /// Forget any partial frame; `level` is the current line level. + void reset(uint32_t now, bool level) { + this->bit_ = RX_IDLE; + this->cur_byte_ = 0; + this->last_level_ = level; + this->last_edge_ = now; + } + + /// ISR. True to wake the loop: a byte was pushed, or the line went high in an + /// open frame (a data 1 and the idle tail look alike) so finalize() may be needed. + bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) { + const bool last_level = this->last_level_; + // Collapsed edge + if (level == last_level) + return false; + // LX106 has no divider, so count + uint32_t delta = now - this->last_edge_; + if (delta > this->max_run_ticks_) + delta = this->max_run_ticks_; + delta += this->bit_ticks_ / 2; + uint32_t bits = 0; + while (delta >= this->bit_ticks_) { + delta -= this->bit_ticks_; + bits++; + } + const bool pushed = this->consume_run_(bits, last_level); + this->last_edge_ = now; + this->last_level_ = level; + return pushed || (level && this->bit_ != RX_IDLE); + } + + bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; } + + bool finalize_due(uint32_t now) const { + const uint8_t bit = this->bit_; + if (bit == RX_IDLE) + return false; + return now - this->last_edge_ >= this->tail_ticks_(bit); + } + + /// Call with the ISR masked. + void finalize(uint32_t now) { + if (!this->last_level_ || !this->finalize_due(now)) + return; + // Remaining bits are all ones + const uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + if (bit < this->data_bits_) + cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1); + this->push_byte(cur); + this->bit_ = RX_IDLE; + } + + /// Drops the byte when full; holds buffer_size - 1 bytes. + bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) { + size_t in = this->in_pos_; + size_t next = in + 1; + if (next == this->buffer_size_) + next = 0; + if (next == this->out_pos_) + return false; + this->buffer_[in] = data; + this->in_pos_ = next; + return true; + } + + uint8_t *buffer() const { return this->buffer_; } + size_t buffer_size() const { return this->buffer_size_; } + + size_t available() const { + // Read volatile in_pos_ once to avoid TOCTOU race with ISR. + size_t in = this->in_pos_; + if (in >= this->out_pos_) + return in - this->out_pos_; + return this->buffer_size_ - this->out_pos_ + in; + } + uint8_t peek_byte() const { + if (this->in_pos_ == this->out_pos_) + return 0; + return this->buffer_[this->out_pos_]; + } + uint8_t read_byte() { + if (this->in_pos_ == this->out_pos_) + return 0; + uint8_t data = this->buffer_[this->out_pos_]; + size_t next = this->out_pos_ + 1; + this->out_pos_ = next == this->buffer_size_ ? 0 : next; + return data; + } + + protected: + uint32_t tail_ticks_(uint8_t bit) const { + return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2; + } + + bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) { + uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + bool pushed = false; + while (bits > 0) { + if (bit == RX_IDLE) { + if (level) + break; + // Start bit + bit = 0; + cur = 0; + bits--; + } else if (bit < this->data_bits_) { + uint8_t n = this->data_bits_ - bit; + if (n > bits) + n = bits; + if (level) + cur |= ((1U << n) - 1) << bit; + bit += n; + bits -= n; + } else if (bit < this->stop_bit_) { + // Parity, not checked + bit++; + bits--; + } else { + // Stop bit; low is a framing error + if (level) + pushed = this->push_byte(cur); + bit = RX_IDLE; + break; + } + } + this->bit_ = bit; + this->cur_byte_ = cur; + return pushed; + } + + // Members ordered largest to smallest to minimize padding + uint32_t bit_ticks_{0}; + uint32_t max_run_ticks_{0}; + volatile uint32_t last_edge_{0}; + uint8_t *buffer_{nullptr}; + size_t buffer_size_{0}; + volatile size_t in_pos_{0}; + size_t out_pos_{0}; // reader only + /// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE. + volatile uint8_t bit_{RX_IDLE}; + volatile uint8_t cur_byte_{0}; + volatile bool last_level_{true}; + uint8_t data_bits_{8}; + uint8_t stop_bit_{8}; +}; + +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0f6d7f6577c..b81c0af85f1 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 0098e752b2d..3d4a2ba85bc 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -7,6 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "software_serial_rx_decoder.h" #include "uart_component.h" namespace esphome::uart { @@ -26,24 +27,30 @@ class ESP8266SoftwareSerial { size_t available(); protected: + /// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR. static void gpio_intr(ESP8266SoftwareSerial *arg); + static void gpio_intr_edge(ESP8266SoftwareSerial *arg); void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void rx_finalize_pending_(); + void ESPHOME_ALWAYS_INLINE rx_sync_() { + if (this->rx_.pending()) + this->rx_finalize_pending_(); + } + + // Members ordered largest to smallest to minimize padding uint32_t bit_time_{0}; - uint8_t *rx_buffer_{nullptr}; - size_t rx_buffer_size_; - volatile size_t rx_in_pos_{0}; - size_t rx_out_pos_{0}; - uint8_t stop_bits_; - uint8_t data_bits_; - UARTParityOptions parity_; InternalGPIOPin *gpio_tx_pin_{nullptr}; ISRInternalGPIOPin tx_pin_; InternalGPIOPin *gpio_rx_pin_{nullptr}; ISRInternalGPIOPin rx_pin_; + SoftwareSerialRxDecoder rx_; + UARTParityOptions parity_; + uint8_t stop_bits_; + uint8_t data_bits_; }; class ESP8266UartComponent final : public UARTComponent, public Component { diff --git a/tests/components/uart/test_software_serial_rx_decoder.cpp b/tests/components/uart/test_software_serial_rx_decoder.cpp new file mode 100644 index 00000000000..b65e8a659fd --- /dev/null +++ b/tests/components/uart/test_software_serial_rx_decoder.cpp @@ -0,0 +1,285 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/uart/software_serial_rx_decoder.h" + +namespace esphome::uart::testing { + +namespace { + +// Firmware time base: micros() << 4 +constexpr uint32_t TICKS_PER_SECOND = 16000000; +constexpr uint32_t TICKS_PER_US = 16; + +// Simulated line: frames become edges at exact baud timing plus jitter, truncated +// to whole microseconds like micros(); poll() emulates the main loop. + +class LineSim { + public: + LineSim(uint32_t baud, uint8_t data_bits, bool parity, bool odd, uint8_t stop_bits, size_t buffer_size = 64) + : data_bits_(data_bits), parity_(parity), odd_(odd), stop_bits_(stop_bits), buffer_(buffer_size) { + this->bit_ = (TICKS_PER_SECOND + baud / 2) / baud; + this->exact_bit_ = static_cast(TICKS_PER_SECOND) / baud; + this->dec_.setup(this->bit_, data_bits, parity, stop_bits, this->buffer_.data(), buffer_size); + this->dec_.reset(this->now_, true); + } + + uint32_t bit_ticks() const { return this->bit_; } + SoftwareSerialRxDecoder &decoder() { return this->dec_; } + const std::vector &received() const { return this->received_; } + + void edge(uint32_t at, bool level) { + this->now_ = at; + this->line_ = level; + this->dec_.on_edge(at, level); + } + + // Main loop pass at cycle `at`. + void poll(uint32_t at) { + this->now_ = at; + if (this->dec_.pending() && this->dec_.finalize_due(at)) + this->dec_.finalize(at); + while (this->dec_.available() > 0) + this->received_.push_back(this->dec_.read_byte()); + } + + // Emit one frame at `start` with per edge `jitter` in ticks; returns the end tick. + uint32_t send( + uint8_t value, uint32_t start, const std::function &jitter = [] { return 0; }) { + std::vector bits; + bits.push_back(false); + int ones = 0; + for (int i = 0; i < this->data_bits_; i++) { + bool b = (value >> i) & 1; + bits.push_back(b); + ones += b; + } + if (this->parity_) + bits.push_back(this->odd_ ? !(ones & 1) : (ones & 1)); + for (int i = 0; i < this->stop_bits_; i++) + bits.push_back(true); + double t = start; + for (bool b : bits) { + if (b != this->line_) { + const auto at = static_cast(static_cast(t) + jitter()); + this->edge(at / TICKS_PER_US * TICKS_PER_US, b); + } + t += this->exact_bit_; + } + return static_cast(t); + } + + protected: + uint8_t data_bits_; + bool parity_; + bool odd_; + uint8_t stop_bits_; + uint32_t bit_{0}; + double exact_bit_{0}; + uint32_t now_{1000}; + bool line_{true}; + std::vector buffer_; + std::vector received_; + SoftwareSerialRxDecoder dec_; +}; + +struct FrameFormat { + uint32_t baud; + uint8_t data_bits; + bool parity; + bool odd; + uint8_t stop_bits; +}; + +// Random bytes, back to back or with idle gaps, with bounded edge jitter. +void run_stream(const FrameFormat &f, double jitter_bits, uint32_t gap_bits, int count, unsigned seed) { + LineSim sim(f.baud, f.data_bits, f.parity, f.odd, f.stop_bits); + std::mt19937 rng(seed); + std::uniform_real_distribution jit(-jitter_bits, jitter_bits); + const uint8_t mask = static_cast((1U << f.data_bits) - 1); + std::vector sent; + uint32_t t = 5000; + for (int n = 0; n < count; n++) { + uint8_t b = rng() & mask; + sent.push_back(b); + t = sim.send(b, t, [&] { return static_cast(jit(rng) * sim.bit_ticks()); }); + if (gap_bits != 0 && n % 3 == 2) { + t += gap_bits * sim.bit_ticks(); + sim.poll(t); + } else if (rng() % 4 == 0) { + sim.poll(t); + } + } + sim.poll(t + 20 * sim.bit_ticks()); + EXPECT_EQ(sim.received(), sent) << "baud " << f.baud << " jitter " << jitter_bits; +} + +} // namespace + +TEST(SoftwareSerialRxDecoder, DecodesBackToBackFramesAtCommonBaudRates) { + for (uint32_t baud : {2400U, 4800U, 9600U, 19200U, 38400U}) { + run_stream({baud, 8, false, false, 1}, 0.0, 0, 300, baud); + } +} + +TEST(SoftwareSerialRxDecoder, ToleratesEdgeJitterUpToAQuarterBit) { + // A quarter bit per edge keeps each run within the half bit rounding budget. + run_stream({9600, 8, false, false, 1}, 0.24, 0, 2000, 1); + run_stream({9600, 8, false, false, 1}, 0.24, 7, 2000, 2); + run_stream({19200, 8, false, false, 1}, 0.24, 4, 2000, 3); // edge decoder limit +} + +TEST(SoftwareSerialRxDecoder, HandlesParityDataBitsAndStopBits) { + run_stream({9600, 8, true, false, 1}, 0.2, 3, 1000, 4); // 8E1 + run_stream({4800, 8, true, true, 2}, 0.2, 2, 1000, 5); // 8O2 + run_stream({19200, 7, false, false, 2}, 0.2, 5, 1000, 6); // 7N2 + run_stream({2400, 5, false, false, 1}, 0.2, 1, 1000, 7); // 5N1 +} + +TEST(SoftwareSerialRxDecoder, AllOnesByteCompletesOnlyByFinalize) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + // 0xFF: start bit, then the line stays high with no closing edge. + sim.edge(t, false); + sim.edge(t + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.poll(t + bit + 8 * bit); + EXPECT_TRUE(sim.received().empty()); + EXPECT_FALSE(sim.decoder().finalize_due(t + bit + 8 * bit)); + sim.poll(t + bit + 10 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xFF); + EXPECT_FALSE(sim.decoder().pending()); +} + +TEST(SoftwareSerialRxDecoder, LastByteOfBurstIsFinalizedThenNextFrameDecodes) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = sim.send(0xA5, 5000); + t = sim.send(0xF0, t); // ends high, needs finalize + EXPECT_TRUE(sim.received().empty()); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 2u); + EXPECT_EQ(sim.received()[0], 0xA5); + EXPECT_EQ(sim.received()[1], 0xF0); + // The stale last edge must not confuse the next start bit. + t = sim.send(0x3C, t + 50 * bit); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 3u); + EXPECT_EQ(sim.received()[2], 0x3C); +} + +TEST(SoftwareSerialRxDecoder, BreakConditionIsDroppedAndResyncs) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + sim.edge(t, false); + t += 25 * bit; // line held low for far longer than a frame + sim.edge(t, true); + t += 3 * bit; + sim.poll(t); + EXPECT_TRUE(sim.received().empty()); + t = sim.send(0xA5, t); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xA5); +} + +TEST(SoftwareSerialRxDecoder, CollapsedEdgeIsIgnoredAndStreamRealignsAtIdle) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + // 0x31: lose the rising edge of bit 4, so the next edge repeats the last level. + uint32_t t = 5000; + sim.edge(t, false); // start + sim.edge(t + 1 * bit, true); // bit0 = 1 + sim.edge(t + 2 * bit, false); // bits1..3 = 0 + sim.decoder().on_edge(t + 7 * bit, false); // should have been bit6 falling edge; level still low + sim.edge(t + 9 * bit, true); // stop + t += 10 * bit; + // Next frame decodes correctly once the line has idled. + t = sim.send(0x5A, t + 12 * bit); + sim.poll(t + 2 * bit); + ASSERT_FALSE(sim.received().empty()); + EXPECT_EQ(sim.received().back(), 0x5A); +} + +TEST(SoftwareSerialRxDecoder, DropsBytesWhenBufferIsFullAndKeepsOldest) { + LineSim sim(9600, 8, false, false, 1, 8); + uint32_t t = 5000; + for (int n = 0; n < 20; n++) + t = sim.send(static_cast(n), t); + sim.poll(t + 20 * sim.bit_ticks()); + ASSERT_EQ(sim.received().size(), 7u); // capacity is size - 1 + for (int n = 0; n < 7; n++) + EXPECT_EQ(sim.received()[n], n); +} + +TEST(SoftwareSerialRxDecoder, SetupAgainDropsStaleStateAndUsesNewBufferAndFraming) { + // Mirrors load_settings(): buffered 8N1 bytes and an open frame, then setup() as 5E2. + LineSim sim(9600, 8, false, false, 1); + uint32_t t = 5000; + for (int n = 0; n < 10; n++) + t = sim.send(static_cast(0x40 + n), t); + sim.edge(t + 8 * sim.bit_ticks(), false); // open a frame, never closed + SoftwareSerialRxDecoder &dec = sim.decoder(); + ASSERT_GE(dec.available(), 9u); + + std::vector small(4, 0xEE); + const uint32_t bit = TICKS_PER_SECOND / 2400; + dec.setup(bit, 5, true, 2, small.data(), small.size()); + EXPECT_EQ(dec.available(), 0u); + EXPECT_FALSE(dec.pending()); + EXPECT_EQ(dec.read_byte(), 0); + + // Capacity of the 4 byte buffer is 3; the spare slot must stay untouched. + auto send_5e2 = [&](uint8_t value, uint32_t start) { + bool line = true; + uint32_t at = start; + auto put = [&](bool b) { + if (b != line) { + dec.on_edge(at, b); + line = b; + } + at += bit; + }; + put(false); + int ones = 0; + for (int i = 0; i < 5; i++) { + bool b = (value >> i) & 1; + ones += b; + put(b); + } + put(ones & 1); + put(true); + put(true); + return at; + }; + uint32_t t2 = 5000; + for (int n = 1; n <= 6; n++) + t2 = send_5e2(static_cast(n), t2); + dec.finalize(t2 + 20 * bit); + ASSERT_EQ(dec.available(), 3u); + EXPECT_EQ(dec.read_byte(), 1); + EXPECT_EQ(dec.read_byte(), 2); + EXPECT_EQ(dec.read_byte(), 3); + EXPECT_EQ(small[3], 0xEE); // capacity slot is never written +} + +TEST(SoftwareSerialRxDecoder, ResetDiscardsPartialFrame) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + sim.edge(5000, false); + sim.edge(5000 + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.decoder().reset(5000 + 2 * bit, true); + EXPECT_FALSE(sim.decoder().pending()); + sim.poll(5000 + 30 * bit); + EXPECT_TRUE(sim.received().empty()); +} + +} // namespace esphome::uart::testing From 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/651] [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 337fa352c14..44bc99ad726 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 8f143b79777..2dfbf9c163b 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 63b5631564d..bf9ab2dfd7b 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 293cb9e786f..e1c1a7143be 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 a632b4e38d0..d5cb65dfbb3 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 00c5e0b0694..ed5a034b82f 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -132,15 +132,12 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo factor = 256 - factor; uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + if (channel->line_stop_bits_() == LineStopBits::LINE_STOP_BITS_2) value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; - } + // Parity enable, then 0 = odd, 1 = even, 2 = mark, 3 = space + const LineParity parity = channel->line_parity_(); + if (parity != LineParity::LINE_PARITY_NONE) + value |= 8 | ((static_cast(parity) - 1) << 4); value |= channel->data_bits_ - 5; value <<= 8; value |= 0x8C; diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5551abe1a1d..e920da6c267 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -107,8 +107,8 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); return true; case 1: { - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; + uint16_t line_control = static_cast(channel->line_stop_bits_()); + line_control |= static_cast(channel->line_parity_()) << 4; line_control |= channel->data_bits_ << 8; ESP_LOGD(TAG, "Line control value 0x%X", line_control); this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index fcebf0fbd95..e10da78aaf0 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -362,34 +362,8 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b } case 2: { // set line properties (data bits / parity / stop bits) uint16_t value = channel->data_bits_; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - switch (channel->stop_bits_) { - default: // 1 bit - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } + value |= static_cast(channel->line_parity_()) << 8; + value |= static_cast(channel->line_stop_bits_()) << 11; value |= (0x00 << 14); this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, channel->cdc_dev_.bulk_interface_number + 1); diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index db177fd308d..36107c093c4 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -261,34 +261,9 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b encode_baud_divisor(line_coding, baud); } - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } + line_coding[4] = static_cast(channel->line_stop_bits_()); - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } + line_coding[5] = static_cast(channel->line_parity_()); // Data bits line_coding[6] = channel->get_data_bits(); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 86dfa6d729f..f61642566cb 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -270,9 +270,10 @@ void USBUartComponent::dump_config() { " Flush Timeout: %" PRIu32 " ms\n" " Debug: %s\n" " Dummy receiver: %s", - channel->index_, channel->baud_rate_, channel->data_bits_, PARITY_NAMES[channel->parity_], - STOP_BITS_NAMES[channel->stop_bits_], channel->flush_timeout_ms_, YESNO(channel->debug_), - YESNO(channel->dummy_receiver_)); + channel->index_, channel->baud_rate_, channel->data_bits_, + PARITY_NAMES[static_cast(channel->line_parity_())], + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], channel->flush_timeout_ms_, + YESNO(channel->debug_), YESNO(channel->dummy_receiver_)); } } void USBUartComponent::start_input(USBUartChannelBase *channel) { @@ -515,14 +516,17 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // sends RSTACK. uint32_t baud = channel->baud_rate_; std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits + static_cast(baud & 0xFF), + static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), + static_cast((baud >> 24) & 0xFF), + static_cast(channel->line_stop_bits_()), // bCharFormat + static_cast(channel->line_parity_()), // bParityType + static_cast(channel->data_bits_), // bDataBits }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%s parity=%s data=%u", (unsigned) baud, + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], + PARITY_NAMES[static_cast(channel->line_parity_())], channel->data_bits_); this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, line_coding); return true; @@ -675,6 +679,32 @@ bool USBUartComponent::run_config_machine_() { return true; } +LineStopBits USBUartChannelBase::line_stop_bits_() const { + switch (this->stop_bits_) { + case UART_CONFIG_STOP_BITS_1_5: + return LineStopBits::LINE_STOP_BITS_1_5; + case UART_CONFIG_STOP_BITS_2: + return LineStopBits::LINE_STOP_BITS_2; + default: + return LineStopBits::LINE_STOP_BITS_1; + } +} + +LineParity USBUartChannelBase::line_parity_() const { + switch (this->parity_) { + case uart::UART_CONFIG_PARITY_ODD: + return LineParity::LINE_PARITY_ODD; + case uart::UART_CONFIG_PARITY_EVEN: + return LineParity::LINE_PARITY_EVEN; + case uart::UART_CONFIG_PARITY_MARK: + return LineParity::LINE_PARITY_MARK; + case uart::UART_CONFIG_PARITY_SPACE: + return LineParity::LINE_PARITY_SPACE; + default: + return LineParity::LINE_PARITY_NONE; + } +} + void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index c6ae0b4eef5..4e52fb60e20 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -71,21 +71,33 @@ enum CH34xChipType : uint8_t { CHIP_UNKNOWN = 0xFF, }; -enum UARTParityOptions { - UART_CONFIG_PARITY_NONE = 0, - UART_CONFIG_PARITY_ODD, - UART_CONFIG_PARITY_EVEN, - UART_CONFIG_PARITY_MARK, - UART_CONFIG_PARITY_SPACE, -}; - +// 1 and 2 are the stop bit count the uart component uses, so a count set through the +// UARTComponent API (as serial_proxy does) means the same here. 1.5 has no count. enum UARTStopBitsOptions { - UART_CONFIG_STOP_BITS_1 = 0, - UART_CONFIG_STOP_BITS_1_5, - UART_CONFIG_STOP_BITS_2, + UART_CONFIG_STOP_BITS_1 = 1, + UART_CONFIG_STOP_BITS_2 = 2, + UART_CONFIG_STOP_BITS_1_5 = 3, }; +// Stop bits and parity as adapters take them on the wire: CDC SET_LINE_CODING, and the +// CP210x, FTDI and PL2303 requests, all use these values +enum class LineStopBits : uint8_t { + LINE_STOP_BITS_1 = 0, + LINE_STOP_BITS_1_5 = 1, + LINE_STOP_BITS_2 = 2, +}; + +enum class LineParity : uint8_t { + LINE_PARITY_NONE = 0, + LINE_PARITY_ODD = 1, + LINE_PARITY_EVEN = 2, + LINE_PARITY_MARK = 3, + LINE_PARITY_SPACE = 4, +}; + +// Indexed by LineParity static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE"}; +// Indexed by LineStopBits static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; class RingBuffer { @@ -156,22 +168,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; - // Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value. - // MARK/SPACE have no uart:: equivalent and report as NONE. - switch (parity) { - case UART_CONFIG_PARITY_EVEN: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN); - break; - case UART_CONFIG_PARITY_ODD: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD); - break; - default: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE); - break; - } - } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } @@ -194,6 +190,8 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented output_queue_; @@ -205,7 +203,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented input_started_{true}; From 60402614cdaa5d03cf1b014a90d102141b5b6c0d Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 12 Oct 2026 00:15:23 +0200 Subject: [PATCH 117/651] [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 cedb9b3c9b5..47526d99bda 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 74f81df2e96..0572e591cfc 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -14,6 +14,7 @@ from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, get_slot_index, + zigbee_assign, zigbee_new_attr_list, zigbee_new_cluster_list, zigbee_new_variable, @@ -81,6 +82,7 @@ async def _add_time_zephyr(config: ConfigType) -> None: # Create the binary attributes structure time_attrs = zigbee_new_variable(attrs_name, "zb_zcl_time_attrs_t") + zigbee_assign(time_attrs.time, cg.RawExpression("ZB_ZCL_TIME_TIME_INVALID_VALUE")) attr_list = zigbee_new_attr_list( attr_list_name, "ZB_ZCL_DECLARE_TIME_ATTR_LIST", diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 3f14d0a62d2..4eb20d3da26 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/651] [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 76b82810a4b..77b5afe818e 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 986017a138d..0dd2869caa0 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 7d5a566b550..6bdc1654bcb 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -6,6 +6,9 @@ #include #include +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +#include +#endif #include "esphome/core/hal.h" #include "proto.h" @@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM; /// Where the entity id of an input is kept, found by the key the client uses for it. struct WizardInputEntry { uint32_t key; // FNV-1 hash of the ESPHome id of the input - char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity + char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + uint16_t size; // Buffer size of a multiple input, 0 for a single input +#endif }; /// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long. @@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg); class WizardInput { public: explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {} - /// The Home Assistant entity ID, empty until the wizard sets one. + /// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole + /// comma separated list, which homeassistant.action data can use as it is. StringRef entity_id() const { return StringRef(this->entity_id_); } bool has_entity_id() const { return this->entity_id_[0] != '\0'; } +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + /// The number of entity IDs in the list, 0 until the wizard sets one. + size_t entity_id_count() const { + if (!this->has_entity_id()) + return 0; + size_t count = 1; + for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++) + count++; + return count; + } + /// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is + /// not null terminated, so print it with "%.*s". + template void for_each_entity_id(F &&callback) const { + // The device stores the list without spaces or empty entries + const char *start = this->entity_id_; + while (*start != '\0') { + const char *end = strchr(start, ','); + if (end == nullptr) + end = start + strlen(start); + callback(StringRef(start, end - start)); + start = *end == ',' ? end + 1 : end; + } + } +#endif protected: const char *entity_id_; diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 314b907e4d0..e3f3faabf6f 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 12f61467d65..f3e54660f73 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 276f42d624e..8de3a2018ce 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 bd84c8d9bb4..79feb398bda 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -23,11 +23,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", ): cg.add_define(define) cg.add_define("API_WIZARD_DATA_SIZE", 200) - cg.add_define("API_WIZARD_INPUT_COUNT", 2) + cg.add_define("API_WIZARD_INPUT_COUNT", 3) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") diff --git a/tests/components/api/common-wizard.yaml b/tests/components/api/common-wizard.yaml index 5e1936a042d..d658da1c1f2 100644 --- a/tests/components/api/common-wizard.yaml +++ b/tests/components/api/common-wizard.yaml @@ -41,6 +41,13 @@ api: entity: - domain: weather - id: unset_input + - id: calendars_input + description: Calendars + multiple: true + max_length: 1023 + target: + entity: + - domain: calendar switch: - platform: template @@ -106,3 +113,16 @@ interval: data: type: daily entity_id: !lambda 'return id(weather_input).entity_id().c_str();' + - if: + condition: + api.wizard.input_is_set: calendars_input + then: + - homeassistant.action: + action: calendar.get_events + data: + duration: "24:00:00" + entity_id: !lambda 'return id(calendars_input).entity_id().c_str();' + - lambda: |- + ESP_LOGD("wizard", "%u calendars", (unsigned) id(calendars_input).entity_id_count()); + id(calendars_input).for_each_entity_id( + [](StringRef calendar) { ESP_LOGD("wizard", "Calendar %.*s", (int) calendar.size(), calendar.c_str()); }); diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 033c8b627e5..54aeb4b4ed6 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -23,8 +23,15 @@ static char *wizard_input_other() { return buffer; } +static constexpr size_t CALENDARS_SIZE = 64; +static char *wizard_input_calendars() { + static char buffer[CALENDARS_SIZE] = ""; + return buffer; +} + static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d; static constexpr uint32_t OTHER_KEY = 0x11223344; +static constexpr uint32_t CALENDARS_KEY = 0x55667788; // The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two // bytes as a varint. @@ -43,6 +50,7 @@ const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = { const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = { {WEATHER_KEY, wizard_input_weather()}, {OTHER_KEY, wizard_input_other()}, + {CALENDARS_KEY, wizard_input_calendars(), CALENDARS_SIZE}, }; using Bytes = std::vector; @@ -144,5 +152,70 @@ TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) { EXPECT_EQ(input.entity_id(), "weather.home"); } +TEST(DeviceWizard, MultipleInputStoresTheList) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + // One id is a list of one + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.c"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.c"); +} + +TEST(DeviceWizard, MultipleInputStoresTheListWithoutSpaces) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a , calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + EXPECT_EQ(set_input(CALENDARS_KEY, " calendar.a , calendar.b ,calendar.c "), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b,calendar.c"); +} + +TEST(DeviceWizard, MultipleInputIgnoresTheWholeRequestForABadEntry) { + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.keep"), wizard_input_calendars()); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,nodot"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,,calendar.b"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ",calendar.a"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, " "), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ""), nullptr); + EXPECT_STREQ(wizard_input_calendars(), "calendar.keep"); +} + +TEST(DeviceWizard, MultipleInputIsLimitedByItsBuffer) { + // Pad the last id so the list fills the buffer + std::string fits = "calendar.a,calendar.b,calendar." + std::string(CALENDARS_SIZE - 1 - 31, 'x'); + ASSERT_EQ(fits.size(), CALENDARS_SIZE - 1); + EXPECT_EQ(set_input(CALENDARS_KEY, fits.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + + std::string too_long = fits + "x"; + EXPECT_EQ(set_input(CALENDARS_KEY, too_long.c_str()), nullptr); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + // Spaces do not count towards the limit + std::string spaced = fits; + spaced.replace(spaced.find(','), 1, " , "); + EXPECT_EQ(set_input(CALENDARS_KEY, spaced.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); +} + +TEST(DeviceWizard, MultipleInputHelpersWalkTheList) { + strcpy(wizard_input_calendars(), ""); + WizardInput input(wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 0u); + std::vector seen; + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_TRUE(seen.empty()); + + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 1u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a"})); + + seen.clear(); + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a, calendar.b,calendar.c"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 3u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a", "calendar.b", "calendar.c"})); + // The raw list is what homeassistant.action data receives + EXPECT_EQ(input.entity_id(), "calendar.a,calendar.b,calendar.c"); +} + } // namespace esphome::api #endif // USE_HOST From 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/651] [uart] Reuse the ISR pin argument on an ESP8266 software serial reload (#20541) --- esphome/components/uart/uart_component_esp8266.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index b81c0af85f1..0819a1bfb00 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -257,16 +257,19 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; + // to_isr() allocates, so call it only for a new or changed pin if (tx_pin != nullptr) { + if (tx_pin != this->gpio_tx_pin_) + this->tx_pin_ = tx_pin->to_isr(); gpio_tx_pin_ = tx_pin; gpio_tx_pin_->setup(); - tx_pin_ = gpio_tx_pin_->to_isr(); tx_pin_.digital_write(true); } if (rx_pin != nullptr) { + if (rx_pin != this->gpio_rx_pin_) + this->rx_pin_ = rx_pin->to_isr(); gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); - rx_pin_ = gpio_rx_pin_->to_isr(); uint8_t *buffer = this->rx_.buffer(); if (this->rx_.buffer_size() != rx_buffer_size) { delete[] buffer; // NOLINT From 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/651] 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 455a83a0218..a72069f8449 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 52712669472..15dcce03ff9 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/651] [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 e3f3faabf6f..e4656e088ae 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 5ec46be67e4..1f9b1407971 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 f429d84d524..b7df6ca4fc7 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 8de3a2018ce..f55a6974e7f 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 8dae1e87b20..764f160cbc0 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/651] [const] Move the colour channel config keys to the shared constants (#20544) --- esphome/components/const/__init__.py | 4 ++++ esphome/components/tcs34725/sensor.py | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 51a5e012102..c44a323f65d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -14,9 +14,11 @@ CONF_ALLOWED_IPS = "allowed_ips" CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" +CONF_BLUE_CHANNEL = "blue_channel" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" +CONF_CLEAR_CHANNEL = "clear_channel" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -29,6 +31,7 @@ CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" +CONF_GREEN_CHANNEL = "green_channel" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_HOLD_STATE = "hold_state" @@ -50,6 +53,7 @@ CONF_ON_STATE_CHANGE = "on_state_change" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" +CONF_RED_CHANNEL = "red_channel" CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index 898aa945438..e19d76a64ae 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_CLEAR_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, +) import esphome.config_validation as cv from esphome.const import ( CONF_COLOR_TEMPERATURE, @@ -20,11 +26,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] -CONF_RED_CHANNEL = "red_channel" -CONF_GREEN_CHANNEL = "green_channel" -CONF_BLUE_CHANNEL = "blue_channel" -CONF_CLEAR_CHANNEL = "clear_channel" - tcs34725_ns = cg.esphome_ns.namespace("tcs34725") TCS34725Component = tcs34725_ns.class_( "TCS34725Component", cg.PollingComponent, i2c.I2CDevice From ce19978cfd6665b05a2eeb11b032696f1279ecc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:17:07 -1000 Subject: [PATCH 123/651] [ci] Tell too-big PR authors that review waits until the PR is under the cap (#20543) --- .github/scripts/auto-label-pr/reviews.js | 36 +++++++++++++----- .../auto-label-pr/tests/reviews.test.js | 38 +++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/auto-label-pr/tests/reviews.test.js diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index e9e848da6f2..8b1db61f463 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; message += - `Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` + - `this PR is on the large side `; + `Hey @${prAuthor}, thanks for the contribution. This PR is on the ` + + `large side `; if (tooManyLabels && tooManyChanges) { message += @@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, } message += `, which makes it harder for maintainers to review.\n\n`; + let condition; + if (tooManyLabels && tooManyChanges) { + condition = + `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` + + `at most ${MAX_LABELS} components/areas`; + } else if (tooManyLabels) { + condition = `touches at most ${MAX_LABELS} components/areas`; + } else { + condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`; + } message += - `Smaller, focused PRs tend to be reviewed much faster since they ` + - `fit into the short gaps between other maintainer work; large ones ` + - `often have to wait for a rare long uninterrupted block of time. ` + - `If you can break this up into smaller pieces that can be reviewed ` + - `independently, it will almost certainly land faster overall.\n\n`; + `**This PR will not be reviewed until it ${condition}.** We know ` + + `that is not what you want to hear after this much work, and we want ` + + `to be upfront so you do not wait for a review that is not coming: ` + + `please split the change. The label is removed automatically once ` + + `the PR ${condition}.\n\n`; message += - `Before putting more time in, it's also worth popping into ` + - `\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` + - `you scope things and flag anything already in flight.\n\n`; + `There is no exception you can request. Rarely, a maintainer agrees in ` + + `\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` + + `genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` + + `but that is rare enough that you should plan on splitting rather than ` + + `wait for it. Smaller, focused PRs are picked up much faster because ` + + `they fit into the short gaps between other maintainer work. For a new ` + + `component, the first PR brings the hub and a single platform (for ` + + `example \`sensor\`); each further platform is its own PR on top of it.\n\n`; message += `For more details (including how to split the work up), see: ` + `https://developers.esphome.io/contributing/submitting-your-work/` + @@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess) } module.exports = { + generateReviewMessages, handleReviews, handleMaintainerAccessComment }; diff --git a/.github/scripts/auto-label-pr/tests/reviews.test.js b/.github/scripts/auto-label-pr/tests/reviews.test.js new file mode 100644 index 00000000000..2390d2a0735 --- /dev/null +++ b/.github/scripts/auto-label-pr/tests/reviews.test.js @@ -0,0 +1,38 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { generateReviewMessages } = require('../reviews'); + +const MAX_LABELS = 15; +const TOO_BIG_THRESHOLD = 1000; + +function tooBigMessage(labelCount, additions) { + const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }]; + const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD); + assert.equal(messages.length, 1); + return messages[0]; +} + +describe('too-big review message', () => { + it('names the line cap when the PR is too long', () => { + const message = tooBigMessage(3, 1500); + assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./); + assert.match(message, /removed automatically once the PR is at most 1000 lines/); + assert.doesNotMatch(message, /components\/areas\./); + }); + + it('names the area cap when the PR touches too many areas but is short', () => { + const message = tooBigMessage(20, 200); + assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./); + assert.doesNotMatch(message, /until it is at most 1000 lines/); + }); + + it('names both caps when both are exceeded', () => { + const message = tooBigMessage(20, 1500); + assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./); + }); + + it('tells new-component authors to bring one platform first', () => { + assert.match(tooBigMessage(3, 1500), /hub and a single platform/); + assert.match(tooBigMessage(3, 1500), /mega-pr/); + }); +}); From 826a7a252a8eecf22f14f54b05703c197262caf8 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:33:06 -0400 Subject: [PATCH 124/651] [tmp102] Add comparator alert binary sensor (#20540) --- esphome/components/tmp102/__init__.py | 19 +++++ esphome/components/tmp102/binary_sensor.py | 28 +++++++ esphome/components/tmp102/tmp102.cpp | 52 +++++++++--- esphome/components/tmp102/tmp102.h | 16 ++++ esphome/core/defines.h | 1 + .../component_tests/tmp102/config/alert.yaml | 19 +++++ .../tmp102/config/duplicate_alert.yaml | 23 ++++++ tests/component_tests/tmp102/test_config.py | 26 ++++++ tests/components/tmp102/__init__.py | 6 +- tests/components/tmp102/common.yaml | 5 ++ tests/components/tmp102/test_tmp102.cpp | 81 +++++++++++++++++++ 11 files changed, 265 insertions(+), 11 deletions(-) create mode 100644 esphome/components/tmp102/binary_sensor.py create mode 100644 tests/component_tests/tmp102/config/alert.yaml create mode 100644 tests/component_tests/tmp102/config/duplicate_alert.yaml diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 6fd5f481e72..077dd03ccc4 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -4,8 +4,12 @@ import esphome.codegen as cg from esphome.components import i2c, sensor from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv +from esphome.const import CONF_PLATFORM +import esphome.final_validate as fv from esphome.types import ConfigType +CONF_TMP102_ID = "tmp102_id" + DOMAIN = "tmp102" tmp102_ns = cg.esphome_ns.namespace("tmp102") @@ -83,3 +87,18 @@ def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: [CONF_TEMPERATURE_LOW], ) return config + + +def validate_unique_child(config: ConfigType, platform: str) -> ConfigType: + entries = fv.full_config.get().get(platform, []) + matches = [ + entry + for entry in entries + if entry.get(CONF_PLATFORM) == DOMAIN + and entry.get(CONF_TMP102_ID) == config[CONF_TMP102_ID] + ] + if len(matches) > 1: + raise cv.Invalid( + f"Only one TMP102 {platform} may reference each temperature sensor" + ) + return config diff --git a/esphome/components/tmp102/binary_sensor.py b/esphome/components/tmp102/binary_sensor.py new file mode 100644 index 00000000000..eb23206b981 --- /dev/null +++ b/esphome/components/tmp102/binary_sensor.py @@ -0,0 +1,28 @@ +"""TMP102 comparator alert entity.""" + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "binary_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_BINARY_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(parent.set_alert_binary_sensor(var)) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index a1066b97619..09b29dd7ef4 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -10,8 +10,12 @@ static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; -#ifdef USE_TMP102_CONFIGURE +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR) static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; +static constexpr uint16_t TMP102_ALERT_BIT = 0x0020; +static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; +#endif +#ifdef USE_TMP102_CONFIGURE static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; @@ -20,7 +24,6 @@ static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; static constexpr uint16_t TMP102_ONE_SHOT_BIT = 0x8000; static constexpr uint16_t TMP102_SHUTDOWN_BIT = 0x0100; static constexpr uint16_t TMP102_THERMOSTAT_MODE_BIT = 0x0200; -static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; static constexpr uint16_t TMP102_EXTENDED_MODE_BIT = 0x0010; static constexpr uint32_t TMP102_ONESHOT_DELAY_MS = 40; @@ -44,6 +47,9 @@ void TMP102Component::dump_config() { } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); +#ifdef USE_TMP102_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); +#endif #ifdef USE_TMP102_CONFIGURE if (!this->configure_) return; @@ -83,13 +89,13 @@ void TMP102Component::update() { } #endif if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } this->set_timeout(READ_TEMP_TIMEOUT_ID, 50, [this]() { int16_t raw_temperature; if (this->read(reinterpret_cast(&raw_temperature), 2) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } raw_temperature = i2c::i2ctohs(raw_temperature); @@ -98,6 +104,10 @@ void TMP102Component::update() { ESP_LOGD(TAG, "Got Temperature=%.1f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); }); } @@ -108,7 +118,7 @@ void TMP102Component::update_configured_() { return; if (this->config_ & TMP102_SHUTDOWN_BIT) { if (!this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_ | TMP102_ONE_SHOT_BIT)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } this->set_timeout(READ_TEMP_TIMEOUT_ID, TMP102_ONESHOT_DELAY_MS, [this]() { this->read_temperature_(); }); @@ -134,7 +144,7 @@ bool TMP102Component::write_configuration_() { bool TMP102Component::check_configuration_() { uint16_t config; if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return false; } bool matches = (config & TMP102_CONFIGURATION_MASK) == (this->config_ & TMP102_CONFIGURATION_MASK); @@ -144,7 +154,7 @@ bool TMP102Component::check_configuration_() { uint16_t high, low; if (!this->read_byte_16(TMP102_REGISTER_HIGH_LIMIT, &high) || !this->read_byte_16(TMP102_REGISTER_LOW_LIMIT, &low)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return false; } matches = high == this->high_limit_ && low == this->low_limit_; @@ -153,7 +163,7 @@ bool TMP102Component::check_configuration_() { return true; ESP_LOGW(TAG, "Configuration changed; restoring configured settings and thresholds"); - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); if (!this->write_configuration_()) { ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); } @@ -163,14 +173,38 @@ bool TMP102Component::check_configuration_() { void TMP102Component::read_temperature_() { uint16_t raw; if (!this->read_byte_16(TMP102_REGISTER_TEMPERATURE, &raw)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } const float temperature = decode_temperature(raw, raw & 0x0001); ESP_LOGD(TAG, "Got Temperature=%.2f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); } #endif +#ifdef USE_TMP102_BINARY_SENSOR +void TMP102Component::invalidate_alert_() { + if (this->alert_binary_sensor_ != nullptr) + this->alert_binary_sensor_->invalidate_state(); +} + +bool TMP102Component::read_alert_state_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->set_warning_and_invalidate_alert_(); + ESP_LOGW(TAG, "Failed to read alert state"); + return false; + } + // AL equals POL while the comparator status is active. + const bool alert = bool(config & TMP102_ALERT_BIT) == bool(config & TMP102_ALERT_POLARITY_BIT); + this->alert_binary_sensor_->publish_state(alert); + return true; +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index b14b6c4cc1f..caf75e19be7 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -2,6 +2,9 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#ifdef USE_TMP102_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" @@ -11,6 +14,9 @@ namespace esphome::tmp102 { class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: +#ifdef USE_TMP102_BINARY_SENSOR + SUB_BINARY_SENSOR(alert) +#endif #ifdef USE_TMP102_CONFIGURE void setup() override; void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { @@ -24,6 +30,16 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void update() override; protected: + void set_warning_and_invalidate_alert_() { + this->status_set_warning(); +#ifdef USE_TMP102_BINARY_SENSOR + this->invalidate_alert_(); +#endif + } +#ifdef USE_TMP102_BINARY_SENSOR + bool read_alert_state_(); + void invalidate_alert_(); +#endif #ifdef USE_TMP102_CONFIGURE bool write_register_(uint8_t reg, uint16_t value); bool write_configuration_(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d219296ca57..28abdb2218c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -244,6 +244,7 @@ #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER diff --git a/tests/component_tests/tmp102/config/alert.yaml b/tests/component_tests/tmp102/config/alert.yaml new file mode 100644 index 00000000000..3e1e42197d3 --- /dev/null +++ b/tests/component_tests/tmp102/config/alert.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + name: Alert diff --git a/tests/component_tests/tmp102/config/duplicate_alert.yaml b/tests/component_tests/tmp102/config/duplicate_alert.yaml new file mode 100644 index 00000000000..c56d0d96fa4 --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_alert.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + tmp102_id: chip + name: Alert One + - platform: tmp102 + tmp102_id: chip + name: Alert Two diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index 020303d455d..49f2629baf8 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -7,6 +7,7 @@ import pytest from esphome.components import tmp102 as component from esphome.components.tmp102.sensor import CONFIG_SCHEMA +from esphome.config import read_config import esphome.config_validation as cv from esphome.core import CORE @@ -32,6 +33,31 @@ def test_advanced_options_enable_configuration_once( assert "USE_TMP102_CONFIGURE" in defines +def test_alert_entity_is_read_only( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("alert.yaml")) + assert "set_alert_binary_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_BINARY_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_alert_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_alert.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "Only one TMP102 binary_sensor may reference each temperature sensor" + in captured.out + ) + + @pytest.mark.parametrize( ("extended", "high", "low"), [ diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index 9702134e8c6..f86177c0f92 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,8 +6,10 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - CORE.testing_ensure_platform_registered("sensor") + for platform in ("sensor", "binary_sensor"): + CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") + cg.add_define("USE_TMP102_BINARY_SENSOR") manifest.to_code = to_code_testing - manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] + manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "binary_sensor"] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index c764eae42b2..1e5f1f456ce 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -15,3 +15,8 @@ sensor: fault_queue: 6 temperature_high: 140°C temperature_low: 120°C + +binary_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Alert diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index b148b07716c..cd06d8a83d7 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -47,6 +47,7 @@ class TMP102Test : public ::testing::Test { protected: RegisterBus bus_; TMP102Component chip_; + binary_sensor::BinarySensor alert_; unsigned publications_{0}; void SetUp() override { @@ -112,6 +113,83 @@ TEST_F(TMP102Test, WritableConfigurationFields) { EXPECT_EQ(this->bus_.registers[2], 0x2580); } +TEST_F(TMP102Test, AlertEntityPreservesHardwareAndUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + for (uint16_t config : {0x60A0, 0x6080, 0x64A0, 0x6480}) { + this->bus_.registers[1] = config; + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x20) == bool(config & 0x400)); + EXPECT_EQ(this->bus_.registers[1], config); + } + EXPECT_EQ(this->bus_.writes, 0); +} + +TEST_F(TMP102Test, FailedTemperatureOrAlertReadInvalidatesAlert) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + for (int reg : {0, 1}) { + this->bus_.fail_read = reg; + this->chip_.update(); + run_timers(55); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); + this->bus_.fail_read = -1; + this->chip_.update(); + run_timers(55); + EXPECT_FALSE(this->chip_.status_has_warning()); + EXPECT_TRUE(this->alert_.has_state()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + for (uint16_t config : {0x0080, 0x00A0, 0x0480, 0x04A0}) { + this->configure(config & ~0x0020); + this->chip_.setup(); + this->bus_.registers[1] = config; + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x0020) == bool(config & 0x0400)); + EXPECT_FALSE(this->chip_.status_has_warning()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertInvalidatesDuringRecoveryAndRepublishes) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0090, 0x2D00, 0x2A80); + this->chip_.setup(); + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + + this->bus_.registers = {0x1900, 0x60A0, 0x4B00, 0x5000}; + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + EXPECT_TRUE(this->chip_.status_has_warning()); + + this->chip_.update(); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotPublishesAlertAfterDelayedRead) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0180); + this->chip_.setup(); + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + run_timers(45); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_TRUE(this->alert_.state); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { this->configure(0x0090, 0x2D00, 0x2A80); this->chip_.setup(); @@ -170,13 +248,16 @@ TEST_F(TMP102Test, ReadOnlyConfigurationBitsDoNotTriggerRecovery) { } TEST_F(TMP102Test, OneShotTriggerFailureSetsWarning) { + this->chip_.set_alert_binary_sensor(&this->alert_); this->configure(0x0180); this->chip_.setup(); + this->alert_.publish_state(false); this->bus_.fail_write = 1; this->chip_.update(); run_timers(45); EXPECT_EQ(this->publications_, 0); EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); } TEST_F(TMP102Test, OneShotRestartsPendingReadAndReselectsTemperature) { From 22c17fe7e1552f3fe17e7e89d582a9685294d908 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:36:27 +0200 Subject: [PATCH 125/651] [remote_base] Add optional suffix bits to KeeLoq after the HCS301 word (#19891) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/remote_base/__init__.py | 39 +++- .../remote_base/keeloq_protocol.cpp | 63 ++++++- .../components/remote_base/keeloq_protocol.h | 9 +- .../config/keeloq_suffix.yaml | 33 ++++ .../remote_transmitter/test_keeloq_action.py | 18 ++ tests/components/remote_base/__init__.py | 2 +- tests/components/remote_base/keeloq_test.cpp | 177 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 4 +- .../remote_transmitter/common-buttons.yaml | 16 ++ .../components/remote_base/__init__.py | 1 + .../components/remote_base/test_keeloq.py | 45 +++++ 11 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml create mode 100644 tests/component_tests/remote_transmitter/test_keeloq_action.py create mode 100644 tests/components/remote_base/keeloq_test.cpp create mode 100644 tests/unit_tests/components/remote_base/__init__.py create mode 100644 tests/unit_tests/components/remote_base/test_keeloq.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index a880169fb20..1911218b1f7 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -57,6 +57,8 @@ DOMAIN = "remote_base" CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" +CONF_SUFFIX = "suffix" +CONF_SUFFIX_BITS = "suffix_bits" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -255,8 +257,12 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( ).extend(REMOTE_TRANSMITTABLE_SCHEMA) -def register_action(name, type_, schema): +def register_action( + name, type_, schema, *, validate: Callable[[ConfigType], ConfigType] | None = None +): validator = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) + if validate is not None: + validator = cv.All(validator, validate) registerer = automation.register_action( f"remote_transmitter.transmit_{name}", type_, @@ -939,6 +945,27 @@ KEELOQ_SCHEMA = cv.Schema( ) +def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: + # A lambda is only known on the device. A plain value has to fit the width. + suffix = config[CONF_SUFFIX] + bits = config[CONF_SUFFIX_BITS] + if isinstance(suffix, int) and isinstance(bits, int) and suffix >> bits: + raise cv.Invalid( + f"suffix 0x{suffix:X} does not fit in {bits} suffix_bits", [CONF_SUFFIX] + ) + return config + + +KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( + { + cv.Optional(CONF_SUFFIX, default=0): cv.All( + cv.hex_int, cv.Range(min=0, max=0xFFFF) + ), + cv.Optional(CONF_SUFFIX_BITS, default=0): cv.int_range(min=0, max=16), + } +) + + @register_binary_sensor("keeloq", KeeloqBinarySensor, KEELOQ_SCHEMA) def Keeloq_binary_sensor(var, config): cg.add( @@ -962,7 +989,9 @@ def keeloq_dumper(var, config): pass -@register_action("keeloq", KeeloqAction, KEELOQ_SCHEMA) +@register_action( + "keeloq", KeeloqAction, KEELOQ_ACTION_SCHEMA, validate=_keeloq_suffix_fits +) async def keeloq_action(var, config, args): template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint32) cg.add(var.set_address(template_)) @@ -972,6 +1001,12 @@ async def keeloq_action(var, config, args): cg.add(var.set_command(template_)) template_ = await cg.templatable(config[CONF_LEVEL], args, cg.bool_) cg.add(var.set_vlow(template_)) + # Without suffix bits nothing of the suffix is sent, so both stay at their C++ default. + if (suffix_bits := config[CONF_SUFFIX_BITS]) != 0: + template_ = await cg.templatable(config[CONF_SUFFIX], args, cg.uint16) + cg.add(var.set_suffix(template_)) + template_ = await cg.templatable(suffix_bits, args, cg.uint8) + cg.add(var.set_suffix_bits(template_)) # NEC diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index caa950218ec..fff8eb897f0 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -19,24 +19,29 @@ static constexpr uint8_t NBITS_SYNC_CNT = 16; static constexpr uint8_t NBITS_FIXED_DATA = NBITS_REPEAT + NBITS_VLOW + NBITS_BUTTONS + NBITS_SERIAL; static constexpr uint8_t NBITS_ENCRYPTED_DATA = NBITS_BUTTONS + NBITS_DISC + NBITS_SYNC_CNT; static constexpr uint8_t NBITS_DATA = NBITS_FIXED_DATA + NBITS_ENCRYPTED_DATA; +static constexpr uint8_t NBITS_SUFFIX_MAX = 16; +// A space this long is not part of the PWM: data spaces are at most 2 bit times, the guard is 39. +static constexpr uint32_t GAP_TIME_US = 4 * BIT_TIME_US; /* KeeLoq Protocol Coded using information from datasheet for Microchip HCS301 KeeLow Code Hopping Encoder -Encoder - Hopping code is generated at random. +Encoder - Hopping code is not generated. Send the encrypted field from the caller. +Optional suffix bits may follow the 66-bit HCS301 word (same PWM coding). Decoder - Hopping code is ignored and not checked when received. Serial number of -transmitter and nutton command is decoded. +transmitter and button command is decoded. Extra bits after the word are returned +as suffix / suffix_bits when present. */ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { uint32_t out_data = 0x0; - ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32, data.address, data.command, - data.encrypted); + ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32 " suffix_bits=%u", data.address, + data.command, data.encrypted, data.suffix_bits); ESP_LOGV(TAG, "Send Keeloq: data bits (%d + %d)", NBITS_ENCRYPTED_DATA, NBITS_FIXED_DATA); // Preamble = '01' x 12 @@ -92,6 +97,17 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->mark(1 * BIT_TIME_US); dst->space(2 * BIT_TIME_US); + const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; + for (uint8_t i = 0; i < extra; i++) { + if (data.suffix & (1 << i)) { + dst->mark(1 * BIT_TIME_US); + dst->space(2 * BIT_TIME_US); + } else { + dst->mark(2 * BIT_TIME_US); + dst->space(1 * BIT_TIME_US); + } + } + // Guard time at end of packet dst->space(39 * BIT_TIME_US); } @@ -103,10 +119,11 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { .command = 0, .repeat = false, .vlow = false, - + .suffix = 0, + .suffix_bits = 0, }; - if (src.size() != (NBITS_PREAMBLE + NBITS_DATA) * 2) { + if (src.size() < (NBITS_PREAMBLE + NBITS_DATA) * 2) { return {}; } @@ -182,11 +199,43 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { return {}; } + if (out.repeat) { + src.expect_space(2 * BIT_TIME_US); + } else { + src.expect_space(BIT_TIME_US); + } + + while (out.suffix_bits < NBITS_SUFFIX_MAX) { + bool one = false; + if (src.expect_mark(2 * BIT_TIME_US)) { + one = false; + } else if (src.expect_mark(BIT_TIME_US)) { + one = true; + } else { + break; + } + // The last bit's space is merged into the guard gap, or the capture ends at idle. + if (!src.expect_space(one ? 2 * BIT_TIME_US : BIT_TIME_US) && src.is_valid() && + !src.peek_space_at_least(GAP_TIME_US)) { + break; + } + if (one) { + out.suffix |= 1 << out.suffix_bits; + } + out.suffix_bits++; + } + // The suffix ends at a gap or at the end of the capture. PWM that goes on is not a suffix. + if (src.is_valid() && !src.peek_space_at_least(GAP_TIME_US)) { + out.suffix = 0; + out.suffix_bits = 0; + } + return out; } void KeeloqProtocol::dump(const KeeloqData &data) { - ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x", data.address, data.command); + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", suffix=0x%04x/%u", + data.address, data.command, data.encrypted, data.suffix, data.suffix_bits); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 335fbd164b1..093f2904aa2 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -11,10 +11,11 @@ struct KeeloqData { uint8_t command; // Button Status S2-S1-S0-S3 bool repeat; // Repeated command bit bool vlow; // Battery status bit + uint16_t suffix{0}; // Optional bits after the 66-bit HCS301 word + uint8_t suffix_bits{0}; bool operator==(const KeeloqData &rhs) const { - // Treat 0x10 as a special, wildcard button press - // This allows us to match on just the address if wanted. + // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { return false; } @@ -37,6 +38,8 @@ template class KeeloqAction : public RemoteTransmitterActionBase TEMPLATABLE_VALUE(uint32_t, encrypted) TEMPLATABLE_VALUE(uint8_t, command) TEMPLATABLE_VALUE(bool, vlow) + TEMPLATABLE_VALUE(uint16_t, suffix) + TEMPLATABLE_VALUE(uint8_t, suffix_bits) void encode(RemoteTransmitData *dst, Ts... x) override { KeeloqData data{}; @@ -44,6 +47,8 @@ template class KeeloqAction : public RemoteTransmitterActionBase data.encrypted = this->encrypted_.value(x...); data.command = this->command_.value(x...); data.vlow = this->vlow_.value(x...); + data.suffix = this->suffix_.value(x...); + data.suffix_bits = this->suffix_bits_.value(x...); KeeloqProtocol().encode(dst, data); } }; diff --git a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml new file mode 100644 index 00000000000..f14bc75f305 --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 100% + +button: + - platform: template + name: Plain word + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x57ffe7b + code: 0xd19ef0a9 + - platform: template + name: Constant suffix + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x116ea01 + code: 0xd19ef0a9 + suffix: 0x2A + suffix_bits: 6 + - platform: template + name: Lambda suffix + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x116ea01 + code: 0xd19ef0a9 + suffix: !lambda return 0x2A; + suffix_bits: !lambda return 6; diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py new file mode 100644 index 00000000000..aafab1f2c8e --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -0,0 +1,18 @@ +"""transmit_keeloq sets the suffix only when suffix bits are sent.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_keeloq_suffix_is_set_only_with_suffix_bits( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) + + # The plain word gets the same setters as before the suffix existed. + assert "remote_base_keeloqaction_id->set_suffix" not in main_cpp + # A constant and a lambda width both set the suffix. + for action in ("remote_base_keeloqaction_id_2", "remote_base_keeloqaction_id_3"): + assert f"{action}->set_suffix([]() -> uint16_t" in main_cpp + assert f"{action}->set_suffix_bits([]() -> uint8_t" in main_cpp diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index c52eb41ca44..90734b72f28 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: # Protocol sources are compiled only behind their define; keep the ones under test. - for protocol in ("hob2hood", "haier", "mirage", "aeha"): + for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq"): request_protocol(protocol) manifest.to_code = to_code_testing diff --git a/tests/components/remote_base/keeloq_test.cpp b/tests/components/remote_base/keeloq_test.cpp new file mode 100644 index 00000000000..e96d8b9d01d --- /dev/null +++ b/tests/components/remote_base/keeloq_test.cpp @@ -0,0 +1,177 @@ +#include +#include "esphome/components/remote_base/keeloq_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint32_t BIT_US = 380; + +KeeloqData frame(uint16_t suffix, uint8_t suffix_bits) { + KeeloqData data{}; + data.encrypted = 0xd19ef0a9; + data.address = 0x116ea01; + data.command = 0x08; + data.suffix = suffix; + data.suffix_bits = suffix_bits; + return data; +} + +RawTimings encode(const KeeloqData &data) { + RemoteTransmitData tx; + KeeloqProtocol().encode(&tx, data); + return tx.get_data(); +} + +// A receiver starts on the first mark and merges the guard into the previous space. +RawTimings as_received(RawTimings timings, bool drop_tail = false) { + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + RawTimings merged; + for (int32_t item : timings) { + if (!merged.empty() && (merged.back() < 0) == (item < 0)) { + merged.back() += item; + } else { + merged.push_back(item); + } + } + if (drop_tail && !merged.empty() && merged.back() < 0) { + merged.pop_back(); + } + return merged; +} + +optional decode(const RawTimings &timings) { + return KeeloqProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(uint16_t suffix, uint8_t suffix_bits, bool drop_tail = false) { + auto decoded = decode(as_received(encode(frame(suffix, suffix_bits)), drop_tail)); + ASSERT_TRUE(decoded.has_value()) << "suffix 0x" << std::hex << suffix << "/" << std::dec << int(suffix_bits); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x116ea01u); + EXPECT_EQ(decoded->command, 0x08); + EXPECT_EQ(decoded->encrypted, 0xd19ef0a9u); + EXPECT_TRUE(decoded->repeat); + EXPECT_FALSE(decoded->vlow); + EXPECT_EQ(decoded->suffix, suffix); + EXPECT_EQ(decoded->suffix_bits, suffix_bits); +} + +// 0xA5 sends 1, 0, 1 first. Replace the space of one of those bits while the other bits still follow. +optional decode_with_suffix_space(uint8_t bit, uint32_t space_us) { + RawTimings timings = as_received(encode(frame(0xA5, 8))); + timings[timings.size() - 2 * 8 + 2 * bit + 1] = -static_cast(space_us); + return decode(timings); +} + +} // namespace + +TEST(KeeloqProtocolTest, PlainWordHasNoSuffix) { expect_round_trip(0, 0); } + +TEST(KeeloqProtocolTest, SuffixRoundTripsWhenTheGuardSwallowsTheLastSpace) { + expect_round_trip(0x00, 8); + expect_round_trip(0x80, 8); + expect_round_trip(0xA5, 8); + expect_round_trip(0x0000, 16); + expect_round_trip(0x8000, 16); + expect_round_trip(0, 1); + expect_round_trip(1, 1); +} + +TEST(KeeloqProtocolTest, SuffixRoundTripsWhenTheCaptureEndsAtIdle) { + expect_round_trip(0x00, 8, true); + expect_round_trip(0x80, 8, true); + expect_round_trip(0x8000, 16, true); + expect_round_trip(1, 1, true); +} + +TEST(KeeloqProtocolTest, ExtraBitsThatEndAtIdleStayASuffix) { + RawTimings timings = encode(frame(0, 0)); + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + ASSERT_LT(timings.back(), 0); + timings.pop_back(); + for (uint8_t i = 0; i < 4; i++) { + timings.push_back(static_cast(2 * BIT_US)); + timings.push_back(-static_cast(BIT_US)); + } + + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0); + EXPECT_EQ(decoded->suffix_bits, 4); + } +} + +TEST(KeeloqProtocolTest, FollowingPwmIsNotASuffix) { + // Drop only the guard item. The repeat bit keeps its own space, then more PWM follows. + RawTimings timings = encode(frame(0, 0)); + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + ASSERT_FALSE(timings.empty()); + ASSERT_LT(timings.back(), 0); + timings.pop_back(); + for (uint8_t i = 0; i < 4; i++) { + timings.push_back(static_cast(2 * BIT_US)); + timings.push_back(-static_cast(BIT_US)); + } + timings.push_back(static_cast(3 * BIT_US)); + + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0); + EXPECT_EQ(decoded->suffix_bits, 0); + } +} + +TEST(KeeloqProtocolTest, ASpaceThatIsNeitherDataNorAGapDropsTheSuffix) { + // A 1 bit with the space of a 0 bit, and a 0 bit with the space of a 1 bit. + for (const auto &[bit, space_us] : {std::pair{2, BIT_US}, {1, 2 * BIT_US}}) { + auto decoded = decode_with_suffix_space(bit, space_us); + ASSERT_TRUE(decoded.has_value()) << "bit " << int(bit); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x116ea01u); + EXPECT_EQ(decoded->suffix, 0) << "bit " << int(bit); + EXPECT_EQ(decoded->suffix_bits, 0) << "bit " << int(bit); + } + } +} + +TEST(KeeloqProtocolTest, ASuffixFollowsARepeatBitOfZero) { + // The encoder always sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. + for (uint8_t suffix_bits : {0, 8}) { + RawTimings timings = as_received(encode(frame(suffix_bits > 0 ? 0xA5 : 0, suffix_bits))); + const size_t repeat = timings.size() - 2 * suffix_bits - 2; + timings[repeat] = static_cast(2 * BIT_US); + if (suffix_bits > 0) { + timings[repeat + 1] = -static_cast(BIT_US); + } + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << int(suffix_bits); + if (decoded.has_value()) { + EXPECT_FALSE(decoded->repeat); + EXPECT_EQ(decoded->suffix, suffix_bits > 0 ? 0xA5 : 0); + EXPECT_EQ(decoded->suffix_bits, suffix_bits); + } + } +} + +TEST(KeeloqProtocolTest, ASuffixEndsAtAGap) { + // What follows the gap is the next frame. + auto decoded = decode_with_suffix_space(2, 4 * BIT_US); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0x5); + EXPECT_EQ(decoded->suffix_bits, 3); + } +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 4dadec6a015..55f489505cb 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -71,8 +71,8 @@ on_jvc: on_keeloq: then: - logger.log: - format: "on_keeloq: %lu %lu %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command"] + format: "on_keeloq: %lu %lu %u %u %u" + args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits"] on_haier: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 776a03e6809..f168a825d36 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -316,3 +316,19 @@ button: on_press: - remote_transmitter.transmit_hob2hood: command: light_on + - platform: template + name: KeeLoq HCS301 + on_press: + remote_transmitter.transmit_keeloq: + address: 0x57ffe7b + command: 0x02 + code: 0xd19ef0a9 + - platform: template + name: KeeLoq with suffix + on_press: + remote_transmitter.transmit_keeloq: + address: 0x116ea01 + command: 0x08 + code: 0xd19ef0a9 + suffix: 0x00 + suffix_bits: 8 diff --git a/tests/unit_tests/components/remote_base/__init__.py b/tests/unit_tests/components/remote_base/__init__.py new file mode 100644 index 00000000000..d6233f0230e --- /dev/null +++ b/tests/unit_tests/components/remote_base/__init__.py @@ -0,0 +1 @@ +"""Unit tests for remote_base config helpers.""" diff --git a/tests/unit_tests/components/remote_base/test_keeloq.py b/tests/unit_tests/components/remote_base/test_keeloq.py new file mode 100644 index 00000000000..bd0f817dd1f --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_keeloq.py @@ -0,0 +1,45 @@ +"""KeeLoq suffix is a transmit key and has to fit in its width.""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.remote_base import ( + CONF_SUFFIX, + CONF_SUFFIX_BITS, + KEELOQ_SCHEMA, + _keeloq_suffix_fits, +) +import esphome.config_validation as cv +from esphome.core import Lambda + + +def test_binary_sensor_rejects_suffix() -> None: + with pytest.raises(cv.Invalid): + KEELOQ_SCHEMA( + {"address": 0x1, "code": 0x2, CONF_SUFFIX: 1, CONF_SUFFIX_BITS: 8} + ) + + +def test_plain_suffix_must_fit() -> None: + fitted = {"address": 0x1, "code": 0x2, CONF_SUFFIX: 0xA5, CONF_SUFFIX_BITS: 8} + assert _keeloq_suffix_fits(fitted)[CONF_SUFFIX] == 0xA5 + with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): + _keeloq_suffix_fits({**fitted, CONF_SUFFIX: 0x100}) + + +def test_lambda_suffix_is_not_checked_here() -> None: + config = {"address": 0x1, "code": 0x2, CONF_SUFFIX: "id(x)", CONF_SUFFIX_BITS: 4} + assert _keeloq_suffix_fits(config)[CONF_SUFFIX] == "id(x)" + + +def test_transmit_action_checks_the_width() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + base = {"address": 0x1, "code": 0x2, CONF_SUFFIX_BITS: 8} + assert schema({**base, CONF_SUFFIX: 0xA5})[CONF_SUFFIX] == 0xA5 + assert isinstance( + schema({**base, CONF_SUFFIX: Lambda("return 0x100;")})[CONF_SUFFIX], Lambda + ) + with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): + schema({**base, CONF_SUFFIX: 0x100}) From e50a0d902e2c1e5d4b72a2f52dfacecc7ffb51a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:42:21 -1000 Subject: [PATCH 126/651] [core] Expose the preferences storage helpers to components (#20535) --- esphome/preferences.py | 18 +++++------- tests/unit_tests/test_preferences.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/esphome/preferences.py b/esphome/preferences.py index fce85191302..6e7e4eda33d 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -19,18 +19,15 @@ STORAGE_FLASH = "flash" STORAGE_RTC = "rtc" -def _rtc_supported() -> bool: - """Whether the active platform has an RTC-backed preferences backend. - - Mirrors the C++ ``SOC_RTC_MEM_SUPPORTED`` guard in the ESP32 backend: the ESP32-C2 - and -C61 have no RTC memory at all, so RTC storage is unavailable there. - """ +def rtc_supported() -> bool: + """Whether the target has RTC-backed preferences (not ESP32-C2/C61); call at validation time.""" if CORE.is_esp8266: return True if CORE.is_esp32: from esphome.components.esp32 import get_esp32_variant from esphome.components.esp32.const import VARIANT_ESP32C2, VARIANT_ESP32C61 + # Mirrors the C++ SOC_RTC_MEM_SUPPORTED guard in the ESP32 backend return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) return False @@ -44,9 +41,10 @@ def _default_storage() -> str: return STORAGE_RTC if CORE.is_esp8266 else STORAGE_FLASH -def _validate_storage(value): +def validate_storage(value: object) -> str: + """Validate a ``storage:`` value, ``flash`` or ``rtc`` where supported.""" value = cv.one_of(STORAGE_FLASH, STORAGE_RTC, lower=True)(value) - if value == STORAGE_RTC and not _rtc_supported(): + if value == STORAGE_RTC and not rtc_supported(): raise cv.Invalid( f"'{STORAGE_RTC}' storage is not supported on this platform; only " f"'{STORAGE_FLASH}' is available" @@ -56,7 +54,7 @@ def _validate_storage(value): def storage_schema(): """Return an Optional(CONF_STORAGE) entry for merging into a component schema.""" - return {cv.Optional(CONF_STORAGE, default=_default_storage): _validate_storage} + return {cv.Optional(CONF_STORAGE, default=_default_storage): validate_storage} def request_rtc_storage() -> None: @@ -88,7 +86,7 @@ def validate_rtc_storage(value): "be disabled" ) return value - if not _rtc_supported(): + if not rtc_supported(): raise cv.Invalid("RTC preference storage is not supported on this platform") return value diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py index 677eeee7f26..a1ab78bfbff 100644 --- a/tests/unit_tests/test_preferences.py +++ b/tests/unit_tests/test_preferences.py @@ -147,3 +147,47 @@ def test_rtc_rejected_on_unsupported_platform() -> None: def test_flash_allowed_on_unsupported_platform() -> None: _set_platform(PLATFORM_RP2040) assert _validate({CONF_STORAGE: "flash"})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_rtc_supported_on_esp32_with_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert preferences.rtc_supported() is True + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_rtc_supported_false_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert preferences.rtc_supported() is False + + +@pytest.mark.parametrize( + ("platform", "expected"), + [(PLATFORM_ESP8266, True), (PLATFORM_RP2040, False)], +) +def test_rtc_supported_per_platform(platform: str, expected: bool) -> None: + _set_platform(platform) + assert preferences.rtc_supported() is expected + + +def test_validate_storage_accepts_flash_everywhere() -> None: + _set_platform(PLATFORM_RP2040) + assert preferences.validate_storage("flash") == preferences.STORAGE_FLASH + assert preferences.validate_storage("FLASH") == preferences.STORAGE_FLASH + + +def test_validate_storage_accepts_rtc_where_supported() -> None: + _set_esp32(VARIANT_ESP32) + assert preferences.validate_storage("rtc") == preferences.STORAGE_RTC + + +def test_validate_storage_rejects_rtc_without_rtc_memory() -> None: + _set_esp32(VARIANT_ESP32C2) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_storage("rtc") + + +def test_validate_storage_rejects_unknown_value() -> None: + _set_esp32(VARIANT_ESP32) + with pytest.raises(cv.Invalid): + preferences.validate_storage("nvs") From 59d31505e97f370d451b4b405652e2bceb29b391 Mon Sep 17 00:00:00 2001 From: Jamie Meredith <160557+jlmeredith@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:48:47 -0600 Subject: [PATCH 127/651] [mipi_spi] Add LCDwiki ES3C35P model (ST77922 QSPI) (#19011) Co-authored-by: Claude Opus 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mipi_spi/models/lcdwiki.py | 86 +++++++++++++++++++ .../mipi_spi/test_get_sequence.py | 23 +++++ 2 files changed, 109 insertions(+) create mode 100644 esphome/components/mipi_spi/models/lcdwiki.py diff --git a/esphome/components/mipi_spi/models/lcdwiki.py b/esphome/components/mipi_spi/models/lcdwiki.py new file mode 100644 index 00000000000..6ef8b1fad79 --- /dev/null +++ b/esphome/components/mipi_spi/models/lcdwiki.py @@ -0,0 +1,86 @@ +# Init sequence sourced from LCDwiki's own published Arduino example for this board: +# https://github.com/ydedox/st77922 (Example_01_Simple_test/Simple_test/Simple_test.ino) +# It is the commented-out panel table in that file, with the 0x71 and 0xBE values from the +# active one, which is what the shipped firmware writes. + +from esphome.components.mipi import MODE_RGB, SLPOUT, DriverChip, delay +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y + +# fmt: off +ES3C35P = DriverChip( + "ES3C35P", + width=320, + height=480, + draw_rounding=4, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, + color_order=MODE_RGB, + invert_colors=True, + bus_mode=TYPE_QUAD, + data_rate="40MHz", # datasheet caps the write clock at 62.5MHz + cs_pin=10, + reset_delay=120, + no_slpout=True, # SLPOUT is in the init sequence, the panel needs 120ms after it + requires={"psram"}, + initsequence=( + (0xF1, 0x00), + (0x60, 0x00, 0x00, 0x00), + (0x65, 0x80), + (0x79, 0x06), + (0x7B, 0x00, 0x08, 0x08), + (0x80, 0x55, 0x62, 0x2F, 0x17, 0xF0, 0x52, 0x70, 0xD2, 0x52, 0x62, 0xEA), + (0x81, 0x26, 0x52, 0x72, 0x27), + (0x84, 0x92, 0x25), + (0x87, 0x10, 0x10, 0x58, 0x00, 0x02, 0x3A), + (0x88, 0x00, 0x00, 0x2C, 0x10, 0x04, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x06), + (0x89, 0x00, 0x00, 0x00), + (0x8A, 0x13, 0x00, 0x2C, 0x00, 0x00, 0x2C, 0x10, 0x10, 0x00, 0x3E, 0x19), + (0x8B, 0x15, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x97, 0x8E), + (0x8C, 0x1D, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x50, 0x0F, 0x01, 0xC5, 0x12, 0x09), + (0x8D, 0x0C), + (0x8E, 0x33, 0x01, 0x0C, 0x13, 0x01, 0x01), + (0xB3, 0x00, 0x30), + (0x71, 0xD0), + (0x66, 0x02, 0x3F), + (0xBE, 0x26, 0x00, 0x9D), + (0x70, 0x01, 0xA6, 0x11, 0x40, 0xE0, 0x00, 0x11, 0x60, 0x11, 0x00, 0x00, 0x1A), + (0x90, 0x04, 0x04, 0x55, 0x74, 0x00, 0x40, 0x43, 0x2D, 0x2D), + (0x91, 0x04, 0x04, 0x55, 0x75, 0x00, 0x40, 0x42, 0x2D, 0x2D), + (0x92, 0x04, 0x44, 0x55, 0xC0, 0x06, 0x00, 0x07, 0x05, 0x90, 0x2D), + (0x93, 0x04, 0x43, 0x11, 0x00, 0x00, 0x00, 0x00, 0x05, 0x90, 0x2D), + (0x94, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0x95, 0x96, 0x16, 0x00, 0x00, 0xFF), + (0x96, 0x44, 0x53, 0x03, 0x12, 0x23, 0x24, 0x06, 0x05, 0x9A, 0x2D, 0x00, 0x44), + (0x97, 0x44, 0x53, 0x47, 0x56, 0x20, 0x20, 0x02, 0x01, 0x9A, 0x2D, 0x00, 0x44), + (0xBA, 0x55, 0x9A, 0x2D, 0x9A, 0x2D), + (0x9A, 0x40, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9B, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9C, 0x5C, 0x12, 0x00, 0x00, 0x10, 0x12, 0x00, 0x00, 0x10, 0x02, 0x00, 0x00, 0x00), + (0x9D, 0x8A, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0x9E, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0xB4, 0x1D, 0x1C, 0x1E, 0x0B, 0x14, 0x02, 0x13, 0x09, 0x1E, 0x00, 0x1E, 0x10), + (0xB5, 0x1D, 0x1C, 0x1E, 0x0A, 0x15, 0x03, 0x11, 0x08, 0x1E, 0x01, 0x1E, 0x12), + (0xB6, 0x77, 0x77, 0x00, 0x0A, 0xFF, 0x0A, 0xFF), + (0x86, 0xC6, 0x04, 0xB1, 0x02, 0x58, 0x12, 0x58, 0x0C, 0x13, 0x01, 0xA5, 0x00, 0xA5, 0xA5), + (0xB7, 0x07, 0x0A, 0x0E, 0x06, 0x05, 0x03, 0x2B, 0x03, 0x03, 0x42, 0x07, 0x10, 0x10, 0x2E, 0x3F, 0x0D), + (0xB8, 0x07, 0x0A, 0x0D, 0x05, 0x05, 0x02, 0x2B, 0x02, 0x03, 0x42, 0x06, 0x10, 0x0F, 0x2E, 0x3F, 0x0D), + (0xB9, 0x23, 0x23), + (0xBF, 0x10, 0x14, 0x14, 0x0B, 0x0B, 0x0B), + (0xF2, 0x00), + (0x73, 0x04, 0xDA, 0x12, 0x54, 0x47), + (0x77, 0x6B, 0x5B, 0xFD, 0xC3, 0xC5), + (0x7A, 0x15, 0x27), + (0x7B, 0x04, 0x57), + (0x7E, 0x01, 0x0E), + (0xBF, 0x36), + (0xE3, 0x40, 0x40), + (0xF0, 0x00), + (0xD0, 0x00), + (SLPOUT,), + delay(120), + ), +) +# fmt: on + +# The speakerless variant uses the same display +ES3C35P.extend("ES3C35P-NS") diff --git a/tests/component_tests/mipi_spi/test_get_sequence.py b/tests/component_tests/mipi_spi/test_get_sequence.py index f8614b17a3a..118f844c848 100644 --- a/tests/component_tests/mipi_spi/test_get_sequence.py +++ b/tests/component_tests/mipi_spi/test_get_sequence.py @@ -8,8 +8,11 @@ from esphome.components.mipi import ( BRIGHTNESS, CONF_INVERT_COLORS, CONF_PIXEL_MODE, + SLPOUT, + SWRESET, DriverChip, ) +from esphome.components.mipi_spi.display import MODELS from esphome.const import CONF_BRIGHTNESS # A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run @@ -96,3 +99,23 @@ def test_get_sequence_omits_brightness_when_not_set() -> None: sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False) assert not any(_has_brightness_command(sequence, v) for v in range(256)) + + +def _count_subsequence(sequence: tuple[int, ...], part: tuple[int, ...]) -> int: + return sum( + sequence[i : i + len(part)] == part + for i in range(len(sequence) - len(part) + 1) + ) + + +def test_es3c35p_waits_after_reset_and_issues_its_own_slpout() -> None: + """The ES3C35P settles 120ms after reset and sends SLPOUT itself with a 120ms wait.""" + sequence = MODELS["ES3C35P"].get_sequence( + _BASE_CONFIG, add_madctl=False, add_reset=True + ) + + assert sequence[:4] == (SWRESET, 0, 120, 255) + # Exactly one SLPOUT, followed by the panel's 120ms settle, and none appended by the framework + assert _count_subsequence(sequence, (SLPOUT, 0, 120, 255)) == 1 + assert _count_subsequence(sequence, (SLPOUT, 0, 10, 255)) == 0 + assert "ES3C35P-NS" in MODELS From 643f29fae6434393bea9c64cff62edf8ebe8d562 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:49:19 +0200 Subject: [PATCH 128/651] [uart] Let a UART say it has no line timing (#20227) --- esphome/components/ble_nus/__init__.py | 3 +- esphome/components/tcp_uart/__init__.py | 2 +- esphome/components/uart/__init__.py | 29 ++++++++++ esphome/components/usb_cdc_acm/__init__.py | 2 +- esphome/components/usb_uart/__init__.py | 5 +- tests/component_tests/uart/test_unclocked.py | 57 ++++++++++++++++++++ 6 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/uart/test_unclocked.py diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index 54f111e4af9..5d431622412 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -3,6 +3,7 @@ from esphome.components.logger import request_log_listener from esphome.components.uart import ( UARTComponent, debug_to_code, + mark_unclocked, maybe_empty_debug, uart_ns, ) @@ -42,7 +43,7 @@ def validate_rx_buffer(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(BLENUS), + cv.GenerateID(): cv.All(cv.declare_id(BLENUS), mark_unclocked), cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of( *[CONF_LOGS, CONF_UART], lower=True ), diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 35326d77d52..df9c1720ee6 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -35,7 +35,7 @@ TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) BASE_SCHEMA = cv.Schema( { - cv.GenerateID(): cv.declare_id(TcpUart), + cv.GenerateID(): cv.All(cv.declare_id(TcpUart), uart.mark_unclocked), cv.Required(CONF_PORT): cv.port, cv.Optional(CONF_BAUD_RATE, default=9600): cv.int_range(min=1), cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 2250b9d5088..5eea8060ff9 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field from logging import getLogger import math import re @@ -444,6 +445,34 @@ UART_DEVICE_SCHEMA = cv.Schema( KEY_UART_DEVICES = "uart_devices" +@dataclass +class UARTData: + # UARTs whose received bytes do not keep the timing of a serial line. + unclocked: list[ID] = field(default_factory=list) + + +def _get_data() -> UARTData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UARTData() + return CORE.data[DOMAIN] + + +def mark_unclocked(uart_id: ID) -> ID: + """Record that the bytes of uart_id do not arrive with the timing of a serial line, e.g. over TCP or USB. + + A gap between them says nothing about where a frame ends. Use it on the UART's declared id, + cv.All(cv.declare_id(MyUart), uart.mark_unclocked), so that every to_code sees it. + """ + _get_data().unclocked.append(uart_id) + return uart_id + + +def is_unclocked(uart_id: ID) -> bool: + """Return True if uart_id was passed to mark_unclocked().""" + # By name: a generated id is named only after the schemas ran. + return any(str(uart) == str(uart_id) for uart in _get_data().unclocked) + + def final_validate_device_schema( name: str, *, diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index e49531c80bc..08cd13e3d27 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -29,7 +29,7 @@ USBCDCACMInstance = usb_cdc_acm_ns.class_( # Schema for individual CDC ACM interface instances INTERFACE_SCHEMA = cv.Schema( { - cv.GenerateID(): cv.declare_id(USBCDCACMInstance), + cv.GenerateID(): cv.All(cv.declare_id(USBCDCACMInstance), uart.mark_unclocked), } ) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 581727b326a..0875966071d 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -6,6 +6,7 @@ from esphome.components.uart import ( CONF_FLUSH_TIMEOUT, UARTComponent, UARTParityOptions, + mark_unclocked, ) from esphome.components.usb_host import ( get_max_packet_size, @@ -162,7 +163,9 @@ def channel_schema(type_: "Type") -> cv.Schema: cv.ensure_list( cv.Schema( { - cv.GenerateID(): cv.declare_id(USBUartChannel), + cv.GenerateID(): cv.All( + cv.declare_id(USBUartChannel), mark_unclocked + ), cv.Optional(CONF_BUFFER_SIZE, default=256): cv.int_range( min=64, max=8192 ), diff --git a/tests/component_tests/uart/test_unclocked.py b/tests/component_tests/uart/test_unclocked.py new file mode 100644 index 00000000000..bbd131037d3 --- /dev/null +++ b/tests/component_tests/uart/test_unclocked.py @@ -0,0 +1,57 @@ +"""Tests for marking UARTs whose bytes do not arrive with the timing of a serial line.""" + +from esphome.components import ble_nus, tcp_uart, uart, usb_cdc_acm +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + + +def test_only_a_marked_uart_is_unclocked() -> None: + marked = ID("link", is_declaration=True) + assert uart.mark_unclocked(marked) is marked + assert uart.is_unclocked(ID("link")) + assert not uart.is_unclocked(ID("bus")) + + +def test_a_generated_id_is_found_once_named() -> None: + generated = uart.mark_unclocked( + ID(None, is_declaration=True, type=uart.UARTComponent) + ) + name = generated.resolve([]) + assert uart.is_unclocked(ID(name)) + + +def test_tcp_uart_is_unclocked() -> None: + tcp_uart.CONFIG_SCHEMA({CONF_ID: "link", "role": "server", "port": 6638}) + assert uart.is_unclocked(ID("link")) + + +def test_every_usb_uart_channel_is_unclocked( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data={"variant": "ESP32S3"}) + # Its schema is built on import and needs the target. + from esphome.components import usb_uart + + schema = usb_uart.channel_schema(usb_uart._TYPES_BY_NAME["FT2232"]) + schema( + { + "channels": [ + {CONF_ID: "ch_a", "baud_rate": 9600}, + {CONF_ID: "ch_b", "baud_rate": 115200}, + ] + } + ) + assert uart.is_unclocked(ID("ch_a")) + assert uart.is_unclocked(ID("ch_b")) + + +def test_usb_cdc_acm_interface_is_unclocked() -> None: + usb_cdc_acm.INTERFACE_SCHEMA({CONF_ID: "cdc"}) + assert uart.is_unclocked(ID("cdc")) + + +def test_ble_nus_is_unclocked(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.NRF52_ZEPHYR) + ble_nus.CONFIG_SCHEMA({CONF_ID: "nus", "type": "uart"}) + assert uart.is_unclocked(ID("nus")) From 40425610d5b19dfe8ac6e5cc9ac8593cb89a2ceb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:55:22 -1000 Subject: [PATCH 129/651] [remote_base] Keep constant abbwelcome payloads in shared flash tables (#20311) --- esphome/components/remote_base/__init__.py | 21 +++++------ .../remote_base/abbwelcome_protocol.h | 35 +++++------------- .../config/abbwelcome_payload_tables.yaml | 36 +++++++++++++++++++ .../test_abbwelcome_payload_tables.py | 21 +++++++++++ 4 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml create mode 100644 tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 1911218b1f7..eca57bca8a3 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -44,7 +44,7 @@ from esphome.const import ( CONF_WAND_ID, CONF_ZERO, ) -from esphome.core import ID, coroutine +from esphome.core import coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, TemplateArgsType @@ -2393,17 +2393,14 @@ async def abbwelcome_action(var, config, args): await cg.templatable(config[CONF_MESSAGE_ID], args, cg.uint8) ) ) - if CONF_DATA in config: - data_ = config[CONF_DATA] - if cg.is_template(data_): - template_ = await cg.templatable( - data_, args, cg.std_vector.template(cg.uint8) - ) - cg.add(var.set_data_template(template_)) - else: - arr_id = ID(f"{var.base}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data_)) - cg.add(var.set_data_static(arr, len(data_))) + # TemplatableBytes starts in lambda mode with no function, so a transmit without data sets an empty payload + await automation.templatable_bytes( + config.get(CONF_DATA, []), + args, + var.set_data_template, + var.set_data_static, + "abbwelcome_data", + ) # Mirage diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index a309c124eed..901950d3634 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -112,13 +112,14 @@ class ABBWelcomeData { } void set_message_id(uint8_t message_id) { this->data_[4 + 2 * this->get_address_length()] = message_id; } uint8_t get_message_id() const { return this->data_[4 + 2 * this->get_address_length()]; } - void set_data(std::vector data) { - uint8_t size = std::min(MAX_DATA_LENGTH, static_cast(data.size())); + void set_data(const uint8_t *data, size_t len) { + uint8_t size = static_cast(std::min(MAX_DATA_LENGTH, len)); this->data_[2] &= (0xff ^ DATA_LENGTH_MASK); this->data_[2] |= (size & DATA_LENGTH_MASK); if (size) - std::copy_n(data.begin(), size, this->data_.begin() + 5 + 2 * this->get_address_length()); + std::copy_n(data, size, this->data_.begin() + 5 + 2 * this->get_address_length()); } + void set_data(const std::vector &data) { this->set_data(data.data(), data.size()); } std::vector get_data() const { std::vector data(this->data_.begin() + 5 + 2 * this->get_address_length(), this->data_.begin() + 5 + 2 * this->get_address_length() + this->get_data_size()); @@ -233,14 +234,8 @@ template class ABBWelcomeAction : public RemoteTransmitterAction TEMPLATABLE_VALUE(uint8_t, message_type) TEMPLATABLE_VALUE(uint8_t, message_id) TEMPLATABLE_VALUE(bool, auto_message_id) - void set_data_template(std::vector (*func)(Ts...)) { - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) + void encode(RemoteTransmitData *dst, Ts... x) override { ABBWelcomeData data; data.set_three_byte_address(this->three_byte_address_.value(x...)); @@ -250,25 +245,11 @@ template class ABBWelcomeAction : public RemoteTransmitterAction data.set_message_type(this->message_type_.value(x...)); data.set_message_id(this->message_id_.value(x...)); data.auto_message_id = this->auto_message_id_.value(x...); - std::vector data_vec; - if (this->len_ > 0) { - // Static mode: copy from flash to vector - data_vec.assign(this->data_.data, this->data_.data + this->len_); - } else if (this->len_ < 0) { - // Template mode: call function - data_vec = this->data_.func(x...); - } - data.set_data(data_vec); + this->data_.template visit( + [&data](const uint8_t *bytes, size_t len) { data.set_data(bytes, len); }, x...); data.finalize(); ABBWelcomeProtocol().encode(dst, data); } - - protected: - ssize_t len_{0}; // <0 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } data_; }; } // namespace esphome::remote_base diff --git a/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml b/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml new file mode 100644 index 00000000000..66e0fe4e7fa --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml @@ -0,0 +1,36 @@ +--- +esphome: + name: test + on_boot: + then: + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x01 + data: [0x10, 0x20, 0x30] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x02 + data: [0x10, 0x20, 0x30] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x03 + data: [] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x04 + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x05 + data: !lambda return {0x09}; + +esp32: + board: esp32dev + +remote_transmitter: + pin: 4 + carrier_duty_percent: 100% diff --git a/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py new file mode 100644 index 00000000000..a653b5137ef --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py @@ -0,0 +1,21 @@ +"""Tests for abbwelcome constant data in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_constant_data_shares_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal data shares one table, empty or missing data is an empty table, lambdas stay templates.""" + main_cpp = generate_main(component_config_path("abbwelcome_payload_tables.yaml")) + + tables = re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + assert [v for _, v in tables] == ["{0x10, 0x20, 0x30}"] + assert main_cpp.count(f"set_data_static({tables[0][0]}, 3);") == 2 + assert main_cpp.count("set_data_static(nullptr, 0);") == 2 + assert main_cpp.count("set_data_template(") == 1 From da0c59a57c6abc9e8fc176c17f949ed947a71f22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:57:06 -1000 Subject: [PATCH 130/651] [http_request] Keep action request headers in a shared flash table (#20225) --- esphome/components/http_request/__init__.py | 28 ++++++-- .../components/http_request/http_request.h | 21 ++++-- .../http_request/config/request_headers.yaml | 72 +++++++++++++++++++ .../http_request/test_request_headers.py | 37 ++++++++++ 4 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/http_request/config/request_headers.yaml create mode 100644 tests/component_tests/http_request/test_request_headers.py diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index a2c181a5ae0..89fe5f8b1cc 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -39,6 +39,7 @@ HttpRequestIDF = http_request_ns.class_("HttpRequestIDF", HttpRequestComponent) HttpRequestHost = http_request_ns.class_("HttpRequestHost", HttpRequestComponent) HttpContainer = http_request_ns.class_("HttpContainer") +RequestHeader = http_request_ns.struct("RequestHeader") HttpRequestSendAction = http_request_ns.class_( "HttpRequestSendAction", automation.Action @@ -360,12 +361,27 @@ async def http_request_action_to_code( for key in json_: template_ = await cg.templatable(json_[key], args, cg.std_string) cg.add(var.add_json(key, template_)) - request_headers = config.get(CONF_REQUEST_HEADERS, {}) - if request_headers: - cg.add(var.init_request_headers(len(request_headers))) - for key, value in request_headers.items(): - template_ = await cg.templatable(value, args, cg.const_char_ptr) - cg.add(var.add_request_header(key, template_)) + if request_headers := config.get(CONF_REQUEST_HEADERS): + # A user lambda may keep static state, so only all-constant lists are shared + share = not any(map(cg.is_template, request_headers.values())) + # Constant headers ignore their arguments, so drop the names: identical lists + # from scripts with different parameter names then render the same table. + header_args = [(arg_type, "") for arg_type, _ in args] if share else args + headers = [ + cg.StructInitializer( + "", + ("name", key), + ("value", await cg.templatable(value, header_args, cg.const_char_ptr)), + ) + for key, value in request_headers.items() + ] + table = cg.shared_progmem_array( + "http_request_headers", + RequestHeader.template(*[arg_type for arg_type, _ in args]), + headers, + share=share, + ) + cg.add(var.set_request_headers(table, len(headers))) for value in config.get(CONF_COLLECT_HEADERS, []): cg.add(var.add_collect_header(value.lower())) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 68696118ea8..424fcf84e86 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -22,6 +22,12 @@ struct Header { std::string value; }; +/// One request header of an action; codegen puts these in a shared flash table. +template struct RequestHeader { + const char *name; + const char *(*value)(Ts...); +}; + // Some common HTTP status codes enum HttpStatus { HTTP_STATUS_OK = 200, @@ -492,9 +498,12 @@ template class HttpRequestSendAction final : public Actionrequest_headers_.init(count); } - void add_request_header(const char *key, TemplatableFn value) { - this->request_headers_.push_back({key, value}); + static_assert(std::is_trivially_copyable_v> && + sizeof(RequestHeader) % sizeof(uint32_t) == 0, + "ESP8266 reads request headers from flash in whole words"); + /// Codegen only: the table must outlive the action. + void set_request_headers(const RequestHeader *headers, size_t count) { + this->request_headers_ = ConstVector>(headers, count); } void add_collect_header(const char *value) { this->lower_case_collect_headers_.emplace_back(value); } @@ -530,8 +539,8 @@ template class HttpRequestSendAction final : public Action request_headers; request_headers.reserve(this->request_headers_.size()); - for (const auto &[key, val] : this->request_headers_) { - request_headers.push_back({key, val.value(x...)}); + for (const auto &header : this->request_headers_) { + request_headers.push_back({header.name, header.value(x...)}); } auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, @@ -596,7 +605,7 @@ template class HttpRequestSendAction final : public Action>> request_headers_{}; + ConstVector> request_headers_{}; std::vector lower_case_collect_headers_{"content-type", "content-length"}; FixedVector>> json_{}; std::function json_func_{nullptr}; diff --git a/tests/component_tests/http_request/config/request_headers.yaml b/tests/component_tests/http_request/config/request_headers.yaml new file mode 100644 index 00000000000..bdd4cd66304 --- /dev/null +++ b/tests/component_tests/http_request/config/request_headers.yaml @@ -0,0 +1,72 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +http_request: + +button: + - platform: template + id: shared_a + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - platform: template + id: shared_b + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - platform: template + id: other + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: text/plain + - platform: template + id: lambda_a + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: !lambda return "a"; + - platform: template + id: lambda_b + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: !lambda return "a"; + +script: + - id: param_a + parameters: + level: int + then: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - id: param_b + parameters: + value: int + then: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a diff --git a/tests/component_tests/http_request/test_request_headers.py b/tests/component_tests/http_request/test_request_headers.py new file mode 100644 index 00000000000..4eefa55b20b --- /dev/null +++ b/tests/component_tests/http_request/test_request_headers.py @@ -0,0 +1,37 @@ +"""Request headers are PROGMEM tables, shared only between all-constant lists.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_request_headers_share_constant_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("request_headers.yaml")) + + # Actions in config order: the five buttons, then the two scripts + tables = [ + (table, int(count)) + for table, count in re.findall( + r"->set_request_headers\((\w+), (\d+)\);", main_cpp + ) + ] + assert len(tables) == 7 + shared_a, shared_b, other, lambda_a, lambda_b, param_a, param_b = tables + assert shared_a == shared_b + assert shared_a[1] == 2 + assert other[1] == 1 + # A list with a lambda may keep static state, so it never shares even with an identical one + assert lambda_a[0] != lambda_b[0] + assert len({shared_a[0], other[0], lambda_a[0], lambda_b[0]}) == 4 + for table, _ in tables: + assert re.search(rf"\b{table}\[\] PROGMEM = \{{", main_cpp) + # Constant headers ignore their arguments, so scripts whose parameters differ + # only by name share a table, and it is the only one of its argument type + assert param_a == param_b + assert param_a[0] not in {shared_a[0], other[0], lambda_a[0], lambda_b[0]} + assert re.findall(r"RequestHeader<[^>]+> (\w+)\[\] PROGMEM = \{", main_cpp) == [ + param_a[0] + ] From e16b3a6245f4322c26b4756fe848d3e12c14c3fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:57:20 -1000 Subject: [PATCH 131/651] [api] Share one flash string pool between API action and Home Assistant action strings (#20281) --- esphome/codegen.py | 1 + esphome/components/api/__init__.py | 40 +++-------------- esphome/cpp_generator.py | 13 ++++++ .../api/test_action_metadata.py | 44 ++++++++++++------- tests/unit_tests/test_cpp_generator.py | 31 +++++++++++++ 5 files changed, 80 insertions(+), 49 deletions(-) diff --git a/esphome/codegen.py b/esphome/codegen.py index 4db01a21740..9ed36de0d55 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -44,6 +44,7 @@ from esphome.cpp_generator import ( # noqa: F401 safe_exp, set_cpp_standard, shared_progmem_array, + shared_progmem_string, statement, static_const_array, static_function, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index f538c0b5e50..f105c1d7f79 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -54,7 +54,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority from esphome.cpp_generator import Expression, MockObj, TemplateArgsType import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash +from esphome.helpers import fnv1_hash from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigFragmentType, ConfigType @@ -537,28 +537,11 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -def _add_action_strings( - index: int, strings: list[str | None], interned: dict[str, MockObj] -) -> MockObj: - """Emit the PROGMEM string table for one action. - - Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical - strings are shared between actions through `interned`. - """ - entries: list[MockObj] = [] - for string in strings: - if string is None: - entries.append(cg.nullptr) - continue - if (var := interned.get(string)) is None: - var = interned[string] = cg.progmem_array( - ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char), - string, - ) - entries.append(var) +def _add_action_strings(index: int, strings: list[str | None]) -> MockObj: + """Emit the PROGMEM string table for one action.""" return cg.progmem_array( ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr), - entries, + [cg.nullptr if s is None else cg.shared_progmem_string(s) for s in strings], ) @@ -611,7 +594,6 @@ async def to_code(config: ConfigType) -> None: has_metadata = _has_action_metadata(actions) if has_metadata: cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA") - interned_strings: dict[str, MockObj] = {} # Collect all triggers first, then register all at once with initializer_list triggers: list[cg.MockObj] = [] for index, conf in enumerate(actions): @@ -656,7 +638,7 @@ async def to_code(config: ConfigType) -> None: service_template_args.append(native) func_args.append((native, name)) strings = _action_strings(conf, has_metadata) - table = _add_action_strings(index, strings, interned_strings) + table = _add_action_strings(index, strings) if CORE.is_esp8266: scratch_size = max(scratch_size, _action_strings_size(strings)) # Template args: supports_response mode, then user service arg types @@ -828,14 +810,6 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) -def _field_string(value: str) -> Expression: - # ESP8266 can only keep a string in flash as its own PROGMEM array - literal = cg.RawExpression(cpp_string_escape(value)) - if CORE.is_esp8266: - return cg.shared_progmem_array("ha_field_str", cg.char, literal) - return literal - - async def _new_service_call_action( server_id: ID, action_id: ID, @@ -858,12 +832,12 @@ async def _new_service_call_action( ) async def field(key: str | None, value: Any, output_type: Any = None) -> Expression: - key_exp = cg.nullptr if key is None else _field_string(key) + key_exp = cg.nullptr if key is None else cg.shared_progmem_string(key) if cg.is_template(value): # output_type=None lets lambdas return numbers or char pointers; C++ converts them lam = await cg.process_lambda(value, args, return_type=output_type) return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})") - return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr) + return cg.ArrayInitializer(key_exp, cg.shared_progmem_string(value), cg.nullptr) entries = [await field(None, service, cg.std_string)] for group in groups: diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 9c9fed66012..1196669662c 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -540,6 +540,19 @@ def shared_progmem_array( return array +def shared_progmem_string(value: str) -> Expression: + """Return a string that stays in flash: one shared PROGMEM array per text on ESP8266. + + Other platforms keep string literals in flash already, so they get the plain literal. + """ + from esphome.cpp_types import char + + text = StringLiteral(value) + if CORE.is_esp8266: + return shared_progmem_array("progmem_str", char, text) + return text + + def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index adbfdf306eb..69c9605a675 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -2,6 +2,7 @@ from collections.abc import Callable from pathlib import Path +import re import pytest @@ -28,35 +29,46 @@ CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yam def test_metadata_is_emitted_as_progmem_table( generate_main: Callable[[str | Path], str], ) -> None: - """Every action string is a PROGMEM array referenced from one PROGMEM table.""" + """Every action string is a literal referenced from one PROGMEM table.""" main_cpp = generate_main(CONFIG) - assert ( - 'static constexpr char api_action_str0[] PROGMEM = "play_buzzer";' in main_cpp - ) - assert ( - 'static constexpr char api_action_str1[] PROGMEM = "Play an RTTTL melody on the buzzer";' - in main_cpp - ) - assert ( - 'static constexpr char api_action_str4[] PROGMEM = "two_short:d=4,o=5,b=100:16e6,16e6";' - in main_cpp - ) assert ( "static constexpr const char * api_action0_strings[] PROGMEM = {" - "api_action_str0, api_action_str1, api_action_str2, api_action_str3, " - "api_action_str4, api_action_str5, nullptr, nullptr};" in main_cpp + '"play_buzzer", "Play an RTTTL melody on the buzzer", "song_str", ' + '"RTTTL melody string", "two_short:d=4,o=5,b=100:16e6,16e6", "volume", ' + "nullptr, nullptr};" in main_cpp ) # An action without metadata still carries the metadata slots (as nullptr) assert ( "static constexpr const char * api_action1_strings[] PROGMEM = {" - "api_action_str6, nullptr, api_action_str7, nullptr, nullptr};" in main_cpp + '"plain_action", nullptr, "value", nullptr, nullptr};' in main_cpp ) assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines} assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None +def test_esp8266_strings_are_shared_progmem_arrays( + generate_main: Callable[[str | Path], str], +) -> None: + """On ESP8266 each string is its own PROGMEM array, referenced from the table.""" + main_cpp = generate_main(CONFIG_ESP8266) + + def array_for(text: str) -> str: + match = re.search( + rf'static constexpr char (progmem_str\w*)\[\] PROGMEM = "{text}";', main_cpp + ) + assert match, text + return match.group(1) + + assert array_for("play_buzzer") + assert ( + "static constexpr const char * api_action1_strings[] PROGMEM = {" + f"{array_for('plain_action')}, nullptr, {array_for('value')}, nullptr, nullptr}};" + in main_cpp + ) + + def test_esp8266_sizes_scratch_buffer_for_largest_action( generate_main: Callable[[str | Path], str], ) -> None: @@ -76,7 +88,7 @@ def test_shorthand_variables_emit_no_metadata( assert ( "static constexpr const char * api_action0_strings[] PROGMEM = " - "{api_action_str0, api_action_str1};" in main_cpp + '{"plain_action", "value"};' in main_cpp ) assert "USE_API_USER_DEFINED_ACTION_METADATA" not in {d.name for d in CORE.defines} diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 6fdd197c952..9529e448008 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -4,6 +4,12 @@ import math import pytest from esphome import cpp_generator as cg, cpp_types as ct +from esphome.const import ( + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) from esphome.core import CORE, ID, EsphomeError @@ -887,6 +893,31 @@ class TestSharedProgmemArray: assert str(array) == "table_2" +class TestProgmemString: + @staticmethod + def _set_platform(platform: str) -> None: + CORE.config = {} + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + def test_esp8266_shares_one_array_per_text(self) -> None: + self._set_platform(PLATFORM_ESP8266) + a = cg.shared_progmem_string("level") + b = cg.shared_progmem_string("level") + c = cg.shared_progmem_string('say "hi"') + assert a is b + assert str(a) == "progmem_str" + assert str(c) == "progmem_str_2" + assert [str(st) for st in CORE.global_statements] == [ + 'static constexpr char progmem_str[] PROGMEM = "level";', + 'static constexpr char progmem_str_2[] PROGMEM = "say \\042hi\\042";', + ] + + def test_other_platforms_return_the_literal(self) -> None: + self._set_platform(PLATFORM_ESP32) + assert str(cg.shared_progmem_string('say "hi"')) == '"say \\042hi\\042"' + assert not CORE.global_statements + + def test_extern_progmem_array_is_an_externally_linked_global() -> None: array = cg.extern_progmem_array("esphome::api::API_TABLE", ct.uint8, [1, 2]) assert str(array) == "esphome::api::API_TABLE" From 9295342c16f7eecdb69f1f2730deb91845c17616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:58:49 -1000 Subject: [PATCH 132/651] [event] Keep event types in a shared flash table (#20217) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_pb2.h | 2 +- esphome/components/event/__init__.py | 4 +- esphome/components/event/event.cpp | 7 +-- esphome/components/event/event.h | 26 +++++++--- tests/component_tests/event/__init__.py | 0 .../event/config/event_types.yaml | 19 +++++++ .../component_tests/event/test_event_types.py | 25 +++++++++ tests/components/event/event_types_test.cpp | 51 +++++++++++++++++++ 9 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/event/__init__.py create mode 100644 tests/component_tests/event/config/event_types.yaml create mode 100644 tests/component_tests/event/test_event_types.py create mode 100644 tests/components/event/event_types_test.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 1b06348e2ce..f8f31d81df0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2570,7 +2570,7 @@ message ListEntitiesEventResponse { EntityCategory entity_category = 7; string device_class = 8 [(max_data_length) = 47]; - repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector"]; + repeated string event_types = 9 [(container_pointer_no_template) = "ConstVector"]; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; } message EventResponse { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 17436d33aab..14204e97aaa 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3460,7 +3460,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); } #endif StringRef device_class{nullptr, 0}; // null until set, encode only - const FixedVector *event_types{}; + const ConstVector *event_types{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index c40130634f9..b09d9791414 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -98,7 +98,9 @@ async def setup_event_core_( ) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - cg.add(var.set_event_types(event_types)) + if event_types: + table = cg.shared_progmem_array("event_types", cg.const_char_ptr, event_types) + cg.add(var.set_event_types_static(table, len(event_types))) setup_device_class(config) diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 3170c7a8dd0..671e11e7d21 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -28,11 +28,8 @@ void Event::trigger(const char *event_type) { #endif } -void Event::set_event_types(const FixedVector &event_types) { - this->types_.init(event_types.size()); - for (const char *type : event_types) { - this->types_.push_back(type); - } +void Event::set_event_types_copy_(const char *const *event_types, size_t count) { + this->types_.assign_copy(event_types, count); this->last_event_type_ = nullptr; // Reset when types change } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index f57327e33f0..203ee52ac8e 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -11,6 +11,9 @@ namespace esphome::event { +/// A codegen table in flash, or a heap copy of a runtime list (see ConstVector). +using EventTypes = ConstVector; + #define LOG_EVENT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ @@ -24,20 +27,29 @@ class Event : public EntityBase { void trigger(const char *event_type); void trigger(const std::string &event_type) { this->trigger(event_type.c_str()); } - /// Set the event types supported by this event; called by generated code with string literals. + /// Codegen only: points at a table that outlives the event (see ConstVector::assign_static). + void set_event_types_static(const char *const *event_types, size_t count) { + this->types_.assign_static(event_types, count); + this->last_event_type_ = nullptr; + } + /// Runtime lists are copied; the strings must still outlive the event. void set_event_types(std::initializer_list event_types) { - this->types_ = event_types; - this->last_event_type_ = nullptr; // Reset when types change + this->set_event_types_copy_(event_types.begin(), event_types.size()); } /// Copy the event types of another event, for components that wrap one. - void set_event_types(const FixedVector &event_types); + void set_event_types(const EventTypes &event_types) { + this->set_event_types_copy_(event_types.data(), event_types.size()); + } + void set_event_types(const FixedVector &event_types) { + this->set_event_types_copy_(event_types.begin(), event_types.size()); + } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_event_types(std::initializer_list event_types) = delete; void set_event_types(const FixedVector &event_types) = delete; /// Return the event types supported by this event. - const FixedVector &get_event_types() const { return this->types_; } + const EventTypes &get_event_types() const { return this->types_; } /// Return the last triggered event type, or empty StringRef if no event triggered yet. StringRef get_last_event_type() const { return StringRef::from_maybe_nullptr(this->last_event_type_); } @@ -68,8 +80,10 @@ class Event : public EntityBase { } protected: + void set_event_types_copy_(const char *const *event_types, size_t count); + LazyCallbackManager event_callback_; - FixedVector types_; + EventTypes types_; private: /// Last triggered event type - must point to entry in types_ to ensure valid lifetime. diff --git a/tests/component_tests/event/__init__.py b/tests/component_tests/event/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/event/config/event_types.yaml b/tests/component_tests/event/config/event_types.yaml new file mode 100644 index 00000000000..8e849d4a782 --- /dev/null +++ b/tests/component_tests/event/config/event_types.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +event: + - platform: template + id: event_a + name: Event A + event_types: [pressed, held] + - platform: template + id: event_b + name: Event B + event_types: [pressed, held] + - platform: template + id: event_c + name: Event C + event_types: [ring] diff --git a/tests/component_tests/event/test_event_types.py b/tests/component_tests/event/test_event_types.py new file mode 100644 index 00000000000..30e653bc70d --- /dev/null +++ b/tests/component_tests/event/test_event_types.py @@ -0,0 +1,25 @@ +"""Event types are shared PROGMEM tables of type pointers.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_event_types_use_shared_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("event_types.yaml")) + + calls = { + var: table + for var, table, _ in re.findall( + r"(\w+)->set_event_types_static\((\w+), (\d+)\);", main_cpp + ) + } + assert set(calls) == {"event_a", "event_b", "event_c"} + assert calls["event_a"] == calls["event_b"] != calls["event_c"] + assert ( + f'static constexpr const char * {calls["event_a"]}[] PROGMEM = {{"pressed", "held"}};' + in main_cpp + ) diff --git a/tests/components/event/event_types_test.cpp b/tests/components/event/event_types_test.cpp new file mode 100644 index 00000000000..0bc99e1a8e3 --- /dev/null +++ b/tests/components/event/event_types_test.cpp @@ -0,0 +1,51 @@ +#include + +#include "esphome/components/event/event.h" + +namespace esphome::event::testing { + +static const char *const TYPES[] = {"pressed", "held"}; + +TEST(EventTypes, StaticTableIsViewed) { + Event event; + event.set_event_types_static(TYPES, 2); + EXPECT_EQ(event.get_event_types().data(), TYPES); + EXPECT_EQ(event.get_event_types().size(), 2u); +} + +TEST(EventTypes, CopiesAnotherEventAndResetsLastType) { + Event source; + source.set_event_types({"x", "y"}); + Event copy; + copy.set_event_types_static(TYPES, 2); + copy.trigger("held"); + ASSERT_TRUE(copy.has_event()); + copy.set_event_types(source.get_event_types()); + EXPECT_FALSE(copy.has_event()); + EXPECT_NE(copy.get_event_types().data(), TYPES); + EXPECT_NE(copy.get_event_types().data(), source.get_event_types().data()); + EXPECT_EQ(copy.get_event_types().size(), 2u); + EXPECT_STREQ(copy.get_event_types()[1], "y"); +} + +TEST(EventTypes, CopiesItsOwnList) { + Event event; + event.set_event_types({"a", "b"}); + event.set_event_types(event.get_event_types()); // copies before freeing the old list + EXPECT_EQ(event.get_event_types().size(), 2u); + EXPECT_STREQ(event.get_event_types()[1], "b"); +} + +TEST(EventTypes, CopiesFromFixedVector) { + FixedVector types; + types.init(2); + types.push_back("on"); + types.push_back("off"); + Event event; + event.set_event_types(types); + EXPECT_NE(event.get_event_types().data(), types.begin()); + EXPECT_EQ(event.get_event_types().size(), 2u); + EXPECT_STREQ(event.get_event_types()[0], "on"); +} + +} // namespace esphome::event::testing From 993fd8d7a212b57416149802af9c795cf03e1f5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:04:16 -1000 Subject: [PATCH 133/651] [thermostat] Keep presets in shared flash tables (#20080) --- esphome/components/thermostat/climate.py | 107 +++++++----------- .../thermostat/thermostat_climate.cpp | 65 ++++++----- .../thermostat/thermostat_climate.h | 46 +++++--- tests/component_tests/thermostat/__init__.py | 0 .../thermostat/config/presets.yaml | 53 +++++++++ .../thermostat/test_presets.py | 28 +++++ 6 files changed, 189 insertions(+), 110 deletions(-) create mode 100644 tests/component_tests/thermostat/__init__.py create mode 100644 tests/component_tests/thermostat/config/presets.yaml create mode 100644 tests/component_tests/thermostat/test_presets.py diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 3cc4dc70091..8a954b181eb 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -39,7 +39,6 @@ from esphome.const import ( CONF_HEAT_MODE, CONF_HEAT_OVERRUN, CONF_HUMIDITY_SENSOR, - CONF_ID, CONF_IDLE_ACTION, CONF_MAX_COOLING_RUN_TIME, CONF_MAX_HEATING_RUN_TIME, @@ -72,6 +71,7 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_CHANGE_ACTION, CONF_VISUAL, ) +from esphome.helpers import cpp_string_escape from esphome.types import ConfigType CONF_DEFAULT_PRESET = "default_preset" @@ -93,6 +93,8 @@ ThermostatClimate = thermostat_ns.class_( ThermostatClimateTargetTempConfig = thermostat_ns.struct( "ThermostatClimateTargetTempConfig" ) +ThermostatPresetEntry = thermostat_ns.struct("ThermostatPresetEntry") +ThermostatCustomPresetEntry = thermostat_ns.struct("ThermostatCustomPresetEntry") OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom") ON_BOOT_RESTORE_FROM = { "MEMORY": OnBootRestoreFrom.MEMORY, @@ -116,6 +118,7 @@ ClimatePreset = climate_ns.enum("ClimatePreset") PRESET_CONFIG_SCHEMA = cv.Schema( { + # Still accepted for existing configs; presets are flash table entries, not variables. cv.GenerateID(): cv.declare_id(ThermostatClimateTargetTempConfig), cv.Required(CONF_NAME): cv.string_strict, cv.Optional(CONF_MODE): validate_climate_mode, @@ -674,7 +677,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, } ), - cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA), + cv.Optional(CONF_PRESET): cv.All( + cv.ensure_list(PRESET_CONFIG_SCHEMA), cv.Length(max=255) + ), cv.Optional(CONF_ON_BOOT_RESTORE_FROM): validate_on_boot_restore_from, cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation( single=True @@ -964,85 +969,51 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS])) if CONF_PRESET in config: - # Separate standard and custom presets, and build preset config variables - standard_presets: list[tuple[cg.MockObj, cg.MockObj]] = [] - custom_presets: list[tuple[str, cg.MockObj]] = [] + # Presets live in shared PROGMEM tables; the thermostat keeps a pointer and a count. + standard_presets: list[cg.StructInitializer] = [] + custom_presets: list[cg.StructInitializer] = [] + nan = float("nan") + unset = cg.RawExpression("std::nullopt") for preset_config in config[CONF_PRESET]: name = preset_config[CONF_NAME] - standard_preset = None - if name.upper() in climate.CLIMATE_PRESETS: - standard_preset = climate.CLIMATE_PRESETS[name.upper()] - + low = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_LOW) + high = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH) if two_points_available: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH], - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH] - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_LOW in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] + temperatures = (nan, low, high) + else: + temperatures = (high if high is not None else low, nan, nan) + target = ThermostatClimateTargetTempConfig( + *(nan if t is None else t for t in temperatures), + preset_config.get(CONF_MODE, unset), + preset_config.get(CONF_FAN_MODE, unset), + preset_config.get(CONF_SWING_MODE, unset), + ) + if (standard := climate.CLIMATE_PRESETS.get(name.upper())) is not None: + standard_presets.append( + cg.StructInitializer( + ThermostatPresetEntry, ("preset", standard), ("config", target) + ) ) else: - preset_target_config = None - - preset_target_variable = cg.new_variable( - preset_config[CONF_ID], preset_target_config - ) - - if CONF_MODE in preset_config: - cg.add(preset_target_variable.set_mode(preset_config[CONF_MODE])) - - if CONF_FAN_MODE in preset_config: - cg.add( - preset_target_variable.set_fan_mode(preset_config[CONF_FAN_MODE]) - ) - - if CONF_SWING_MODE in preset_config: - cg.add( - preset_target_variable.set_swing_mode( - preset_config[CONF_SWING_MODE] + custom_presets.append( + cg.StructInitializer( + ThermostatCustomPresetEntry, + ("name", cg.RawExpression(cpp_string_escape(name))), + ("config", target), ) ) - if standard_preset is not None: - standard_presets.append((standard_preset, preset_target_variable)) - else: - custom_presets.append((name, preset_target_variable)) - - # Build initializer list for standard presets if standard_presets: - cg.add( - var.set_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatPresetEntry"), - ("preset", preset), - ("config", preset_var), - ) - for preset, preset_var in standard_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_presets", ThermostatPresetEntry, standard_presets ) - - # Build initializer list for custom presets + cg.add(var.set_preset_config(table, len(standard_presets))) if custom_presets: - cg.add( - var.set_custom_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatCustomPresetEntry"), - ("name", cg.RawExpression(f'"{name}"')), - ("config", preset_var), - ) - for name, preset_var in custom_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_custom_presets", ThermostatCustomPresetEntry, custom_presets ) + cg.add(var.set_custom_preset_config(table, len(custom_presets))) if CONF_DEFAULT_PRESET in config: default_preset_name = config[CONF_DEFAULT_PRESET] diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index abfe1b027a5..5d24759c2d9 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -322,8 +322,8 @@ climate::ClimateTraits ThermostatClimate::traits() { if (this->supports_swing_mode_vertical_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); - for (const auto &entry : this->preset_config_) { - traits.add_supported_preset(entry.preset); + for (uint8_t i = 0; i < this->preset_count_; i++) { + traits.add_supported_preset(this->preset_entry_(i).preset); } // Custom presets are stored on Climate base class and wired via get_traits() @@ -1175,17 +1175,30 @@ void ThermostatClimate::dump_preset_config_(const char *preset_name, const Therm } } +ThermostatPresetEntry ThermostatClimate::preset_entry_(uint8_t index) const { + ThermostatPresetEntry entry; + progmem_memcpy(&entry, &this->preset_config_[index], sizeof(entry)); + return entry; +} + +ThermostatCustomPresetEntry ThermostatClimate::custom_preset_entry_(uint8_t index) const { + ThermostatCustomPresetEntry entry; + progmem_memcpy(&entry, &this->custom_preset_config_[index], sizeof(entry)); + return entry; +} + void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { // Linear search through preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->preset_config_) { + optional config; + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); if (entry.preset == preset) { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Preset %s requested", LOG_STR_ARG(climate::climate_preset_to_string(preset))); if (this->change_preset_internal_(*config) || (!this->preset.has_value()) || this->preset.value() != preset) { // Fire preset changed trigger @@ -1205,16 +1218,17 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { void ThermostatClimate::change_custom_preset_(const char *custom_preset, size_t len) { // Linear search through custom preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->custom_preset_config_) { + optional config; + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); // Compare first len chars, then verify entry.name ends there (same length) if (strncmp(entry.name, custom_preset, len) == 0 && entry.name[len] == '\0') { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Custom preset %s requested", custom_preset); if (this->change_preset_internal_(*config) || !this->has_custom_preset() || this->get_custom_preset() != custom_preset) { @@ -1279,13 +1293,14 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { +void ThermostatClimate::set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count) { this->custom_preset_config_ = presets; + this->custom_preset_count_ = count; // Populate Climate base class custom presets vector std::vector names; - names.reserve(presets.size()); - for (const auto &entry : this->custom_preset_config_) { - names.push_back(entry.name); + names.reserve(count); + for (uint8_t i = 0; i < count; i++) { + names.push_back(this->custom_preset_entry_(i).name); } this->set_supported_custom_presets(names); } @@ -1294,7 +1309,8 @@ ThermostatClimate::ThermostatClimate() = default; void ThermostatClimate::set_default_preset(const char *custom_preset) { // Find the preset in custom_preset_config_ and store pointer from there - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); if (strcmp(entry.name, custom_preset) == 0) { this->default_custom_preset_ = entry.name; return; @@ -1545,9 +1561,10 @@ void ThermostatClimate::dump_config() { YESNO(this->supports_dehumidification_ || this->supports_humidification_), YESNO(this->supports_dehumidification_), YESNO(this->supports_humidification_)); - if (!this->preset_config_.empty()) { + if (this->preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported PRESETS:"); - for (const auto &entry : this->preset_config_) { + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(entry.preset)); ESP_LOGCONFIG(TAG, " %s:%s", preset_name, entry.preset == this->default_preset_ ? LOG_STR_LITERAL(" (default)") : ""); @@ -1555,9 +1572,10 @@ void ThermostatClimate::dump_config() { } } - if (!this->custom_preset_config_.empty()) { + if (this->custom_preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:"); - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); const auto *preset_name = entry.name; ESP_LOGCONFIG(TAG, " %s:%s", preset_name, (this->default_custom_preset_ != nullptr && strcmp(entry.name, this->default_custom_preset_) == 0) @@ -1568,13 +1586,4 @@ void ThermostatClimate::dump_config() { } } -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig() = default; - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature) - : default_temperature(default_temperature) {} - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature_low, - float default_temperature_high) - : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} - } // namespace esphome::thermostat diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index b7d46eae227..e9c0ae75fdc 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -49,9 +49,21 @@ struct ThermostatClimateTimer { struct ThermostatClimateTargetTempConfig { public: - ThermostatClimateTargetTempConfig(); - ThermostatClimateTargetTempConfig(float default_temperature); - ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high); + constexpr ThermostatClimateTargetTempConfig() = default; + constexpr ThermostatClimateTargetTempConfig(float default_temperature) : default_temperature(default_temperature) {} + constexpr ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high) + : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} + /// Full form used by the generated preset tables in flash. + constexpr ThermostatClimateTargetTempConfig(float default_temperature, float default_temperature_low, + float default_temperature_high, optional mode, + optional fan_mode, + optional swing_mode) + : default_temperature(default_temperature), + default_temperature_low(default_temperature_low), + default_temperature_high(default_temperature_high), + fan_mode_(fan_mode), + swing_mode_(swing_mode), + mode_(mode) {} void set_fan_mode(climate::ClimateFanMode fan_mode) { this->fan_mode_ = fan_mode; } void set_swing_mode(climate::ClimateSwingMode swing_mode) { this->swing_mode_ = swing_mode; } @@ -60,10 +72,6 @@ struct ThermostatClimateTargetTempConfig { float default_temperature{NAN}; float default_temperature_low{NAN}; float default_temperature_high{NAN}; - float cool_deadband_{NAN}; - float cool_overrun_{NAN}; - float heat_deadband_{NAN}; - float heat_overrun_{NAN}; optional fan_mode_{}; optional swing_mode_{}; optional mode_{}; @@ -175,8 +183,12 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_supports_humidification(bool supports_humidification); void set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; } - void set_preset_config(std::initializer_list presets) { this->preset_config_ = presets; } - void set_custom_preset_config(std::initializer_list presets); + /// `presets` is a PROGMEM table that outlives the thermostat. + void set_preset_config(const PresetEntry *presets, uint8_t count) { + this->preset_config_ = presets; + this->preset_count_ = count; + } + void set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count); Trigger<> *get_cool_action_trigger(); Trigger<> *get_supplemental_cool_action_trigger(); @@ -328,6 +340,9 @@ class ThermostatClimate final : public climate::Climate, public Component { bool humidification_required_(); void dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config); + /// Copies of table entries; the tables live in PROGMEM and hold byte-sized fields. + PresetEntry preset_entry_(uint8_t index) const; + CustomPresetEntry custom_preset_entry_(uint8_t index) const; /// Minimum allowable duration in seconds for action timers const uint8_t min_timer_duration_{1}; @@ -349,6 +364,9 @@ class ThermostatClimate final : public climate::Climate, public Component { /// state will attempt to be restored if possible OnBootRestoreFrom on_boot_restore_from_{OnBootRestoreFrom::MEMORY}; + uint8_t preset_count_{0}; + uint8_t custom_preset_count_{0}; + /// Whether the controller supports auto/cooling/drying/fanning/heating. /// /// A false value for any given attribute means that the controller has no such action @@ -522,13 +540,13 @@ class ThermostatClimate final : public climate::Climate, public Component { /// Climate action timers std::array timer_{}; - /// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc) - FixedVector preset_config_{}; - /// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset") - FixedVector custom_preset_config_{}; + /// Standard preset configurations (Eg. AWAY, ECO, etc), a PROGMEM table of preset_count_ entries + const PresetEntry *preset_config_{nullptr}; + /// Custom preset configurations (eg. "My Custom Preset"), a PROGMEM table of custom_preset_count_ entries + const CustomPresetEntry *custom_preset_config_{nullptr}; private: - /// Default custom preset to use on start up (pointer to entry in custom_preset_config_) + /// Default custom preset to use on start up (name pointer from an entry in custom_preset_config_) const char *default_custom_preset_{nullptr}; }; diff --git a/tests/component_tests/thermostat/__init__.py b/tests/component_tests/thermostat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/thermostat/config/presets.yaml b/tests/component_tests/thermostat/config/presets.yaml new file mode 100644 index 00000000000..956562ff63f --- /dev/null +++ b/tests/component_tests/thermostat/config/presets.yaml @@ -0,0 +1,53 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: thermo_a + name: Thermo A + sensor: temp_sensor + min_heating_off_time: 300s + min_heating_run_time: 300s + min_idle_time: 30s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle + preset: + - name: Away + default_target_temperature_low: 15°C + mode: HEAT + - name: Eco + default_target_temperature_low: 17°C + - name: Night Mode + default_target_temperature_low: 16°C + - platform: thermostat + id: thermo_b + name: Thermo B + sensor: temp_sensor + min_heating_off_time: 300s + min_heating_run_time: 300s + min_idle_time: 30s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle + preset: + - name: Away + default_target_temperature_low: 15°C + mode: HEAT + - name: Eco + default_target_temperature_low: 17°C + - name: Night Mode + default_target_temperature_low: 16°C diff --git a/tests/component_tests/thermostat/test_presets.py b/tests/component_tests/thermostat/test_presets.py new file mode 100644 index 00000000000..129cb1587fa --- /dev/null +++ b/tests/component_tests/thermostat/test_presets.py @@ -0,0 +1,28 @@ +"""Tests for the thermostat preset codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_presets_shared_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Thermostats with the same presets share one PROGMEM table per kind.""" + main_cpp = generate_main(component_config_path("presets.yaml")) + + standard = re.findall( + r"static constexpr thermostat::ThermostatPresetEntry (\w+)\[\] PROGMEM", + main_cpp, + ) + custom = re.findall( + r"static constexpr thermostat::ThermostatCustomPresetEntry (\w+)\[\] PROGMEM", + main_cpp, + ) + assert len(standard) == 1 + assert len(custom) == 1 + for var in ("thermo_a", "thermo_b"): + assert f"{var}->set_preset_config({standard[0]}, 2);" in main_cpp + assert f"{var}->set_custom_preset_config({custom[0]}, 1);" in main_cpp + assert "climate::CLIMATE_MODE_HEAT" in main_cpp From 5d64b65d725663015ea4c0903b300f2d3aafc5e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:12:09 -1000 Subject: [PATCH 134/651] [esp32_ble_server] Keep constant set_value payloads in shared flash tables (#20314) --- .../components/esp32_ble_server/__init__.py | 68 ++++++++++++++---- .../esp32_ble_server/ble_characteristic.cpp | 8 +++ .../esp32_ble_server/ble_characteristic.h | 1 + .../esp32_ble_server/ble_descriptor.cpp | 9 +-- .../esp32_ble_server/ble_descriptor.h | 3 +- .../esp32_ble_server/ble_server_automations.h | 26 ++++--- .../esp32_ble_server/config/set_value.yaml | 37 ++++++++++ .../esp32_ble_server/test_esp32_ble_server.py | 72 +++++++++++++++++++ .../bytebuffer/test_wrap_values.cpp | 9 ++- tests/components/esp32_ble_server/common.yaml | 7 ++ 10 files changed, 204 insertions(+), 36 deletions(-) create mode 100644 tests/component_tests/esp32_ble_server/config/set_value.yaml diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 75a099a3c49..d37b2f4020d 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -1,4 +1,6 @@ import encodings +import struct +from typing import Any from esphome import automation import esphome.codegen as cg @@ -25,8 +27,10 @@ from esphome.const import ( __version__ as ESPHOME_VERSION, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.types import ConfigType, TemplateArgsType AUTO_LOAD = ["esp32_ble", "bytebuffer"] CODEOWNERS = ["@jesserockz", "@clydebarrow", "@Rapsssito"] @@ -111,8 +115,6 @@ BLECharacteristicNotifyAction = esp32_ble_server_automations_ns.class_( ) bytebuffer_ns = cg.esphome_ns.namespace("bytebuffer") Endianness_ns = bytebuffer_ns.namespace("Endian") -ByteBuffer_ns = bytebuffer_ns.namespace("ByteBuffer") -ByteBuffer = bytebuffer_ns.class_("ByteBuffer") PROPERTY_MAP = { @@ -141,6 +143,15 @@ class ValueType: return value +def float32(value: Any) -> float: + value = cv.float_(value) + try: + struct.pack(" list[int]: + """The bytes of a constant value, as ByteBuffer::wrap packed them at runtime.""" + value = value_config[CONF_DATA] + if isinstance(value, str): + return list(value.encode(value_config[CONF_STRING_ENCODING])) + if isinstance(value, list): + return value + order = ">" if value_config[CONF_ENDIANNESS] == "BIG" else "<" + return list(struct.pack(order + _STRUCT_FORMATS[value_config[CONF_TYPE]], value)) + + async def parse_value(value_config, args): value = value_config[CONF_DATA] if isinstance(value, cv.Lambda): return await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) + # An initializer list calls the set_value(std::initializer_list) overload + return cg.ArrayInitializer(*value_bytes(value_config)) - if isinstance(value, str): - value = list(value.encode(value_config[CONF_STRING_ENCODING])) - if isinstance(value, list): - # Generate initializer list {1, 2, 3} instead of std::vector({1, 2, 3}) - # This calls the set_value(std::initializer_list) overload - return cg.ArrayInitializer(*value) - val = cg.RawExpression(f"{value_config[CONF_TYPE]}({cg.safe_exp(value)})") - return ByteBuffer_ns.wrap(val, value_config[CONF_ENDIANNESS]) + +async def set_value_buffer( + var: MockObj, value_config: ConfigType, args: TemplateArgsType +) -> None: + value = value_config[CONF_DATA] + if not isinstance(value, cv.Lambda): + value = value_bytes(value_config) + await automation.templatable_bytes( + value, args, var.set_buffer_template, var.set_buffer_static, "ble_server_value" + ) def calculate_num_handles(service_config): @@ -662,8 +702,7 @@ async def to_code(config): async def ble_server_characteristic_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_SET_VALUE_ACTION") return var @@ -682,8 +721,7 @@ async def ble_server_characteristic_set_value(config, action_id, template_arg, a async def ble_server_descriptor_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION") return var diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 75a33993797..1571a6077bd 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -30,6 +30,14 @@ BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) void BLECharacteristic::set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } +void BLECharacteristic::set_value(const uint8_t *data, size_t length) { + if (length == 0) { // An empty constant is a nullptr table + this->value_.clear(); + return; + } + this->value_.assign(data, data + length); +} + void BLECharacteristic::set_value(std::vector &&buffer) { this->value_ = std::move(buffer); } void BLECharacteristic::set_value(std::initializer_list data) { diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index b7a3fae1a5b..905f5b7a0d0 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -30,6 +30,7 @@ class BLECharacteristic final { ~BLECharacteristic(); void set_value(ByteBuffer buffer); + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(const std::string &buffer); diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 228313e2d8e..7810dba9f5c 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -49,17 +49,18 @@ void BLEDescriptor::do_create(BLECharacteristic *characteristic) { this->state_ = CREATING; } -void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value_impl_(buffer.data(), buffer.size()); } +void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value(buffer.data(), buffer.size()); } -void BLEDescriptor::set_value(std::initializer_list data) { this->set_value_impl_(data.begin(), data.size()); } +void BLEDescriptor::set_value(std::initializer_list data) { this->set_value(data.begin(), data.size()); } -void BLEDescriptor::set_value_impl_(const uint8_t *data, size_t length) { +void BLEDescriptor::set_value(const uint8_t *data, size_t length) { if (length > this->value_.attr_max_len) { ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", length, this->value_.attr_max_len); return; } this->value_.attr_len = length; - memcpy(this->value_.attr_value, data, length); + if (length != 0) // An empty constant is a nullptr table + memcpy(this->value_.attr_value, data, length); } void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 5096d39f284..1ffd4034346 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -26,6 +26,7 @@ class BLEDescriptor { void do_create(BLECharacteristic *characteristic); ESPBTUUID get_uuid() const { return this->uuid_; } + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } @@ -42,8 +43,6 @@ class BLEDescriptor { } protected: - void set_value_impl_(const uint8_t *data, size_t length); - BLECharacteristic *characteristic_{nullptr}; ESPBTUUID uuid_; uint16_t handle_{0xFFFF}; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index e5463847fa8..d90b2a28fd5 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -16,6 +16,16 @@ namespace esphome::esp32_ble_server::esp32_ble_server_automations { using namespace esp32_ble; +/// Sets a characteristic or descriptor value from a constant flash table or a lambda's vector. +// Not TemplatableBytes::visit, which would copy the lambda's vector instead of moving it into the value. +template void apply_value(T *target, const V &value, Xs &&...x) { + if (value.is_static()) { + target->set_value(value.data(), value.size()); + } else { + target->set_value(value.value(x...)); + } +} + class BLETriggers { public: #ifdef USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE @@ -67,25 +77,23 @@ class BLECharacteristicSetValueActionManager { template class BLECharacteristicSetValueAction final : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } + TEMPLATABLE_BYTES(buffer) void play(const Ts &...x) override { // If the listener is already set, do nothing if (BLECharacteristicSetValueActionManager::get_instance()->has_listener(this->parent_)) return; // Set initial value - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); // Set the listener for read events // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference // (e.g. climate on_control's ClimateCall&). See #17142. this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { apply_value(this->parent_, this->buffer_, x...); }); } protected: @@ -115,10 +123,8 @@ template class BLECharacteristicNotifyAction final : public Acti template class BLEDescriptorSetValueAction final : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } - void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } + TEMPLATABLE_BYTES(buffer) + void play(const Ts &...x) override { apply_value(this->parent_, this->buffer_, x...); } protected: BLEDescriptor *parent_; diff --git a/tests/component_tests/esp32_ble_server/config/set_value.yaml b/tests/component_tests/esp32_ble_server/config/set_value.yaml new file mode 100644 index 00000000000..69f1a17dc80 --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/set_value.yaml @@ -0,0 +1,37 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble_server: + id: ble_server + services: + - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d + characteristics: + - id: test_characteristic + uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 + read: true + value: [1, 2, 3, 4] + descriptors: + - id: test_descriptor + uuid: 0x4414 + value: "Initial" + - uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc99a + write: true + on_write: + - ble_server.characteristic.set_value: + id: test_characteristic + value: + data: 4660 + type: uint16_t + endianness: BIG + - ble_server.descriptor.set_value: + id: test_descriptor + value: + data: 4660 + type: uint16_t + endianness: BIG + - ble_server.characteristic.set_value: + id: test_characteristic + value: !lambda return x; diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index 21a12d9cf2e..e3006d2c692 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -9,10 +9,16 @@ import pytest from esphome.components.esp32_ble_server import ( CCCD_DESCRIPTOR_UUID, + CONF_ENDIANNESS, + CONF_STRING_ENCODING, CUD_DESCRIPTOR_UUID, DEVICE_INFORMATION_SERVICE_UUID, + float32, uuid_is, + value_bytes, ) +import esphome.config_validation as cv +from esphome.const import CONF_DATA, CONF_TYPE @pytest.mark.parametrize( @@ -73,3 +79,69 @@ def test_advertising_required( main_cpp = generate_main(component_config_path(config_file)) assert f"set_advertising_required({str(required).lower()})" in main_cpp + + +# The same values and bytes as tests/components/bytebuffer/test_wrap_values.cpp, which pins +# what ByteBuffer::wrap produced from the expressions this component used to emit. +@pytest.mark.parametrize( + ("data", "type_", "endianness", "expected"), + [ + (18, "uint8_t", "LITTLE", [0x12]), + (18, "uint16_t", "LITTLE", [0x12, 0x00]), + (4660, "uint16_t", "BIG", [0x12, 0x34]), + (305419896, "uint32_t", "LITTLE", [0x78, 0x56, 0x34, 0x12]), + ( + 1311768467294899695, + "uint64_t", + "BIG", + [0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF], + ), + (-5, "int8_t", "LITTLE", [0xFB]), + (-2, "int16_t", "BIG", [0xFF, 0xFE]), + (-2, "int32_t", "LITTLE", [0xFE, 0xFF, 0xFF, 0xFF]), + (-2, "int64_t", "LITTLE", [0xFE] + [0xFF] * 7), + (123.1, "float", "BIG", [0x42, 0xF6, 0x33, 0x33]), + (0.1, "float", "LITTLE", [0xCD, 0xCC, 0xCC, 0x3D]), + (0.1, "double", "LITTLE", [0x9A, 0x99, 0x99, 0x99, 0x99, 0x99, 0xB9, 0x3F]), + (2.5, "double", "BIG", [0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + ], +) +def test_value_bytes_match_bytebuffer( + data: float, type_: str, endianness: str, expected: list[int] +) -> None: + config = { + CONF_DATA: data, + CONF_TYPE: type_, + CONF_ENDIANNESS: endianness, + CONF_STRING_ENCODING: "utf_8", + } + assert value_bytes(config) == expected + + +def test_value_bytes_strings_and_lists() -> None: + assert value_bytes({CONF_DATA: "hé", CONF_STRING_ENCODING: "utf_8"}) == [ + 0x68, + 0xC3, + 0xA9, + ] + assert value_bytes({CONF_DATA: [1, 2], CONF_STRING_ENCODING: "utf_8"}) == [1, 2] + + +def test_set_value_constants_share_a_flash_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("set_value.yaml")) + + assert main_cpp.count("ble_server_value[] PROGMEM = {0x12, 0x34};") == 1 + assert main_cpp.count("set_buffer_static(ble_server_value, 2);") == 2 + assert "set_buffer_template(" in main_cpp + assert "ByteBuffer::wrap" not in main_cpp + + +def test_float32_rejects_out_of_range() -> None: + for value in (1e40, -1e40): + with pytest.raises(cv.Invalid, match="out of range for a float"): + float32(value) + assert float32(3.4028235e38) == 3.4028235e38 + assert float32(float("inf")) == float("inf") diff --git a/tests/components/bytebuffer/test_wrap_values.cpp b/tests/components/bytebuffer/test_wrap_values.cpp index 9a470d8a088..bcae7125bf2 100644 --- a/tests/components/bytebuffer/test_wrap_values.cpp +++ b/tests/components/bytebuffer/test_wrap_values.cpp @@ -8,8 +8,7 @@ namespace esphome::bytebuffer { -// The expressions esp32_ble_server emits for typed constant values, and the bytes they produce. -// Doubles are written as float literals, so they carry float precision. +// Typed values as ByteBuffer::wrap packs them; esp32_ble_server's value_bytes() is tested against the same bytes. TEST(ByteBufferWrapValues, MatchesGeneratedConstants) { EXPECT_EQ(ByteBuffer::wrap(uint8_t(18), LITTLE).get_data(), (std::vector{0x12})); EXPECT_EQ(ByteBuffer::wrap(uint16_t(18), LITTLE).get_data(), (std::vector{0x12, 0x00})); @@ -24,9 +23,9 @@ TEST(ByteBufferWrapValues, MatchesGeneratedConstants) { (std::vector{0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff})); EXPECT_EQ(ByteBuffer::wrap(float(123.1f), BIG).get_data(), (std::vector{0x42, 0xf6, 0x33, 0x33})); EXPECT_EQ(ByteBuffer::wrap(float(0.1f), LITTLE).get_data(), (std::vector{0xcd, 0xcc, 0xcc, 0x3d})); - EXPECT_EQ(ByteBuffer::wrap(double(0.1f), LITTLE).get_data(), - (std::vector{0x00, 0x00, 0x00, 0xa0, 0x99, 0x99, 0xb9, 0x3f})); - EXPECT_EQ(ByteBuffer::wrap(double(2.5f), BIG).get_data(), + EXPECT_EQ(ByteBuffer::wrap(double(0.1), LITTLE).get_data(), + (std::vector{0x9a, 0x99, 0x99, 0x99, 0x99, 0x99, 0xb9, 0x3f})); + EXPECT_EQ(ByteBuffer::wrap(double(2.5), BIG).get_data(), (std::vector{0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})); } diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index b513c43234b..01994e2d7a7 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -110,6 +110,13 @@ esp32_ble_server: - ble_server.descriptor.set_value: id: test_change_descriptor value: "Changed" + # Empty constants + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: "" + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: "" # Lambdas that read the trigger argument - ble_server.characteristic.set_value: id: test_change_characteristic From ed6ab4565df661d0f3da70980c23457825ea82cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:12:59 -1000 Subject: [PATCH 135/651] [core] Emit filter, action and condition lists one item at a time (#20116) --- esphome/automation.py | 24 ++++--- esphome/components/api/__init__.py | 2 +- esphome/components/binary_sensor/__init__.py | 3 +- esphome/components/sensor/__init__.py | 5 +- esphome/components/sensor/filter.h | 3 + esphome/components/text_sensor/__init__.py | 3 +- esphome/core/base_automation.h | 33 ++++++++-- .../automation/test_condition_groups.py | 2 +- .../sensor/test_timeout_throttle.py | 10 +-- .../template/config/per_item_lists.yaml | 62 +++++++++++++++++++ .../template/test_per_item_lists.py | 30 +++++++++ 11 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 tests/component_tests/template/config/per_item_lists.yaml create mode 100644 tests/component_tests/template/test_per_item_lists.py diff --git a/esphome/automation.py b/esphome/automation.py index fea5769050e..2f90cbfcbbf 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -724,7 +724,7 @@ async def condition_group_to_code( if len(conditions) == 1: return conditions[0] return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions ) @@ -840,11 +840,13 @@ async def if_action_to_code( condition = await build_condition(config[cond_conf], template_arg, args) var = cg.new_Pvariable(action_id, if_template_arg, condition) if CONF_THEN in config: - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) if has_else: - actions = await build_action_list(config[CONF_ELSE], template_arg, args) - cg.add(var.add_else(actions)) + for action in await build_action_list(config[CONF_ELSE], template_arg, args): + cg.add(var.add_else_action(action)) + cg.add(var.finish_else()) return var @@ -867,8 +869,9 @@ async def while_action_to_code( ) -> MockObj: condition = await build_condition(config[CONF_CONDITION], template_arg, args) var = cg.new_Pvariable(action_id, template_arg, condition) - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -897,7 +900,9 @@ async def repeat_action_to_code( cg.TemplateArguments(cg.uint32, *template_arg.args), [(cg.uint32, "iteration"), *args], ) - cg.add(var.add_then(actions)) + for action in actions: + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -1113,7 +1118,8 @@ async def _new_automation( templ = cg.TemplateArguments(*(arg[0] for arg in args)) obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args) actions = await build_action_list(config[CONF_THEN], templ, args) - cg.add(obj.add_actions(actions)) + for action in actions: + cg.add(obj.add_action(action)) return obj diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index f105c1d7f79..9611228ba1d 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -664,7 +664,7 @@ async def to_code(config: ConfigType) -> None: unregister_id, var, ) - cg.add(auto.add_actions([unregister_action])) + cg.add(auto.add_action(unregister_action)) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) if CORE.is_esp8266 and has_user_actions: diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 60e20aa1935..109a22c7bca 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -607,7 +607,8 @@ async def setup_binary_sensor_core_(var, config): if filters_config := config.get(CONF_FILTERS): cg.add_define("USE_BINARY_SENSOR_FILTER") filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config) - cg.add(var.add_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_binary_sensor_automations, var, config) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 2b404442a4d..1440e07f763 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -672,7 +672,7 @@ async def delta_filter_to_code(config, filter_id): @FILTER_REGISTRY.register("or", OrFilter, validate_filters) async def or_filter_to_code(config, filter_id): filters = await build_filters(config) - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), filters) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), *filters) @FILTER_REGISTRY.register( @@ -1033,7 +1033,8 @@ async def setup_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_SENSOR_FILTER") filters = await _build_chain_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_sensor_automations, var, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 510ee94c6a7..01949f45df2 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -545,6 +545,9 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, template class OrFilter : public Filter { public: explicit OrFilter(std::initializer_list filters) { init_array_from(this->filters_, filters); } + // Codegen passes each filter as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Fs) == N) explicit OrFilter(Fs *...filters) : filters_{filters...} {} void initialize(Sensor *parent, Filter *next) override { Filter::initialize(parent, next); diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index e9c70f7a2e3..959ce77c7a9 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -208,7 +208,8 @@ async def setup_text_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_TEXT_SENSOR_FILTER") filters = await build_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_text_sensor_automations, var, config) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 69def222da2..09cde1261d0 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -22,6 +22,9 @@ template class AndCondition : public Condition explicit AndCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit AndCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (!condition->check(x...)) @@ -40,6 +43,9 @@ template class OrCondition : public Condition { explicit OrCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit OrCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (condition->check(x...)) @@ -67,6 +73,9 @@ template class XorCondition : public Condition explicit XorCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit XorCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { size_t result = 0; for (auto *condition : this->conditions_) { @@ -311,10 +320,9 @@ template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} - // Precondition: add_then/add_else must be called at most once per instance. - // Codegen always batches the full action list into a single call. Calling - // twice would re-append the same inline continuation pointer and form a - // self-loop in the next_ chain. + // Precondition: build each branch either with one add_then/add_else call, or with + // add_then_action/add_else_action calls followed by one finish_then/finish_else, never both. + // Appending the inline continuation twice forms a self-loop in the next_ chain. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->then_continuation_); @@ -325,6 +333,13 @@ template class IfAction : public Action { this->else_.add_action(&this->else_continuation_.action); } + // Codegen appends branch actions one at a time, then finishes each branch once; a list literal + // would be a constant array in .rodata, which is RAM on ESP8266. + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->then_continuation_); } + void add_else_action(Action *action) requires(HasElse) { this->else_.add_action(action); } + void finish_else() requires(HasElse) { this->else_.add_action(&this->else_continuation_.action); } + void play_complex(const Ts &...x) override { this->num_running_++; if (this->condition_->check(x...)) { @@ -364,11 +379,14 @@ template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class WhileLoopContinuation; @@ -428,11 +446,14 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class RepeatLoopContinuation; diff --git a/tests/component_tests/automation/test_condition_groups.py b/tests/component_tests/automation/test_condition_groups.py index effe7b5b0bd..5d9f9b828ed 100644 --- a/tests/component_tests/automation/test_condition_groups.py +++ b/tests/component_tests/automation/test_condition_groups.py @@ -15,7 +15,7 @@ def test_single_condition_groups_are_unwrapped( assert "IfAction(lambdacondition_id);" in main_cpp assert "IfAction(lambdacondition_id_2);" in main_cpp group = re.search( - r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);", + r"new\((\w+)\) AndCondition<2>\(lambdacondition_id_3, lambdacondition_id_4\);", main_cpp, ) assert group is not None diff --git a/tests/component_tests/sensor/test_timeout_throttle.py b/tests/component_tests/sensor/test_timeout_throttle.py index 8154471e472..ceecd2f1226 100644 --- a/tests/component_tests/sensor/test_timeout_throttle.py +++ b/tests/component_tests/sensor/test_timeout_throttle.py @@ -15,10 +15,10 @@ def test_timeout_throttle_pair_is_merged( len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp)) == 1 ) - merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp) - assert merged is not None - assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp + merged = re.findall(r"merged->add_filter\((\w+)\);", main_cpp) + assert len(merged) == 1 + assert f"new({merged[0]}) sensor::TimeoutThrottleFilter(1000);" in main_cpp # Every other shape keeps its two filters for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"): - call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp) - assert call is not None and len(call.group(1).split(",")) == 2, sensor_id + calls = re.findall(rf"{sensor_id}->add_filter\(\w+\);", main_cpp) + assert len(calls) == 2, sensor_id diff --git a/tests/component_tests/template/config/per_item_lists.yaml b/tests/component_tests/template/config/per_item_lists.yaml new file mode 100644 index 00000000000..89f1a80ce2d --- /dev/null +++ b/tests/component_tests/template/config/per_item_lists.yaml @@ -0,0 +1,62 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +globals: + - id: counter + type: int + initial_value: "0" + +sensor: + - platform: template + id: temp + lambda: return 1.0; + filters: + - multiply: 2.0 + - offset: 1.0 + - or: + - throttle: 2s + - delta: 1.0 + +binary_sensor: + - platform: template + id: button + lambda: return true; + filters: + - invert: + - delayed_on: 10ms + +text_sensor: + - platform: template + id: label + lambda: return {"x"}; + filters: + - to_upper: + - append: "!" + +interval: + - interval: 10s + then: + - if: + condition: + and: + - lambda: return id(counter) == 0; + - binary_sensor.is_on: button + then: + - lambda: id(counter) += 1; + - logger.log: even + else: + - logger.log: odd + - repeat: + count: 2 + then: + - logger.log: again + - while: + condition: + lambda: return id(counter) < 3; + then: + - lambda: id(counter) += 1; diff --git a/tests/component_tests/template/test_per_item_lists.py b/tests/component_tests/template/test_per_item_lists.py new file mode 100644 index 00000000000..adf1498e37e --- /dev/null +++ b/tests/component_tests/template/test_per_item_lists.py @@ -0,0 +1,30 @@ +"""Filters and action lists are added one item at a time, so no pointer list lands in .rodata.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_filters_and_actions_are_added_per_item( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("per_item_lists.yaml")) + + for list_call in ( + "add_filters(", + "set_filters(", + "add_actions(", + "add_then(", + "add_else(", + ): + assert list_call not in main_cpp + assert len(re.findall(r"temp->add_filter\(", main_cpp)) == 3 + assert len(re.findall(r"button->add_filter\(", main_cpp)) == 2 + assert len(re.findall(r"label->add_filter\(", main_cpp)) == 2 + assert main_cpp.count("->add_then_action(") == 4 + assert main_cpp.count("->add_else_action(") == 1 + assert main_cpp.count("->finish_then();") == 3 + assert main_cpp.count("->finish_else();") == 1 + # and/or conditions and the sensor `or` filter take their items as separate arguments + assert not re.search(r"(Condition|OrFilter)<[^>]*>\(\{", main_cpp) From 0cd239dfd1176026963bf04fa81989a59f31dce7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:16:34 -1000 Subject: [PATCH 136/651] [uart] Keep constant switch, button and write payloads in flash (#20082) --- esphome/components/uart/__init__.py | 21 +++++------- esphome/components/uart/automation.h | 29 +++------------- esphome/components/uart/button/__init__.py | 10 +++--- .../components/uart/button/uart_button.cpp | 2 +- esphome/components/uart/button/uart_button.h | 11 +++--- esphome/components/uart/switch/__init__.py | 34 ++++++++----------- .../components/uart/switch/uart_switch.cpp | 8 ++--- esphome/components/uart/switch/uart_switch.h | 19 +++++++---- esphome/components/uart/uart.h | 1 + esphome/components/uart/uart_component.cpp | 13 +++++++ esphome/components/uart/uart_component.h | 7 ++++ tests/component_tests/uart/__init__.py | 0 .../uart/config/payload_tables.yaml | 34 +++++++++++++++++++ .../uart/test_payload_tables.py | 28 +++++++++++++++ 14 files changed, 137 insertions(+), 80 deletions(-) create mode 100644 tests/component_tests/uart/__init__.py create mode 100644 tests/component_tests/uart/config/payload_tables.yaml create mode 100644 tests/component_tests/uart/test_payload_tables.py diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 5eea8060ff9..d1ac806b5fd 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -137,6 +137,10 @@ def validate_raw_data(value): ) +# Lengths are stored as uint16_t; reject longer payloads at validation. +validate_raw_payload = cv.All(validate_raw_data, cv.Length(max=65535)) + + def validate_rx_pin(value): value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: @@ -596,7 +600,7 @@ async def register_uart_device(var, config): cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(UARTComponent), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + cv.Required(CONF_DATA): cv.templatable(validate_raw_payload), }, key=CONF_DATA, ), @@ -605,18 +609,9 @@ async def register_uart_device(var, config): async def uart_write_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], args, var.set_data_template, var.set_data_static, "uart_data" + ) return var diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index e5a9fa7c7bc..b134c8af9f6 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -8,36 +8,15 @@ namespace esphome::uart { template class UARTWriteAction final : public Action, public Parented { - public: - void set_data_template(std::vector (*func)(Ts...)) { - // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers - this->code_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - // Store pointer to static data in flash (no RAM copy) - void set_data_static(const uint8_t *data, size_t len) { - this->code_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->len_ >= 0) { - // Static mode: use pointer and length - this->parent_->write_array(this->code_.data, static_cast(this->len_)); + if (this->data_.is_static()) { + this->parent_->write_array_progmem(this->data_.data(), this->data_.size()); } else { - // Template mode: call function - auto val = this->code_.func(x...); - this->parent_->write_array(val); + this->parent_->write_array(this->data_.value(x...)); } } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Code { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } code_; }; } // namespace esphome::uart diff --git a/esphome/components/uart/button/__init__.py b/esphome/components/uart/button/__init__.py index 95fe21271db..821d8264dc4 100644 --- a/esphome/components/uart/button/__init__.py +++ b/esphome/components/uart/button/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import button, uart import esphome.config_validation as cv from esphome.const import CONF_DATA -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload CODEOWNERS = ["@ssieb"] @@ -17,7 +17,7 @@ CONFIG_SCHEMA = ( button.button_schema(UARTButton) .extend( { - cv.Required(CONF_DATA): validate_raw_data, + cv.Required(CONF_DATA): validate_raw_payload, } ) .extend(uart.UART_DEVICE_SCHEMA) @@ -31,6 +31,4 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data(cg.ArrayInitializer(*data))) + cg.add(var.set_data(automation.progmem_bytes("uart_data", data), len(data))) diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp index b55207cb46e..99b5ee548d0 100644 --- a/esphome/components/uart/button/uart_button.cpp +++ b/esphome/components/uart/button/uart_button.cpp @@ -7,7 +7,7 @@ ESPHOME_LOG_TAG(TAG, "uart.button"); void UARTButton::press_action() { ESP_LOGD(TAG, "'%s': Sending data", this->get_name().c_str()); - this->write_array(this->data_.data(), this->data_.size()); + this->write_array_progmem(this->data_, this->data_len_); } void UARTButton::dump_config() { LOG_BUTTON("", "UART Button", this); } diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 47f45d4899c..903f945fd30 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -4,20 +4,21 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/button/button.h" -#include - namespace esphome::uart { class UARTButton final : public button::Button, public UARTDevice, public Component { public: - void set_data(std::vector &&data) { this->data_ = std::move(data); } - void set_data(std::initializer_list data) { this->data_ = std::vector(data); } + void set_data(const uint8_t *data, uint16_t len) { + this->data_ = data; + this->data_len_ = len; + } void dump_config() override; protected: void press_action() override; - std::vector data_; + const uint8_t *data_{nullptr}; + uint16_t data_len_{0}; }; } // namespace esphome::uart diff --git a/esphome/components/uart/switch/__init__.py b/esphome/components/uart/switch/__init__.py index 290bbed5d30..3288ceeee20 100644 --- a/esphome/components/uart/switch/__init__.py +++ b/esphome/components/uart/switch/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import switch, uart import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_SEND_EVERY -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload DEPENDENCIES = ["uart"] @@ -18,11 +18,11 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_DATA): cv.Any( - validate_raw_data, + validate_raw_payload, cv.Schema( { - cv.Optional(CONF_TURN_OFF): validate_raw_data, - cv.Optional(CONF_TURN_ON): validate_raw_data, + cv.Optional(CONF_TURN_OFF): validate_raw_payload, + cv.Optional(CONF_TURN_ON): validate_raw_payload, } ), ), @@ -40,20 +40,16 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, dict): - if data_on := data.get(CONF_TURN_ON): - if isinstance(data_on, bytes): - data_on = [HexInt(x) for x in data_on] - cg.add(var.set_data_on(cg.ArrayInitializer(*data_on))) - if data_off := data.get(CONF_TURN_OFF): - if isinstance(data_off, bytes): - data_off = [HexInt(x) for x in data_off] - cg.add(var.set_data_off(cg.ArrayInitializer(*data_off))) - else: - data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data_on(cg.ArrayInitializer(*data))) + single_state = not isinstance(data, dict) + if single_state: + data = {CONF_TURN_ON: data} + for key, setter in ( + (CONF_TURN_ON, var.set_data_on), + (CONF_TURN_OFF, var.set_data_off), + ): + if payload := data.get(key): + cg.add(setter(automation.progmem_bytes("uart_data", payload), len(payload))) + if single_state: cg.add(var.set_single_state(True)) if CONF_SEND_EVERY in config: cg.add(var.set_send_every(config[CONF_SEND_EVERY])) diff --git a/esphome/components/uart/switch/uart_switch.cpp b/esphome/components/uart/switch/uart_switch.cpp index 6b6aa273f7a..1af497ffcfd 100644 --- a/esphome/components/uart/switch/uart_switch.cpp +++ b/esphome/components/uart/switch/uart_switch.cpp @@ -17,13 +17,13 @@ void UARTSwitch::loop() { } void UARTSwitch::write_command_(bool state) { - if (state && !this->data_on_.empty()) { + if (state && this->data_on_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending on data", this->get_name().c_str()); - this->write_array(this->data_on_.data(), this->data_on_.size()); + this->write_array_progmem(this->data_on_, this->data_on_len_); } - if (!state && !this->data_off_.empty()) { + if (!state && this->data_off_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending off data", this->get_name().c_str()); - this->write_array(this->data_off_.data(), this->data_off_.size()); + this->write_array_progmem(this->data_off_, this->data_off_len_); } } diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index c924c7d4e54..96d8833d7a8 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -5,7 +5,6 @@ #include "esphome/components/switch/switch.h" #include -#include namespace esphome::uart { @@ -13,10 +12,14 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo public: void loop() override; - void set_data_on(std::vector &&data) { this->data_on_ = std::move(data); } - void set_data_on(std::initializer_list data) { this->data_on_ = std::vector(data); } - void set_data_off(std::vector &&data) { this->data_off_ = std::move(data); } - void set_data_off(std::initializer_list data) { this->data_off_ = std::vector(data); } + void set_data_on(const uint8_t *data, uint16_t len) { + this->data_on_ = data; + this->data_on_len_ = len; + } + void set_data_off(const uint8_t *data, uint16_t len) { + this->data_off_ = data; + this->data_off_len_ = len; + } void set_send_every(uint32_t send_every) { this->send_every_ = send_every; } void set_single_state(bool single) { this->single_state_ = single; } @@ -25,8 +28,10 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo protected: void write_command_(bool state); void write_state(bool state) override; - std::vector data_on_; - std::vector data_off_; + const uint8_t *data_on_{nullptr}; + const uint8_t *data_off_{nullptr}; + uint16_t data_on_len_{0}; + uint16_t data_off_len_{0}; bool single_state_{false}; uint32_t send_every_; uint32_t last_transmission_; diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index d1c67733634..7a34a20583e 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -25,6 +25,7 @@ class UARTDevice { size_t get_rx_timeout() { return this->parent_->get_rx_timeout(); } void write_array(const uint8_t *data, size_t len) { this->parent_->write_array(data, len); } + void write_array_progmem(const uint8_t *data, size_t len) { this->parent_->write_array_progmem(data, len); } void write_array(const std::vector &data) { this->parent_->write_array(data); } template void write_array(const std::array &data) { this->parent_->write_array(data.data(), data.size()); diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 82bc5927024..e77e37cd089 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -46,4 +46,17 @@ void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { this->set_rx_full_threshold(val); } +#ifdef USE_ESP8266 +void UARTComponent::write_array_progmem(const uint8_t *data, size_t len) { + uint8_t buf[32]; + while (len > 0) { + const size_t chunk = std::min(len, sizeof(buf)); + progmem_memcpy(buf, data, chunk); + this->write_array(buf, chunk); + data += chunk; + len -= chunk; + } +} +#endif + } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 6d446fd6a6f..14709d44906 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -65,6 +65,13 @@ class UARTComponent { // @param len Length of the array. virtual void write_array(const uint8_t *data, size_t len) = 0; + // Writes bytes that may live in PROGMEM; ESP8266 cannot read flash bytewise, so it copies chunks. +#ifdef USE_ESP8266 + void write_array_progmem(const uint8_t *data, size_t len); +#else + void write_array_progmem(const uint8_t *data, size_t len) { this->write_array(data, len); } +#endif + // Reads a single byte from the UART bus. // @param data Pointer to the byte where the read data will be stored. // @return True if a byte was successfully read, false otherwise. diff --git a/tests/component_tests/uart/__init__.py b/tests/component_tests/uart/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/uart/config/payload_tables.yaml b/tests/component_tests/uart/config/payload_tables.yaml new file mode 100644 index 00000000000..e93ab4754d0 --- /dev/null +++ b/tests/component_tests/uart/config/payload_tables.yaml @@ -0,0 +1,34 @@ +--- +esphome: + name: test + on_boot: + then: + - uart.write: [0x01, 0x02, 0x03] + - uart.write: !lambda return {0x09}; + - uart.write: "" + +esp32: + board: esp32dev + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 9600 + +switch: + - platform: uart + id: shared_switch + name: Shared Switch + data: [0x01, 0x02, 0x03] + - platform: uart + id: on_off_switch + name: On Off Switch + data: + turn_on: "ON" + turn_off: [0x04] + +button: + - platform: uart + id: shared_button + name: Shared Button + data: [0x01, 0x02, 0x03] diff --git a/tests/component_tests/uart/test_payload_tables.py b/tests/component_tests/uart/test_payload_tables.py new file mode 100644 index 00000000000..2ba341136a7 --- /dev/null +++ b/tests/component_tests/uart/test_payload_tables.py @@ -0,0 +1,28 @@ +"""Tests for UART constant payloads in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_constant_payloads_share_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal payloads share one table across switch, button and write action.""" + main_cpp = generate_main(component_config_path("payload_tables.yaml")) + + tables = dict( + re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + ) + shared = next(k for k, v in tables.items() if v == "{0x01, 0x02, 0x03}") + assert sorted(tables.values()) == sorted( + ["{0x01, 0x02, 0x03}", "{0x4F, 0x4E}", "{0x04}"] + ) + assert f"shared_switch->set_data_on({shared}, 3);" in main_cpp + assert f"shared_button->set_data({shared}, 3);" in main_cpp + assert f"set_data_static({shared}, 3);" in main_cpp + assert "set_data_static(nullptr, 0);" in main_cpp + assert "set_data_template(" in main_cpp 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/651] [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 c44a323f65d..78778553aab 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 9dfc700f748..e7d4be8cc3d 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 bab413f04d7..f1c3887d5c2 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 854ef0fb494..2858b41f719 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/651] [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 17aeb1467d3..a59c03ed1e5 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 d6aba02f920..00000000000 --- 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 00000000000..b79d871f0ff --- /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 00000000000..bbf01242bd3 --- /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/651] [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 07f86c1330f..7b9f616460b 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 c2dc752ca5a..9bef819e6d0 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 00000000000..bc3510950c1 --- /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 00000000000..17b435f5a27 --- /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/651] [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 ac5469fc42c..cb54e89c3ca 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 4c587904b73..e5ce09172b7 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 00000000000..d64f7b5e829 --- /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 00000000000..0cfba2d491a --- /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/651] [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 fbbc09bd2d1..243185b674d 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 bd6fb3cd23c..00000000000 --- 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 00000000000..585cb30c2de --- /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 00000000000..aecb27e6023 --- /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/651] [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 be12d0aa38a..9b4cc53a2e8 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 98df98ce5d6..40e9c0b1aaf 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 323857c37e8..5fa3ea054b5 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 9a6e419e439..953351f259b 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 4c0ce28a5ac..14275412294 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/651] [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 f168a825d36..261f78b2623 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/651] [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 c54d1bc16f0..50be278bb36 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/651] [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 cc5f672ad55..effa7d864fc 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 1b5ea9ebcd6..e2b5de6f106 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 f4597f2522f..1d50555cd1b 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 00000000000..4ec0a373550 --- /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 00000000000..49e3a23f234 --- /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 2b21291c30b..77ff0f47358 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 ed8057e289a..69158cc8fe0 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 00000000000..dceba802ae8 --- /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 00000000000..268515106ff --- /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 7f3b706cd8d..9cba1647163 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 89002cdd19e..a91c455e0fd 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 d1a70c5aa84..8f5860e26e1 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/651] [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 ac0b33b6aef..906781e17bf 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/651] [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 a6101812223..4ba93af01f1 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 557de0e3422..fc4eac6477c 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 8ffbb44771a..6f695307de8 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 00000000000..6c5854d8af5 --- /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 b9ae061c253..c0c97c2becb 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 0d57039e1c4..d1d376eebc1 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 65e6252c511..3850be00707 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 00000000000..9ad82dcf964 --- /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/651] [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 2f90cbfcbbf..393a79422bc 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 eca57bca8a3..01e25714e7b 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 ed9d6aa6712..539aa0d75ba 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 bfea463b7d4..f85df953056 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 c71390c267c..67a2d5a06a1 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 09bead18b3c..31e4f5c47e5 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 29a306ce291..d5ce29d436a 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 f319b55f432..85ea1faa38a 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 954c3cf1d38..06f77f8050d 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 4e879f0f75b..ef9dc39fb3b 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 663e50fb4b5..5afa28e07be 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 0c8797af70c..55e8137b752 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 44bd873a65b..dd3288103c8 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 5911664fc39..58991fc8f4a 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 093f2904aa2..e391212e42d 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 91dfbadb0c2..41ca3a2b040 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 f0d2410fe27..98a5fe20819 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 1337f7a8b32..849952c40f8 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 ebf85387b04..b4d2ec40304 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 c6f0f1dd97f..55efb1e9a3c 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 84df3c08b72..da8e3a0ff7f 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 d02bd3451f2..b3d71d7ac9c 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 f6f0f33c6e2..d720fb11cc0 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 7205fb6221a..38db90c97bc 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 36d2a44c509..be0c7a75c56 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

::type(); // NOLINT +} + +// Codegen calls only the configured protocols, so the linker drops the other classes +#define HEATPUMPIR_PROTOCOL(protocol, cls) \ + template<> struct ProtocolClass { using type = cls; }; \ + template HeatpumpIR *create_heatpump_ir(); + +HEATPUMPIR_PROTOCOL(PROTOCOL_AUX, AUXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BALLU, BalluHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_MCA, CarrierMCAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_NQV, CarrierNQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC417, DaikinHeatpumpARC417IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC480, DaikinHeatpumpARC480A14IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN, DaikinHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ELECTROLUXYAL, ElectroluxYALHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUEGO, FuegoHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUJITSU_AWYZ, FujitsuHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREE, GreeGenericHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAA, GreeYAAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAN, GreeYANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAC, GreeYACHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYT, GreeYTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAP, GreeYAPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HISENSE_AUD, HisenseHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HITACHI, HitachiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HYUNDAI, HyundaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_IVT, IVTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MIDEA, MideaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FA, MitsubishiFAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FD, MitsubishiFDHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FE, MitsubishiFEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_FDTC, MitsubishiHeavyFDTCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZJ, MitsubishiHeavyZJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZM, MitsubishiHeavyZMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZMP, MitsubishiHeavyZMPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_KJ, MitsubishiKJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSC, MitsubishiMSCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSY, MitsubishiMSYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_SEZ, MitsubishiSEZKDXXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_CKP, PanasonicCKPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_DKE, PanasonicDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_EKE, PanasonicEKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_JKE, PanasonicJKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_LKE, PanasonicLKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_NKE, PanasonicNKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV, SamsungAQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_FJM, SamsungFJMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SHARP, SharpHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA_DAISEIKAI, ToshibaDaiseikaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA, ToshibaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHLT01, ZHLT01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_NIBE, NibeHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_1, Qlima1HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_2, Qlima2HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV12MSAN, SamsungAQV12MSANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHJG01, ZHJG01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_AIRWAY, AIRWAYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BGH_AUD, BGHHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_ALTDKE, PanasonicAltDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PHILCO_PHS32, PhilcoPHS32HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_VAILLANTVAI8, VaillantHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_R51M, R51MHeatpumpIR) + +#undef HEATPUMPIR_PROTOCOL void HeatpumpIRClimate::setup() { - auto protocol_constructor = PROTOCOL_CONSTRUCTOR_MAP.find(protocol_); - if (protocol_constructor == PROTOCOL_CONSTRUCTOR_MAP.end()) { - ESP_LOGE(TAG, "Invalid protocol"); - return; - } - this->heatpump_ir_ = protocol_constructor->second(); climate_ir::ClimateIR::setup(); if (this->sensor_) { this->sensor_->add_on_state_callback([this](float state) { diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index d00fa91d05a..dfa5c40366a 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -89,25 +89,21 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 5, }; +/// Creates the HeatpumpIR library class for a protocol; instantiated in heatpumpir.cpp. +template HeatpumpIR *create_heatpump_ir(); + +/// Selects the protocol at compile time so the enum and the library object always match. +template struct ProtocolTag {}; + // Temperature const float TEMP_MIN = 0; // Celsius const float TEMP_MAX = 100; // Celsius class HeatpumpIRClimate final : public climate_ir::ClimateIR { public: - HeatpumpIRClimate() - : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, - climate::CLIMATE_FAN_AUTO}, - {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} + template + explicit HeatpumpIRClimate(ProtocolTag

/*tag*/) : HeatpumpIRClimate(P, create_heatpump_ir

()) {} void setup() override; - void set_protocol(Protocol protocol) { - this->protocol_ = protocol; - 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) { this->default_horizontal_direction_ = horizontal_direction; } @@ -119,6 +115,18 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { void set_min_temperature(float temperature) { this->min_temperature_ = temperature; } protected: + HeatpumpIRClimate(Protocol protocol, HeatpumpIR *heatpump_ir) + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_FAN_AUTO}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}), + heatpump_ir_(heatpump_ir), + protocol_(protocol) { + if (this->has_fan_byte_presets_()) { + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; + } + } HeatpumpIR *heatpump_ir_; void control(const climate::ClimateCall &call) override; /// Transmit via IR the state of this climate controller. From 1146b77836f59944a18001d95e675bfb67f1f5d9 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 14 Oct 2026 08:19:17 +0300 Subject: [PATCH 348/651] [socket] Add TcpClientLink::connecting() (#20722) --- esphome/components/socket/tcp_client_link.h | 2 ++ .../socket/test_tcp_client_link_host.cpp | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/esphome/components/socket/tcp_client_link.h b/esphome/components/socket/tcp_client_link.h index f77ee050555..517a83375df 100644 --- a/esphome/components/socket/tcp_client_link.h +++ b/esphome/components/socket/tcp_client_link.h @@ -61,6 +61,8 @@ class TcpClientLink { void close(); bool connected() const { return this->connected_; } + /// A connect attempt is in flight. + bool connecting() const { return this->sock_ != nullptr && !this->connected_; } bool ready() const { return this->sock_ != nullptr && this->sock_->ready(); } /// Shared retry clock, also usable for a listen socket. void note_attempt() { this->last_attempt_ms_ = App.get_loop_component_start_time(); } diff --git a/tests/components/socket/test_tcp_client_link_host.cpp b/tests/components/socket/test_tcp_client_link_host.cpp index 98be8190b00..a5be9225e52 100644 --- a/tests/components/socket/test_tcp_client_link_host.cpp +++ b/tests/components/socket/test_tcp_client_link_host.cpp @@ -102,6 +102,19 @@ TEST_F(TcpClientLinkClock, PendingConnectTimesOutIntoBackoff) { EXPECT_TRUE(this->link_.in_backoff()); } +TEST_F(TcpClientLinkClock, ConnectingUntilThePendingAttemptEnds) { + this->link_.begin("link_test"); + EXPECT_FALSE(this->link_.connecting()); + this->set_pending_socket(); + this->link_.note_attempt(); + this->link_.poll(); + EXPECT_TRUE(this->link_.connecting()); + set_loop_time(1000 + 10000); + this->link_.poll(); + EXPECT_FALSE(this->link_.connecting()); + EXPECT_FALSE(this->link_.connected()); +} + TEST_F(TcpClientLinkClock, ConnectTimeoutFollowsALongerInterval) { this->link_.set_reconnect_interval(20000); this->link_.begin("link_test"); @@ -177,6 +190,7 @@ TEST_F(TcpClientLinkClock, BackoffSpansAMillisWrap) { TEST(TcpClientLink, AdoptedSocketFlushesQueuedBytes) { LinkPeer p; ASSERT_TRUE(p.link_.connected()); + EXPECT_FALSE(p.link_.connecting()); EXPECT_EQ(p.link_.queue(reinterpret_cast("ping"), 4), 4u); EXPECT_TRUE(p.link_.flush_tx()); char buf[8]; From a97604d5746ed7c3045ca539ea319ce52edfd0b1 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 14 Oct 2026 01:10:51 -0500 Subject: [PATCH 349/651] [esp32_ble] Rely on preferences for NVS init (#20708) Co-authored-by: Claude Opus 5.5 --- esphome/components/esp32/preferences.cpp | 1 + esphome/components/esp32_ble/ble.cpp | 16 ++++------------ esphome/components/esp32_ble/ble.h | 1 - .../esp32_ble_beacon/esp32_ble_beacon.cpp | 1 - .../components/esp32_ble_server/ble_server.cpp | 1 - .../esp32_ble_tracker/esp32_ble_tracker.cpp | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 1 - 7 files changed, 5 insertions(+), 17 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 02978ae0f7a..87cb3345b08 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -131,6 +131,7 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { void ESP32Preferences::open() { // Runs from app_main() before the logger is initialized; any logging here // must be deferred. See s_open_err and make_preference() below. + // Components that need NVS check nvs_handle instead of initializing it again. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 11290a95f41..1379584120b 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #ifndef CONFIG_BT_CONTROLLER_DISABLED #include @@ -28,7 +29,6 @@ extern "C" { #include #include #include -#include #ifdef USE_ARDUINO // Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c). @@ -77,8 +77,9 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; void ESP32BLE::setup() { global_ble = this; - if (!ble_pre_setup_()) { - ESP_LOGE(TAG, "BLE could not be prepared for configuration"); + // Preferences opens NVS in app_main(); a zero handle means it could not be opened, even after an erase + if (global_preferences->nvs_handle == 0) { + ESP_LOGE(TAG, "NVS unavailable"); this->mark_failed(); return; } @@ -185,15 +186,6 @@ void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) { } #endif -bool ESP32BLE::ble_pre_setup_() { - esp_err_t err = nvs_flash_init(); - if (err != ESP_OK) { - ESP_LOGE(TAG, "nvs_flash_init failed: %d", err); - return false; - } - return true; -} - #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_init_() { if (this->advertising_ != nullptr) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 950ae6e0bc1..ef326e1532d 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -185,7 +185,6 @@ class ESP32BLE final : public Component { } this->ble_events_.get_and_reset_dropped_count(); } - bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); #endif diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 7481d2c612c..430393f2d81 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include "esphome/core/hal.h" diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 650a765b6a5..0dec0a74c5d 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -7,7 +7,6 @@ #ifdef USE_ESP32 -#include #include #include #ifndef CONFIG_BT_CONTROLLER_DISABLED diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8d3148c2fc7..cd1c16e8f5d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #ifdef USE_OTA diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 68c0d005b57..29bbdc2b0b5 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -5,7 +5,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_check.h" -#include "nvs_flash.h" #include "zigbee_attribute_esp32.h" #include "zigbee_esp32.h" #include "esphome/core/application.h" From 7f2c3ba3f5540e241d5aa110967d4d36db410a5e Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 14 Oct 2026 09:11:47 +0300 Subject: [PATCH 350/651] [display] Let other components draw into a display (#20622) --- esphome/components/display/__init__.py | 36 +++++++ esphome/components/epaper_spi/display.py | 7 +- esphome/components/it8951/display.py | 6 +- .../config/epaper_spi_owner_auto_id.yaml | 28 ++++++ .../display/config/it8951_owner_auto_id.yaml | 21 ++++ .../display/config/no_owner.yaml | 25 +++++ .../display/config/owner_by_id.yaml | 33 +++++++ .../fake_display_writer/__init__.py | 20 ++++ .../display/test_external_writer.py | 98 +++++++++++++++++++ 9 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml create mode 100644 tests/component_tests/display/config/it8951_owner_auto_id.yaml create mode 100644 tests/component_tests/display/config/no_owner.yaml create mode 100644 tests/component_tests/display/config/owner_by_id.yaml create mode 100644 tests/component_tests/display/external_components/fake_display_writer/__init__.py create mode 100644 tests/component_tests/display/test_external_writer.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 0b871af30cd..22e3d9320b0 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -250,6 +250,42 @@ def add_metadata( ) +KEY_EXTERNAL_WRITERS = "external_writers" + + +def _get_external_writer_list() -> list[tuple[ID, str]]: + """Get the raw external writer list. Each entry is (display id, owner).""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_EXTERNAL_WRITERS, []) + + +def register_external_writer(display_id: ID, owner: str) -> None: + """Declare that ``owner`` draws into the display from outside its lambda or pages. + + Call from the owner's CONFIG_SCHEMA. The ID may still be unresolved; the ID pass + resolves it in place before final validation. + """ + _get_external_writer_list().append((display_id, owner)) + + +def get_external_writers(display_id: ID) -> list[str]: + """Get the owners registered for a display. Only valid once IDs are resolved.""" + return [ + owner + for id_, owner in _get_external_writer_list() + if id_.id is not None and id_.id == display_id.id + ] + + +def has_external_writer(display_id: ID) -> bool: + """Tell a driver's final validation that something else draws into the display. + + LVGL anywhere in the config also counts, as the drivers assumed before. + """ + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + return LVGL_DOMAIN in full_config.get() or bool(get_external_writers(display_id)) + + async def register_display(var, config): await cg.register_component(var, config) await setup_display_core_(var, config) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 487e8e5ef40..06aa53acb69 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -40,7 +40,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from . import models @@ -198,15 +197,11 @@ def _final_validate(config) -> None: "epaper_spi", require_miso=False, require_mosi=True )(config) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: - # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 76fbadd1968..d9c80ebf43d 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -37,7 +37,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from esphome.types import ConfigType AUTO_LOAD = ["split_buffer"] @@ -348,11 +347,8 @@ def _final_validate(config: ConfigType) -> None: config ) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: diff --git a/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml new file mode 100644 index 00000000000..73d993fb290 --- /dev/null +++ b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: epaper_spi + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: diff --git a/tests/component_tests/display/config/it8951_owner_auto_id.yaml b/tests/component_tests/display/config/it8951_owner_auto_id.yaml new file mode 100644 index 00000000000..ed6b0fd8994 --- /dev/null +++ b/tests/component_tests/display/config/it8951_owner_auto_id.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + model: m5stack-m5paper + +fake_display_writer: diff --git a/tests/component_tests/display/config/no_owner.yaml b/tests/component_tests/display/config/no_owner.yaml new file mode 100644 index 00000000000..4a4714779a2 --- /dev/null +++ b/tests/component_tests/display/config/no_owner.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 diff --git a/tests/component_tests/display/config/owner_by_id.yaml b/tests/component_tests/display/config/owner_by_id.yaml new file mode 100644 index 00000000000..e3be24deffe --- /dev/null +++ b/tests/component_tests/display/config/owner_by_id.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: + display_id: epaper_display diff --git a/tests/component_tests/display/external_components/fake_display_writer/__init__.py b/tests/component_tests/display/external_components/fake_display_writer/__init__.py new file mode 100644 index 00000000000..5e1388578fa --- /dev/null +++ b/tests/component_tests/display/external_components/fake_display_writer/__init__.py @@ -0,0 +1,20 @@ +"""External component that draws into a display from outside its lambda or pages.""" + +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_DISPLAY_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["display"] +DOMAIN = "fake_display_writer" + + +def _register_writer(config: ConfigType) -> ConfigType: + display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display)}), + _register_writer, +) diff --git a/tests/component_tests/display/test_external_writer.py b/tests/component_tests/display/test_external_writer.py new file mode 100644 index 00000000000..c51bf15b579 --- /dev/null +++ b/tests/component_tests/display/test_external_writer.py @@ -0,0 +1,98 @@ +"""Tests for the display external writer registry.""" + +from collections.abc import Callable, Generator +from pathlib import Path +import sys + +import pytest + +from esphome import loader +from esphome.components.display import ( + Display, + get_external_writers, + has_external_writer, + register_external_writer, +) +from esphome.core import ID +from esphome.final_validate import full_config + +TEST_CARD = "show_test_card()" +UPDATE_NEVER = "set_update_interval(4294967295UL)" + + +@pytest.fixture +def unload_fake_writer() -> Generator[None]: + """Drop the fake external component so later tests start clean.""" + yield + loader._COMPONENT_CACHE.pop("fake_display_writer", None) + sys.modules.pop("esphome.components.fake_display_writer", None) + loader.clear_component_meta_finders() + + +def test_unresolved_id_matches_after_resolution() -> None: + """An auto-picked ID registered during schema validation matches once resolved.""" + pending = ID(None, is_declaration=False, type=Display) + register_external_writer(pending, "owner") + declared = ID("epd", is_declaration=True, type=Display) + + assert get_external_writers(declared) == [] + assert has_external_writer(declared) is False + + pending.id = "epd" + assert get_external_writers(declared) == ["owner"] + assert has_external_writer(declared) is True + assert get_external_writers(ID("other", is_declaration=True, type=Display)) == [] + + +def test_all_owners_returned() -> None: + register_external_writer(ID("epd"), "first") + register_external_writer(ID("epd"), "second") + register_external_writer(ID("other"), "third") + + assert get_external_writers(ID("epd")) == ["first", "second"] + + +def test_lvgl_counts_as_external_writer() -> None: + assert has_external_writer(ID("epd")) is False + full_config.get()["lvgl"] = [] + assert has_external_writer(ID("epd")) is True + + +def test_drivers_show_test_card_without_owner( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("no_owner.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" in main_cpp + assert UPDATE_NEVER not in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +@pytest.mark.parametrize( + "config_file", ["it8951_owner_auto_id.yaml", "epaper_spi_owner_auto_id.yaml"] +) +def test_owned_display_has_no_test_card( + config_file: str, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The owner picks the display ID automatically, so it resolves after the schema runs.""" + main_cpp = generate_main(component_config_path(config_file)) + + assert TEST_CARD not in main_cpp + assert UPDATE_NEVER in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +def test_only_owned_display_loses_test_card( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("owner_by_id.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" not in main_cpp + assert f"epaper_display->{UPDATE_NEVER}" in main_cpp + assert f"it8951_display->{UPDATE_NEVER}" not in main_cpp From 35b416c6c0bc85135811bca6a98150c3160d5558 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Wed, 14 Oct 2026 08:19:06 +0000 Subject: [PATCH 351/651] [web_server_idf] Keep active SSE sessions out of the LRU purge (#17383) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../web_server_idf/web_server_idf.cpp | 34 +++++++++++++++++-- .../web_server_idf/web_server_idf.h | 12 +++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index d9a7d65e8ce..456c9be2c9a 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -959,10 +959,39 @@ void AsyncEventSourceResponse::process_close_() { } } +void AsyncEventSourceResponse::refresh_lru_() { + // httpd only advances a session's LRU counter when a request arrives, and an event stream receives one + // request ever, so the purge that frees a socket for a new client would evict the live streams first. + // The HTTPD task owns the session table, so the refresh runs there. + // A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh. + if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0) + return; + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS || + this->lru_work_queued_.load(std::memory_order_acquire)) { + return; + } + this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval + this->lru_work_queued_.store(true, std::memory_order_release); + if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) { + this->lru_work_queued_.store(false, std::memory_order_release); + } +} + +void AsyncEventSourceResponse::refresh_lru_work(void *arg) { + auto *response = static_cast(arg); + const int fd = response->fd_.load(); + if (response->owns_session_(fd)) { + httpd_sess_update_lru_counter(response->hd_, fd); + } + // Release self only after the HTTPD-task callback has finished every access. + response->lru_work_queued_.store(false, std::memory_order_release); +} + void AsyncEventSourceResponse::close_session_work(void *arg) { auto *response = static_cast(arg); const int fd = response->fd_.load(); - if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { + if (response->owns_session_(fd)) { // The HTTPD task remains the session owner. Shutting the socket down makes // its next select/recv path delete the session and invoke destroy(). shutdown(fd, SHUT_RDWR); @@ -976,7 +1005,7 @@ ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) { // httpd frees a session before closing its socket, so the fd may already be a new client's. // Treated as would-block; the stall timer ends a session that never becomes ours again. const int fd = this->fd_.load(); - if (httpd_sess_get_ctx(this->hd_, fd) != this) { + if (!this->owns_session_(fd)) { return 0; } struct msghdr msg {}; @@ -1085,6 +1114,7 @@ void AsyncEventSourceResponse::loop() { this->process_close_(); return; } + this->refresh_lru_(); drain_tail_(); process_deferred_queue_(); if (this->close_requested_) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 4d4490d9076..c25dd8d609f 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -339,10 +339,15 @@ class AsyncEventSourceResponse { }; void process_close_(); static void close_session_work(void *arg); + void refresh_lru_(); + static void refresh_lru_work(void *arg); + // httpd frees a session before closing its socket, so the fd may already belong to a new client + bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; } - // Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. + // Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object. bool safe_to_delete_() const { - return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); + return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) && + !this->lru_work_queued_.load(std::memory_order_acquire); } static void destroy(void *p); @@ -357,11 +362,13 @@ class AsyncEventSourceResponse { RAMUniquePtr tail_; uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. uint32_t next_close_attempt_ms_{0}; + uint32_t last_lru_refresh_ms_{0}; uint16_t tail_cap_{0}; uint16_t tail_len_{0}; // Zero means nothing pending uint16_t tail_sent_{0}; // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. std::atomic close_work_queued_{false}; + std::atomic lru_work_queued_{false}; // Main-loop only; the HTTPD task never reads or writes these flags. bool close_requested_{false}; bool close_retry_warning_logged_{false}; @@ -383,6 +390,7 @@ class AsyncEventSourceResponse { static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; + static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000; }; using AsyncEventSourceClient = AsyncEventSourceResponse; From 56cc03c4d4e6696fc1d4f0ca745748a57205ac0c Mon Sep 17 00:00:00 2001 From: Pavlo Dudnytskyi Date: Wed, 14 Oct 2026 11:27:18 +0200 Subject: [PATCH 352/651] [haier] Haier Select component added (#16148) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Pavlo Dudnytskyi Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/haier/hon_climate.cpp | 176 ++++++++++++++++-- esphome/components/haier/hon_climate.h | 38 ++++ esphome/components/haier/select/__init__.py | 67 +++++++ .../haier/select/horizontal_airflow.h | 15 ++ .../haier/select/vertical_airflow.h | 15 ++ tests/components/haier/common.yaml | 8 + 7 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 esphome/components/haier/select/__init__.py create mode 100644 esphome/components/haier/select/horizontal_airflow.h create mode 100644 esphome/components/haier/select/vertical_airflow.h diff --git a/CODEOWNERS b/CODEOWNERS index 95d8c49c23e..1282e9d66b9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -240,6 +240,7 @@ esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/haier/* @paveldn esphome/components/haier/binary_sensor/* @paveldn esphome/components/haier/button/* @paveldn +esphome/components/haier/select/* @paveldn esphome/components/haier/sensor/* @paveldn esphome/components/haier/switch/* @paveldn esphome/components/haier/text_sensor/* @paveldn diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 72b1e26818b..59484aab16d 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -2,6 +2,8 @@ #include #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/progmem.h" #include "hon_climate.h" @@ -12,6 +14,26 @@ using namespace esphome::uart; namespace esphome::haier { +static bool vertical_axis_is_auto(climate::ClimateSwingMode swing_mode) { + return (swing_mode == climate::CLIMATE_SWING_VERTICAL) || (swing_mode == climate::CLIMATE_SWING_BOTH); +} + +static bool horizontal_axis_is_auto(climate::ClimateSwingMode swing_mode) { + return (swing_mode == climate::CLIMATE_SWING_HORIZONTAL) || (swing_mode == climate::CLIMATE_SWING_BOTH); +} + +static bool is_auto(hon_protocol::VerticalSwingMode mode) { + return (mode == hon_protocol::VerticalSwingMode::AUTO) || (mode == hon_protocol::VerticalSwingMode::AUTO_SPECIAL); +} + +static bool is_auto(hon_protocol::HorizontalSwingMode mode) { return mode == hon_protocol::HorizontalSwingMode::AUTO; } + +// A position the user just set wins over the appliance's echo of the old one for this long +constexpr uint32_t AIRFLOW_SELECT_DEBOUNCE_MS = 10000; +static bool in_debounce(uint32_t set_time, uint32_t now) { + return set_time != 0 && now - set_time < AIRFLOW_SELECT_DEBOUNCE_MS; +} + ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; constexpr int PROTOCOL_OUTDOOR_TEMPERATURE_OFFSET = -64; @@ -89,21 +111,60 @@ bool HonClimate::get_quiet_mode_state() const { } esphome::optional HonClimate::get_vertical_airflow() const { + if (!this->current_vertical_swing_.has_value()) { + return this->current_vertical_swing_; + } + // While the appliance swings, or right after the user chose a position, report the chosen position + if (is_auto(this->current_vertical_swing_.value()) || + in_debounce(this->vertical_direction_set_time_, App.get_loop_component_start_time())) { + return this->settings_.last_vertiacal_swing; + } return this->current_vertical_swing_; -}; +} void HonClimate::set_vertical_airflow(hon_protocol::VerticalSwingMode direction) { + if (is_auto(direction)) { + ESP_LOGW(TAG, "Vertical AUTO mode is not allowed for airflow position selector"); + return; + } + if (this->settings_.last_vertiacal_swing != direction) { + this->settings_.last_vertiacal_swing = direction; + this->hon_rtc_.save(&this->settings_); + } this->pending_vertical_direction_ = direction; this->force_send_control_ = true; + this->vertical_direction_set_time_ = App.get_loop_component_start_time(); +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif } esphome::optional HonClimate::get_horizontal_airflow() const { + if (!this->current_horizontal_swing_.has_value()) { + return this->current_horizontal_swing_; + } + if (is_auto(this->current_horizontal_swing_.value()) || + in_debounce(this->horizontal_direction_set_time_, App.get_loop_component_start_time())) { + return this->settings_.last_horizontal_swing; + } return this->current_horizontal_swing_; } void HonClimate::set_horizontal_airflow(hon_protocol::HorizontalSwingMode direction) { + if (is_auto(direction)) { + ESP_LOGW(TAG, "Horizontal AUTO mode is not allowed for airflow position selector"); + return; + } + if (this->settings_.last_horizontal_swing != direction) { + this->settings_.last_horizontal_swing = direction; + this->hon_rtc_.save(&this->settings_); + } this->pending_horizontal_direction_ = direction; this->force_send_control_ = true; + this->horizontal_direction_set_time_ = App.get_loop_component_start_time(); +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif } const char *HonClimate::get_cleaning_status_text() const { @@ -533,6 +594,18 @@ void HonClimate::initialization() { } else { this->settings_ = {hon_protocol::VerticalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::CENTER, true, false}; } + bool settings_fixed = false; + if (is_auto(this->settings_.last_vertiacal_swing)) { + this->settings_.last_vertiacal_swing = hon_protocol::VerticalSwingMode::CENTER; + settings_fixed = true; + } + if (is_auto(this->settings_.last_horizontal_swing)) { + this->settings_.last_horizontal_swing = hon_protocol::HorizontalSwingMode::CENTER; + settings_fixed = true; + } + if (settings_fixed) { + this->hon_rtc_.save(&this->settings_); + } this->current_vertical_swing_ = this->settings_.last_vertiacal_swing; this->current_horizontal_swing_ = this->settings_.last_horizontal_swing; this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; @@ -668,12 +741,21 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { } } } + const ClimateSwingMode target_swing_mode = this->current_hvac_settings_.swing_mode.value_or(this->swing_mode); if (this->pending_vertical_direction_.has_value()) { - out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value(); + if (!vertical_axis_is_auto(target_swing_mode)) { + out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value(); + } else { + ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off"); + } this->pending_vertical_direction_.reset(); } if (this->pending_horizontal_direction_.has_value()) { - out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value(); + if (!horizontal_axis_is_auto(target_swing_mode)) { + out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value(); + } else { + ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off"); + } this->pending_horizontal_direction_.reset(); } { @@ -840,6 +922,41 @@ void HonClimate::set_quiet_mode_switch(switch_::Switch *sw) { } #endif // USE_SWITCH +#ifdef USE_SELECT +template +static void publish_airflow_index(select::Select *sel, const std::array &order, Mode mode) { + if (sel == nullptr) { + return; + } + // Saved settings never hold AUTO after initialization(); any other value outside the table shows as Center + size_t index = N; + size_t center = 0; + for (size_t i = 0; i < N; i++) { + const Mode entry = airflow_order_at(order, i); + if (entry == mode) { + index = i; + } else if (entry == Mode::CENTER) { + center = i; + } + } + if (index == N) { + index = center; + } + const auto active = sel->active_index(); + if (active.has_value() && *active == index) { + return; + } + sel->publish_state(index); +} + +void HonClimate::publish_airflow_selects_() { + publish_airflow_index(this->vertical_airflow_select_, VERTICAL_SWING_MODES_ORDER, + this->settings_.last_vertiacal_swing); + publish_airflow_index(this->horizontal_airflow_select_, HORIZONTAL_SWING_MODES_ORDER, + this->settings_.last_horizontal_swing); +} +#endif // USE_SELECT + haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *packet_buffer, uint8_t size) { size_t expected_size = 2 + this->status_message_header_size_ + this->real_control_packet_size_ + this->real_sensors_packet_size_; @@ -1074,16 +1191,25 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // Saving last known non auto mode for vertical and horizontal swing this->current_vertical_swing_ = (hon_protocol::VerticalSwingMode) packet.control.vertical_swing_mode; this->current_horizontal_swing_ = (hon_protocol::HorizontalSwingMode) packet.control.horizontal_swing_mode; - bool save_settings = ((this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO) && - (this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO_SPECIAL) && - (this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing)) || - ((this->current_horizontal_swing_.value() != hon_protocol::HorizontalSwingMode::AUTO) && - (this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing)); - if (save_settings) { - this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value(); - this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value(); + const uint32_t now_ms = App.get_loop_component_start_time(); + bool save_vertical = !in_debounce(this->vertical_direction_set_time_, now_ms) && + !is_auto(this->current_vertical_swing_.value()) && + (this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing); + bool save_horizontal = !in_debounce(this->horizontal_direction_set_time_, now_ms) && + !is_auto(this->current_horizontal_swing_.value()) && + (this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing); + if (save_vertical || save_horizontal) { + if (save_vertical) { + this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value(); + } + if (save_horizontal) { + this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value(); + } this->hon_rtc_.save(&this->settings_); } +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif should_publish = should_publish || (old_swing_mode != this->swing_mode); } this->last_valid_status_timestamp_ = std::chrono::steady_clock::now(); @@ -1259,8 +1385,10 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::SET_POINT, buffer, 2); } + ClimateSwingMode target_swing_mode = this->swing_mode; // Vertical swing mode if (climate_control.swing_mode.has_value()) { + target_swing_mode = climate_control.swing_mode.value(); uint8_t vertical_swing_buf[] = {0x00, (uint8_t) hon_protocol::VerticalSwingMode::AUTO}; uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) hon_protocol::HorizontalSwingMode::AUTO}; switch (climate_control.swing_mode.value()) { @@ -1286,6 +1414,32 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE, vertical_swing_buf, 2); } + // A swing change above already sent both axes from settings_.last_*, which set_*_airflow() updated + const bool swing_sent = climate_control.swing_mode.has_value(); + if (this->pending_vertical_direction_.has_value()) { + if (vertical_axis_is_auto(target_swing_mode)) { + ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off"); + } else if (!swing_sent) { + uint8_t vertical_swing_buf[] = {0x00, (uint8_t) this->pending_vertical_direction_.value()}; + this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE, + vertical_swing_buf, 2); + } + this->pending_vertical_direction_.reset(); + } + if (this->pending_horizontal_direction_.has_value()) { + if (horizontal_axis_is_auto(target_swing_mode)) { + ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off"); + } else if (!swing_sent) { + uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) this->pending_horizontal_direction_.value()}; + this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::HORIZONTAL_SWING_MODE, + horizontal_swing_buf, 2); + } + this->pending_horizontal_direction_.reset(); + } // Fan mode if (climate_control.fan_mode.has_value()) { switch (climate_control.fan_mode.value()) { diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index a34b4422c69..db4ef4590bf 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #ifdef USE_SENSOR @@ -14,12 +15,36 @@ #ifdef USE_SWITCH #include "esphome/components/switch/switch.h" #endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif #include "esphome/core/automation.h" +#include "esphome/core/hal.h" #include "haier_base.h" #include "hon_packet.h" namespace esphome::haier { +#ifdef USE_SELECT +// Positions in the order the select platform lists them; in flash on ESP8266, so read with airflow_order_at() +inline constexpr std::array VERTICAL_SWING_MODES_ORDER PROGMEM{ + hon_protocol::VerticalSwingMode::HEALTH_UP, hon_protocol::VerticalSwingMode::MAX_UP, + hon_protocol::VerticalSwingMode::UP, hon_protocol::VerticalSwingMode::CENTER, + hon_protocol::VerticalSwingMode::DOWN, hon_protocol::VerticalSwingMode::MAX_DOWN, + hon_protocol::VerticalSwingMode::HEALTH_DOWN, +}; +inline constexpr std::array HORIZONTAL_SWING_MODES_ORDER PROGMEM{ + hon_protocol::HorizontalSwingMode::MAX_LEFT, hon_protocol::HorizontalSwingMode::LEFT, + hon_protocol::HorizontalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::RIGHT, + hon_protocol::HorizontalSwingMode::MAX_RIGHT, +}; + +template Mode airflow_order_at(const std::array &order, size_t index) { + static_assert(sizeof(Mode) == 1); + return static_cast(progmem_read_byte(reinterpret_cast(order.data() + index))); +} +#endif + enum class CleaningState : uint8_t { NO_CLEANING = 0, SELF_CLEAN = 1, @@ -100,6 +125,16 @@ class HonClimate final : public HaierClimateBase { protected: switch_::Switch *beeper_switch_{nullptr}; switch_::Switch *quiet_mode_switch_{nullptr}; +#endif +#ifdef USE_SELECT + public: + void set_vertical_airflow_select(select::Select *sel) { this->vertical_airflow_select_ = sel; } + void set_horizontal_airflow_select(select::Select *sel) { this->horizontal_airflow_select_ = sel; } + + protected: + void publish_airflow_selects_(); + select::Select *vertical_airflow_select_{nullptr}; + select::Select *horizontal_airflow_select_{nullptr}; #endif public: HonClimate(); @@ -195,6 +230,9 @@ class HonClimate final : public HaierClimateBase { uint8_t big_data_counter_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; + // millis() of the last user-set position per axis, 0 when none + uint32_t vertical_direction_set_time_{0}; + uint32_t horizontal_direction_set_time_{0}; HonSettings settings_{}; ESPPreferenceObject hon_rtc_; SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF}; diff --git a/esphome/components/haier/select/__init__.py b/esphome/components/haier/select/__init__.py new file mode 100644 index 00000000000..4198ba82081 --- /dev/null +++ b/esphome/components/haier/select/__init__.py @@ -0,0 +1,67 @@ +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 ..climate import ( + CONF_HAIER_ID, + CONF_HORIZONTAL_AIRFLOW, + CONF_VERTICAL_AIRFLOW, + HonClimate, + haier_ns, +) + +CODEOWNERS = ["@paveldn"] +VerticalAirflowSelect = haier_ns.class_("VerticalAirflowSelect", select.Select) +HorizontalAirflowSelect = haier_ns.class_("HorizontalAirflowSelect", select.Select) + +# Same order as VERTICAL_SWING_MODES_ORDER and HORIZONTAL_SWING_MODES_ORDER in hon_climate.h +AIRFLOW_VERTICAL_DIRECTION_OPTIONS = [ + "Health Up", + "Max Up", + "Up", + "Center", + "Down", + "Max Down", + "Health Down", +] + +AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = [ + "Max Left", + "Left", + "Center", + "Right", + "Max Right", +] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HAIER_ID): cv.use_id(HonClimate), + cv.Optional(CONF_VERTICAL_AIRFLOW): select.select_schema( + VerticalAirflowSelect, + icon="mdi:arrow-expand-vertical", + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_HORIZONTAL_AIRFLOW): select.select_schema( + HorizontalAirflowSelect, + icon="mdi:arrow-expand-horizontal", + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_VERTICAL_AIRFLOW, + hub.set_vertical_airflow_select, + options=AIRFLOW_VERTICAL_DIRECTION_OPTIONS, + ) + await selects( + CONF_HORIZONTAL_AIRFLOW, + hub.set_horizontal_airflow_select, + options=AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS, + ) diff --git a/esphome/components/haier/select/horizontal_airflow.h b/esphome/components/haier/select/horizontal_airflow.h new file mode 100644 index 00000000000..54f46bfcb19 --- /dev/null +++ b/esphome/components/haier/select/horizontal_airflow.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../hon_climate.h" + +namespace esphome::haier { + +class HorizontalAirflowSelect : public select::Select, public Parented { + protected: + void control(size_t index) override { + this->parent_->set_horizontal_airflow(airflow_order_at(HORIZONTAL_SWING_MODES_ORDER, index)); + } +}; + +} // namespace esphome::haier diff --git a/esphome/components/haier/select/vertical_airflow.h b/esphome/components/haier/select/vertical_airflow.h new file mode 100644 index 00000000000..81ac1b39d2c --- /dev/null +++ b/esphome/components/haier/select/vertical_airflow.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../hon_climate.h" + +namespace esphome::haier { + +class VerticalAirflowSelect : public select::Select, public Parented { + protected: + void control(size_t index) override { + this->parent_->set_vertical_airflow(airflow_order_at(VERTICAL_SWING_MODES_ORDER, index)); + } +}; + +} // namespace esphome::haier diff --git a/tests/components/haier/common.yaml b/tests/components/haier/common.yaml index ab39dfa61d5..2c0283215f6 100644 --- a/tests/components/haier/common.yaml +++ b/tests/components/haier/common.yaml @@ -121,3 +121,11 @@ switch: name: Haier health mode quiet_mode: name: Haier quiet mode + +select: + - platform: haier + haier_id: haier_ac + horizontal_airflow: + name: Haier airflow horizontal + vertical_airflow: + name: Haier airflow vertical From 34e1cf96d583156bebf26ecf8ca305962a04a36f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 02:58:56 -1000 Subject: [PATCH 353/651] [bmi270] Build the component on ESP8266 in CI (#20731) --- tests/components/bmi270/test.esp8266-ard.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/components/bmi270/test.esp8266-ard.yaml diff --git a/tests/components/bmi270/test.esp8266-ard.yaml b/tests/components/bmi270/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7163710d74b --- /dev/null +++ b/tests/components/bmi270/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bmi270: !include common.yaml From d9298cb7b91c3a238af6a1346b53d7b87ff8bf0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 02:59:27 -1000 Subject: [PATCH 354/651] [seeed_mr60bha2] Compile without sensors or binary sensors (#20729) --- esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp | 12 ++++++++++++ .../seeed_mr60bha2/common-binary-sensor.yaml | 9 +++++++++ tests/components/seeed_mr60bha2/common-minimal.yaml | 3 +++ .../seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml | 3 +++ .../seeed_mr60bha2/test-minimal.esp32-idf.yaml | 3 +++ tests/components/seeed_mr60bha2/test.esp32-idf.yaml | 3 +++ .../components/seeed_mr60bha2/test.esp8266-ard.yaml | 3 +++ 7 files changed, 36 insertions(+) create mode 100644 tests/components/seeed_mr60bha2/common-binary-sensor.yaml create mode 100644 tests/components/seeed_mr60bha2/common-minimal.yaml create mode 100644 tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60bha2/test.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60bha2/test.esp8266-ard.yaml diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index c5d12924583..e86909d0491 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -162,6 +162,7 @@ bool MR60BHA2Component::validate_message_() { void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, const uint8_t *data, size_t length) { switch (frame_type) { case BREATH_RATE_TYPE_BUFFER: +#ifdef USE_SENSOR if (this->breath_rate_sensor_ != nullptr && length >= 4) { uint32_t current_breath_rate_int = encode_uint32(data[3], data[2], data[1], data[0]); if (current_breath_rate_int != 0) { @@ -170,11 +171,14 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->breath_rate_sensor_->publish_state(breath_rate_float); } } +#endif break; case PEOPLE_EXIST_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->has_target_binary_sensor_ != nullptr && length >= 2) { uint16_t has_target_int = encode_uint16(data[1], data[0]); this->has_target_binary_sensor_->publish_state(has_target_int); +#ifdef USE_SENSOR if (has_target_int == 0) { if (this->breath_rate_sensor_ != nullptr) this->breath_rate_sensor_->publish_state(0.0); @@ -185,9 +189,12 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c if (this->num_targets_sensor_ != nullptr) this->num_targets_sensor_->publish_state(0); } +#endif } +#endif break; case HEART_RATE_TYPE_BUFFER: +#ifdef USE_SENSOR if (this->heart_rate_sensor_ != nullptr && length >= 4) { uint32_t current_heart_rate_int = encode_uint32(data[3], data[2], data[1], data[0]); if (current_heart_rate_int != 0) { @@ -196,8 +203,10 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->heart_rate_sensor_->publish_state(heart_rate_float); } } +#endif break; case DISTANCE_TYPE_BUFFER: +#ifdef USE_SENSOR if (length >= 1 && data[0] != 0) { if (this->distance_sensor_ != nullptr && length >= 8) { uint32_t current_distance_int = encode_uint32(data[7], data[6], data[5], data[4]); @@ -206,12 +215,15 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->distance_sensor_->publish_state(distance_float); } } +#endif break; case PRINT_CLOUD_BUFFER: +#ifdef USE_SENSOR if (this->num_targets_sensor_ != nullptr && length >= 4) { uint32_t current_num_targets_int = encode_uint32(data[3], data[2], data[1], data[0]); this->num_targets_sensor_->publish_state(current_num_targets_int); } +#endif break; default: break; diff --git a/tests/components/seeed_mr60bha2/common-binary-sensor.yaml b/tests/components/seeed_mr60bha2/common-binary-sensor.yaml new file mode 100644 index 00000000000..b329cdbef90 --- /dev/null +++ b/tests/components/seeed_mr60bha2/common-binary-sensor.yaml @@ -0,0 +1,9 @@ +# The hub with only the presence binary sensor, which nests a USE_SENSOR guard in process_frame_() +seeed_mr60bha2: + id: my_seeed_mr60bha2_binary_sensor + +binary_sensor: + - platform: seeed_mr60bha2 + mr60bha2_id: my_seeed_mr60bha2_binary_sensor + has_target: + name: "Person Information Only" diff --git a/tests/components/seeed_mr60bha2/common-minimal.yaml b/tests/components/seeed_mr60bha2/common-minimal.yaml new file mode 100644 index 00000000000..571e5a57386 --- /dev/null +++ b/tests/components/seeed_mr60bha2/common-minimal.yaml @@ -0,0 +1,3 @@ +# The hub alone, without the sensors and binary sensor that process_frame_() guards +seeed_mr60bha2: + id: my_seeed_mr60bha2_minimal diff --git a/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml new file mode 100644 index 00000000000..0f5c050ba73 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common-binary-sensor.yaml diff --git a/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml new file mode 100644 index 00000000000..a97c76ca583 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common-minimal.yaml diff --git a/tests/components/seeed_mr60bha2/test.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test.esp32-idf.yaml new file mode 100644 index 00000000000..18801075447 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common.yaml diff --git a/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml b/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4f849c315e2 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + seeed_mr60bha2: !include common.yaml From 66ea99ce65a3c7d6221c98139a6f2d21b69a414a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:00:08 -1000 Subject: [PATCH 355/651] [seeed_mr60fda2] Compile without binary sensors or selects (#20726) --- esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp | 8 ++++++++ tests/components/seeed_mr60fda2/common-minimal.yaml | 3 +++ .../components/seeed_mr60fda2/test-minimal.esp32-idf.yaml | 3 +++ tests/components/seeed_mr60fda2/test.esp32-idf.yaml | 3 +++ tests/components/seeed_mr60fda2/test.esp8266-ard.yaml | 3 +++ 5 files changed, 20 insertions(+) create mode 100644 tests/components/seeed_mr60fda2/common-minimal.yaml create mode 100644 tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60fda2/test.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60fda2/test.esp8266-ard.yaml diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 332c59061b1..6b5fb087171 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -96,6 +96,7 @@ static bool validate_checksum(const uint8_t *data, size_t len, uint8_t expected_ return calculate_checksum(data, len) == expected_checksum; } +#ifdef USE_SELECT static uint8_t find_nearest_index(float value, const float *arr, int size) { int nearest_index = 0; float min_diff = std::abs(value - arr[0]); @@ -108,6 +109,7 @@ static uint8_t find_nearest_index(float value, const float *arr, int size) { } return nearest_index; } +#endif /** * @brief Convert a float value to a byte array. @@ -250,15 +252,19 @@ void MR60FDA2Component::split_frame_(uint8_t buffer) { void MR60FDA2Component::process_frame_() { switch (this->current_frame_type_) { case IS_FALL_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->fall_detected_binary_sensor_ != nullptr) { this->fall_detected_binary_sensor_->publish_state(this->current_frame_buf_[LEN_TO_HEAD_CKSUM]); } +#endif this->current_frame_locate_ = LOCATE_FRAME_HEADER; break; case PEOPLE_EXIST_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->people_exist_binary_sensor_ != nullptr) this->people_exist_binary_sensor_->publish_state(this->current_frame_buf_[LEN_TO_HEAD_CKSUM]); +#endif this->current_frame_locate_ = LOCATE_FRAME_HEADER; break; @@ -293,6 +299,7 @@ void MR60FDA2Component::process_frame_() { float install_height_float = 0; float height_threshold_float = 0; uint32_t current_sensitivity = 0; +#ifdef USE_SELECT if (this->install_height_select_ != nullptr) { uint32_t current_install_height_int = encode_uint32(current_data_buf_[3], current_data_buf_[2], current_data_buf_[1], current_data_buf_[0]); @@ -318,6 +325,7 @@ void MR60FDA2Component::process_frame_() { uint32_t select_index = find_nearest_index(current_sensitivity, SENSITIVITY, 3); this->sensitivity_select_->publish_state(select_index); } +#endif ESP_LOGD(TAG, "Mounting height: %.2f, Height threshold: %.2f, Sensitivity: %" PRIu32, install_height_float, height_threshold_float, current_sensitivity); diff --git a/tests/components/seeed_mr60fda2/common-minimal.yaml b/tests/components/seeed_mr60fda2/common-minimal.yaml new file mode 100644 index 00000000000..da329aefbe3 --- /dev/null +++ b/tests/components/seeed_mr60fda2/common-minimal.yaml @@ -0,0 +1,3 @@ +# The hub alone, without the binary sensors and selects that process_frame_() guards +seeed_mr60fda2: + id: my_seeed_mr60fda2_minimal diff --git a/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml b/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml new file mode 100644 index 00000000000..4338267485c --- /dev/null +++ b/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60fda2: !include common-minimal.yaml diff --git a/tests/components/seeed_mr60fda2/test.esp32-idf.yaml b/tests/components/seeed_mr60fda2/test.esp32-idf.yaml new file mode 100644 index 00000000000..07faf09a8df --- /dev/null +++ b/tests/components/seeed_mr60fda2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60fda2: !include common.yaml diff --git a/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml b/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml new file mode 100644 index 00000000000..f059ca9f66f --- /dev/null +++ b/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + seeed_mr60fda2: !include common.yaml From d6abf35138be086d4b7f81685de8d49e06ddaf6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:01:10 -1000 Subject: [PATCH 356/651] [sx126x][sx127x] Stop the watchdog reset loop when a packet is sent before setup (#20727) --- esphome/components/sx126x/sx126x.cpp | 7 +++++++ esphome/components/sx126x/sx126x.h | 2 +- esphome/components/sx127x/sx127x.cpp | 7 +++++++ esphome/components/sx127x/sx127x.h | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6c1281c89b1..0bac0eb0466 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -1,4 +1,5 @@ #include "sx126x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -313,6 +314,11 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { + // Callers such as a template number's on_value can run before setup() or after a failed setup + if (!this->is_ready()) { + ESP_LOGW(TAG, "Radio not ready, packet dropped"); + return SX126xError::SX126X_ERROR_NOT_READY; + } if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX126xError::INVALID_PARAMS; @@ -333,6 +339,7 @@ SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { // wait until transmit completes, typically the delay will be less than 100 ms uint32_t start = millis(); while (!this->dio1_pin_->digital_read()) { + App.feed_wdt(); if (millis() - start > TRANSMIT_TIMEOUT_MS) { ESP_LOGE(TAG, "Transmit packet failure"); ret = SX126xError::TIMEOUT; diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 536bb93c48f..aa275c9e972 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -48,7 +48,7 @@ enum SX126xBw : uint8_t { SX126X_BW_500000, }; -enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS }; +enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX126X_ERROR_NOT_READY }; class SX126xListener { public: diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 16c3d1dd21f..fb816094c29 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -1,4 +1,5 @@ #include "sx127x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -256,6 +257,11 @@ size_t SX127x::get_max_packet_size() { } SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { + // Callers such as a template number's on_value can run before setup() or after a failed setup + if (!this->is_ready()) { + ESP_LOGW(TAG, "Radio not ready, packet dropped"); + return SX127xError::SX127X_ERROR_NOT_READY; + } if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX127xError::INVALID_PARAMS; @@ -292,6 +298,7 @@ SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { // wait until transmit completes, typically the delay will be less than 100 ms uint32_t start = millis(); while (!this->dio0_pin_->digital_read()) { + App.feed_wdt(); if (millis() - start > 4000) { ESP_LOGE(TAG, "Transmit packet failure"); ret = SX127xError::TIMEOUT; diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 27031d98b2b..547c52e7bb3 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -36,7 +36,7 @@ enum SX127xBw : uint8_t { SX127X_BW_500_0, }; -enum class SX127xError { NONE = 0, TIMEOUT, INVALID_PARAMS }; +enum class SX127xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX127X_ERROR_NOT_READY }; class SX127xListener { public: From 25d4b88605566506212cacbf7ecb2f2d06c8a010 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:01:59 -1000 Subject: [PATCH 357/651] [nfc] Add unit tests for the URI record prefix codes (#20724) --- tests/components/nfc/test_ndef_record_uri.cpp | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/components/nfc/test_ndef_record_uri.cpp diff --git a/tests/components/nfc/test_ndef_record_uri.cpp b/tests/components/nfc/test_ndef_record_uri.cpp new file mode 100644 index 00000000000..5629c23c9f6 --- /dev/null +++ b/tests/components/nfc/test_ndef_record_uri.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "esphome/components/nfc/ndef_record_uri.h" + +namespace esphome::nfc::testing { + +static std::string decode(const std::vector &payload) { return NdefRecordUri(payload).get_payload(); } + +static uint8_t encoded_prefix(const std::string &uri) { return NdefRecordUri(uri).get_encoded_payload()[0]; } + +TEST(NdefRecordUri, DecodeExpandsPrefixCode) { + EXPECT_EQ(decode({0x01, 'a', '.', 'b'}), "http://www.a.b"); + EXPECT_EQ(decode({0x04, 'a', '.', 'b'}), "https://a.b"); + EXPECT_EQ(decode({0x07, 'a'}), "ftp://anonymous:anonymous@a"); +} + +// URI identifier codes from the NFC Forum URI Record Type Definition, kept separate from the +// component's table so a reordered entry fails here +static constexpr const char *SPEC_PREFIXES[] = {"", + "http://www.", + "https://www.", + "http://", + "https://", + "tel:", + "mailto:", + "ftp://anonymous:anonymous@", + "ftp://ftp.", + "ftps://", + "sftp://", + "smb://", + "nfs://", + "ftp://", + "dav://", + "news:", + "telnet://", + "imap:", + "rtsp://", + "urn:", + "pop:", + "sip:", + "sips:", + "tftp:", + "btspp://", + "btl2cap://", + "btgoep://", + "tcpobex://", + "irdaobex://", + "file://", + "urn:epc:id:", + "urn:epc:tag:", + "urn:epc:pat:", + "urn:epc:raw:", + "urn:epc:", + "urn:nfc:"}; + +TEST(NdefRecordUri, DecodeEveryPrefixCode) { + for (size_t code = 1; code < std::size(SPEC_PREFIXES); code++) + EXPECT_EQ(decode({static_cast(code), 'x'}), std::string(SPEC_PREFIXES[code]) + "x") << "code " << code; +} + +TEST(NdefRecordUri, DecodeAcceptsLastPrefixCode) { EXPECT_EQ(decode({0x23, 'x'}), "urn:nfc:x"); } + +TEST(NdefRecordUri, DecodeIgnoresUnknownPrefixCode) { + EXPECT_EQ(decode({0x00, 'x'}), "x"); + EXPECT_EQ(decode({0x24, 'x'}), "x"); +} + +// Encode takes the first prefix in table order that matches, so codes 0x1E to 0x23 are never written; +// these tests pin that current behaviour, not the spec's longest match +TEST(NdefRecordUri, EncodePicksFirstMatchingPrefix) { + EXPECT_EQ(encoded_prefix("https://www.a.b"), 0x02); + EXPECT_EQ(encoded_prefix("http://a.b"), 0x03); + EXPECT_EQ(encoded_prefix("urn:epc:id:x"), 0x13); +} + +TEST(NdefRecordUri, EncodeStopsBeforeLastPrefixCode) { + // urn:nfc: (0x23) is never chosen; the shorter urn: (0x13) matches first + EXPECT_EQ(encoded_prefix("urn:nfc:x"), 0x13); +} + +TEST(NdefRecordUri, EncodeStripsPrefix) { + const std::vector expected = {0x02, 'a', '.', 'b'}; + EXPECT_EQ(NdefRecordUri("https://www.a.b").get_encoded_payload(), expected); +} + +TEST(NdefRecordUri, EncodeWithoutPrefix) { + EXPECT_EQ(encoded_prefix("ht"), 0x00); + const std::vector expected = {0x00, 'x', 'y'}; + EXPECT_EQ(NdefRecordUri("xy").get_encoded_payload(), expected); +} + +} // namespace esphome::nfc::testing From 10f42e3e7c79ba77015a4e332cf2bfda1148347b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:03:34 -1000 Subject: [PATCH 358/651] [shelly_dimmer] Build the STM32 firmware update path in CI (#20677) --- tests/components/shelly_dimmer/common-update.yaml | 15 +++++++++++++++ .../shelly_dimmer/test-update.esp8266-ard.yaml | 3 +++ 2 files changed, 18 insertions(+) create mode 100644 tests/components/shelly_dimmer/common-update.yaml create mode 100644 tests/components/shelly_dimmer/test-update.esp8266-ard.yaml diff --git a/tests/components/shelly_dimmer/common-update.yaml b/tests/components/shelly_dimmer/common-update.yaml new file mode 100644 index 00000000000..babf55b2300 --- /dev/null +++ b/tests/components/shelly_dimmer/common-update.yaml @@ -0,0 +1,15 @@ +light: + - platform: shelly_dimmer + name: Shelly Dimmer Light + power: + name: Shelly Dimmer Power + voltage: + name: Shelly Dimmer Voltage + current: + name: Shelly Dimmer Current + max_brightness: 500 + firmware: + version: "51.6" + update: true + nrst_pin: 13 + boot0_pin: 14 diff --git a/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml b/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml new file mode 100644 index 00000000000..185686969df --- /dev/null +++ b/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + shelly_dimmer: !include common-update.yaml From 9c568525b0229b319d5832ab17bdb18f7bd1fc4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:08:41 -1000 Subject: [PATCH 359/651] [core] Add CodSpeed benchmarks for importing entity components (#20648) --- .../python/test_component_import_bench.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/benchmarks/python/test_component_import_bench.py diff --git a/tests/benchmarks/python/test_component_import_bench.py b/tests/benchmarks/python/test_component_import_bench.py new file mode 100644 index 00000000000..35981cb7ee4 --- /dev/null +++ b/tests/benchmarks/python/test_component_import_bench.py @@ -0,0 +1,105 @@ +"""CodSpeed benchmarks for importing entity components. + +Every ``esphome config`` or ``esphome compile`` imports the Python module of +each component the YAML uses. A component that imports its optional +integrations (mqtt, web_server, zigbee) at module scope drags their whole +import chains in for every user, so the cost of a fresh import is what these +benchmarks pin down. + +The core of esphome is imported once up front and stays loaded; each run +drops only the component modules from ``sys.modules`` and imports the +component again, so the import machinery and the module bodies of the +component and everything it pulls in run every time, as they do in a new +process, while a change to the core moves no number here. The modules and +registry entries the session had before a case are put back afterwards, so +later tests keep the objects they already hold. + +Skipped when ``pytest-codspeed`` isn't installed so the regular unit-test +suite keeps working unchanged. +""" + +from __future__ import annotations + +from collections.abc import Generator, Iterable +import importlib +import sys + +import pytest + +pytest.importorskip("pytest_codspeed") + +# Load the core before the first case so no case pays for it. +from esphome import automation, pins # noqa: E402 +import esphome.codegen # noqa: E402, F401 +import esphome.config_validation # noqa: E402, F401 + +COMPONENTS = ("binary_sensor", "sensor", "logger") +COMPONENT_PACKAGE = "esphome.components" +COMPONENT_PREFIX = COMPONENT_PACKAGE + "." +# Core registries that component module bodies write to when they are imported +REGISTRIES = ( + automation.ACTION_REGISTRY, + automation.CONDITION_REGISTRY, + pins.PIN_SCHEMA_REGISTRY, +) + + +def _component_modules() -> dict[str, object]: + # The package itself goes too: ``from esphome.components import x`` is + # answered from its attributes, so a stale package would hide the import. + return { + n: m + for n, m in sys.modules.items() + if n == COMPONENT_PACKAGE or n.startswith(COMPONENT_PREFIX) + } + + +def _forget_component_modules() -> None: + for name in _component_modules(): + del sys.modules[name] + + +def _import_component(name: str) -> object: + _forget_component_modules() + return importlib.import_module(COMPONENT_PREFIX + name) + + +_MISSING = object() + + +def _parent_bindings(names: Iterable[str]) -> list[tuple[object, str, object]]: + # An import binds the module to its parent package; a package may hold something else + # under that name (substitutions keeps a Jinja() instance as ``jinja``), so the value + # the parent has now is what goes back, not the module. + bindings = [] + for name in names: + parent, _, child = name.rpartition(".") + bindings.append( + (sys.modules[parent], child, getattr(sys.modules[parent], child, _MISSING)) + ) + return bindings + + +@pytest.fixture +def restore_component_modules() -> Generator[None]: + saved = _component_modules() + saved_bindings = _parent_bindings(saved) + saved_registries = [dict(registry) for registry in REGISTRIES] + yield + _forget_component_modules() + sys.modules.update(saved) + for parent, child, value in saved_bindings: + if value is _MISSING: + delattr(parent, child) + else: + setattr(parent, child, value) + # The re-imported bodies registered their actions, conditions and pin schemas again + for registry, entries in zip(REGISTRIES, saved_registries, strict=True): + registry.clear() + registry.update(entries) + + +@pytest.mark.usefixtures("restore_component_modules") +@pytest.mark.parametrize("component", COMPONENTS) +def test_import_component(benchmark, component: str) -> None: + benchmark(_import_component, component) From 7d7a78308d0259f07747e33e5a08de7b39b520fd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:05:02 -0400 Subject: [PATCH 360/651] [ci] Test tinyusb_keyboard in isolation (#20749) --- 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 f7680f105ca..fee1438636b 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -98,6 +98,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "tinyusb_keyboard": "Depends on tinyusb which conflicts with usb_host", "uart_mux": "Depends on tinyusb which conflicts with usb_host", "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } From 0132f722a359e41297297a003c149725897f50ed Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 13:24:36 -0400 Subject: [PATCH 361/651] [audio] Bump esp-audio-libs to v4.1.0 (#20754) --- 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 332638e75bb..36a9161eb45 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -338,7 +338,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1") + add_idf_component(name="esphome/esp-audio-libs", ref="4.1.0") data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 2ef48602d1b..87ec5cdcfc4 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 2.2.0 esphome/esp-audio-libs: - version: 4.0.1 + version: 4.1.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From 9fe75e262f629d6692553d017efe94afcbb19c34 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:09:53 -0400 Subject: [PATCH 362/651] [ci] Add a host clang-tidy environment and job (#20747) --- .github/workflows/ci.yml | 26 +++++++++--- esphome/components/host/core.cpp | 4 +- esphome/components/host/hal.cpp | 2 +- esphome/components/host/helpers.cpp | 4 +- esphome/components/host/preferences.cpp | 8 ++-- .../http_request/http_request_host.cpp | 4 +- esphome/components/ota/ota_backend_host.cpp | 34 +++++++-------- .../prometheus/prometheus_handler.cpp | 2 +- .../prometheus/prometheus_handler.h | 2 +- esphome/components/sdl/sdl_esphome.cpp | 6 +-- esphome/components/sdl/sdl_esphome.h | 4 +- esphome/components/snapshot/gif.cpp | 6 +-- esphome/components/snapshot/snapshot.cpp | 2 +- .../components/uart/uart_component_host.cpp | 7 ++-- esphome/components/uart/uart_component_host.h | 6 ++- .../web_server_base/web_server_base.cpp | 2 +- .../web_server_base/web_server_base.h | 2 +- esphome/core/defines.h | 8 +++- platformio.ini | 17 ++++++++ script/clang-tidy | 42 +++++++++++++++---- 20 files changed, 125 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d68bb93ec9f..04df45d7df7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,8 +81,8 @@ jobs: - name: Install apt packages (cached) uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 determine-jobs: name: Determine which jobs to run @@ -415,8 +415,8 @@ jobs: timeout-minutes: 10 uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -695,6 +695,10 @@ jobs: name: Run script/clang-tidy for RP2 options: --environment rp2-tidy --grep USE_RP2 pio_cache_key: tidyrp2 + - id: clang-tidy + name: Run script/clang-tidy for host + options: --environment host-tidy --grep USE_HOST + pio_cache_key: tidyhost - id: clang-tidy name: Run script/clang-tidy for LibreTiny environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy @@ -707,6 +711,16 @@ jobs: with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 + - name: Install apt packages (cached) + if: matrix.pio_cache_key == 'tidyhost' + # libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev + # by dsmr and dlms_meter. Packages and version must match seed-apt-cache + # exactly. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Restore Python id: restore-python @@ -1102,8 +1116,8 @@ jobs: timeout-minutes: 10 uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index f753534efd9..2cc455e3f45 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -59,8 +59,8 @@ namespace esphome::host { char **get_argv() { return s_argv; } const std::string &get_exe_path() { - static const std::string empty; - return s_exe_path != nullptr ? *s_exe_path : empty; + static const std::string EMPTY; + return s_exe_path != nullptr ? *s_exe_path : EMPTY; } void arm_reexec(const std::string &path) { diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index 9108c1ea9d3..caee316a53e 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -5,7 +5,7 @@ #include "esphome/core/log.h" #include "core.h" -#include +#include #include #include #include diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 9e203efcb9c..f8b7a0c5f08 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -39,8 +39,8 @@ bool Mutex::try_lock() { return static_cast(handle_)->try_lock(); void Mutex::unlock() { static_cast(handle_)->unlock(); } void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) - static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; - memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address)); + static constexpr uint8_t HOST_MAC_ADDRESS[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; + memcpy(mac, HOST_MAC_ADDRESS, sizeof(HOST_MAC_ADDRESS)); } } // namespace esphome diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index 9a8208e96ea..0f6b03ff0b5 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "preferences.h" #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -45,11 +46,10 @@ void HostPreferences::setup_() { break; if (fread(&len, sizeof(len), 1, fp) != 1) break; - uint8_t data[len]; - if (fread(data, sizeof(uint8_t), len, fp) != len) + std::vector data(len); + if (fread(data.data(), sizeof(uint8_t), len, fp) != len) break; - std::vector vec(data, data + len); - this->data_[key] = vec; + this->data_[key] = std::move(data); } fclose(fp); } diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 0130445acdc..1856b97e6e9 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -53,7 +53,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c for (const auto &[name, value] : request_headers) { h_headers.emplace(name, value); } - httplib::Client client(scheme_host.c_str()); + httplib::Client client(scheme_host); if (!client.is_valid()) { ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url); return nullptr; @@ -114,7 +114,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c } container->content_length = container->response_body_.size(); - for (auto header : response.headers) { + for (const auto &header : response.headers) { ESP_LOGD(TAG, "Header: %s: %s", header.first.c_str(), header.second.c_str()); auto lower_name = str_lower_case(header.first); // NOLINT if (should_collect_header(lower_case_collect_headers, lower_name)) { diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 923b0afe18a..765d6a04c08 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -32,7 +32,7 @@ ESPHOME_LOG_TAG(TAG, "ota"); constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; -ssize_t read_header_(const char *path, uint8_t *buf, size_t len) { +ssize_t read_header(const char *path, uint8_t *buf, size_t len) { int fd = ::open(path, O_RDONLY); if (fd < 0) return -1; @@ -50,7 +50,7 @@ struct ElfIdent { uint16_t e_type; }; -ElfIdent parse_elf_(const uint8_t *buf, size_t len) { +ElfIdent parse_elf(const uint8_t *buf, size_t len) { ElfIdent out{}; if (len < EI_NIDENT + 4) return out; @@ -76,17 +76,17 @@ ElfIdent parse_elf_(const uint8_t *buf, size_t len) { return out; } -bool validate_elf_(const char *staging_path, const std::string &exe_path) { +bool validate_elf(const char *staging_path, const std::string &exe_path) { uint8_t new_buf[HEADER_PEEK_SIZE]; uint8_t cur_buf[HEADER_PEEK_SIZE]; - ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); - ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + ssize_t new_n = read_header(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header(exe_path.c_str(), cur_buf, sizeof(cur_buf)); if (new_n < static_cast(EI_NIDENT + 4) || cur_n < static_cast(EI_NIDENT + 4)) { ESP_LOGE(TAG, "ELF header read failed"); return false; } - ElfIdent new_id = parse_elf_(new_buf, new_n); - ElfIdent cur_id = parse_elf_(cur_buf, cur_n); + ElfIdent new_id = parse_elf(new_buf, new_n); + ElfIdent cur_id = parse_elf(cur_buf, cur_n); if (!new_id.valid) { ESP_LOGE(TAG, "Uploaded payload is not a valid ELF"); return false; @@ -122,7 +122,7 @@ struct MachOIdent { uint32_t cpusubtype; }; -MachOIdent parse_macho_(const uint8_t *buf, size_t len) { +MachOIdent parse_macho(const uint8_t *buf, size_t len) { MachOIdent out{}; // mach_header is the common prefix of mach_header and mach_header_64; // cputype/cpusubtype/filetype have identical offsets in both. @@ -153,18 +153,18 @@ MachOIdent parse_macho_(const uint8_t *buf, size_t len) { return out; } -bool validate_macho_(const char *staging_path, const std::string &exe_path) { +bool validate_macho(const char *staging_path, const std::string &exe_path) { uint8_t new_buf[HEADER_PEEK_SIZE]; uint8_t cur_buf[HEADER_PEEK_SIZE]; - ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); - ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + ssize_t new_n = read_header(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header(exe_path.c_str(), cur_buf, sizeof(cur_buf)); if (new_n < static_cast(sizeof(struct mach_header)) || cur_n < static_cast(sizeof(struct mach_header))) { ESP_LOGE(TAG, "Mach-O header read failed"); return false; } - MachOIdent new_id = parse_macho_(new_buf, new_n); - MachOIdent cur_id = parse_macho_(cur_buf, cur_n); + MachOIdent new_id = parse_macho(new_buf, new_n); + MachOIdent cur_id = parse_macho(cur_buf, cur_n); if (!new_id.valid) { ESP_LOGE(TAG, "Uploaded payload is not a valid thin Mach-O executable"); return false; @@ -182,11 +182,11 @@ bool validate_macho_(const char *staging_path, const std::string &exe_path) { } #endif // __APPLE__ -bool validate_executable_(const char *staging_path, const std::string &exe_path) { +bool validate_executable(const char *staging_path, const std::string &exe_path) { #ifdef __linux__ - return validate_elf_(staging_path, exe_path); + return validate_elf(staging_path, exe_path); #elif defined(__APPLE__) - return validate_macho_(staging_path, exe_path); + return validate_macho(staging_path, exe_path); #else (void) staging_path; (void) exe_path; @@ -298,7 +298,7 @@ OTAResponseTypes HostOTABackend::end() { ::close(this->fd_); this->fd_ = -1; - if (!validate_executable_(this->staging_path_.c_str(), this->final_path_)) { + if (!validate_executable(this->staging_path_.c_str(), this->final_path_)) { ::unlink(this->staging_path_.c_str()); this->staging_path_.clear(); return OTA_RESPONSE_ERROR_UPDATE_END; diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 00c2e043102..b2bd7bd9bd7 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -1,5 +1,5 @@ #include "prometheus_handler.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_HOST) #include "esphome/core/application.h" namespace esphome::prometheus { diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 9dcbf7e6869..ae5bbcae74a 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) #include #include diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index a764b74581f..5dd25ec2bb8 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -157,7 +157,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); } else { auto stride = x_offset + w + x_pad; - auto data = ptr + (stride * y_offset + x_offset) * 2; + const auto *data = ptr + (stride * y_offset + x_offset) * 2; SDL_UpdateTexture(this->texture_, &rect, data, stride * 2); } this->redraw_(rect); @@ -199,7 +199,7 @@ void Sdl::process_key(uint32_t keycode, bool down) { callback->second(down); } -Sdl *Sdl::instance_for_window_(uint32_t window_id) { +Sdl *Sdl::instance_for_window(uint32_t window_id) { SDL_Window *window = SDL_GetWindowFromID(window_id); if (window == nullptr) return nullptr; @@ -293,7 +293,7 @@ void Sdl::loop() { continue; } - Sdl *target = instance_for_window_(window_id); + Sdl *target = instance_for_window(window_id); if (target == nullptr) { // Nothing to route this to: the window has gone, or it is not one of ours. Say so, otherwise // input that stops working leaves no trace at all. diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index c58de643cc8..c927004e1f6 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -42,7 +42,7 @@ class Sdl final : public display::Display, public snapshot::Snapshot { float get_setup_priority() const override { return setup_priority::HARDWARE; } void dump_config() override { LOG_DISPLAY("", "SDL", this); } template void add_key_listener(int32_t keycode, F &&callback) { - if (!this->key_callbacks_.count(keycode)) { + if (!this->key_callbacks_.contains(keycode)) { this->key_callbacks_[keycode] = CallbackManager(); } this->key_callbacks_[keycode].add(std::forward(callback)); @@ -66,7 +66,7 @@ class Sdl final : public display::Display, public snapshot::Snapshot { bool capture_bgr(uint8_t *dest, size_t row_stride) override; void handle_event_(const SDL_Event &event); /// The display owning the given window, or nullptr if it is not one of ours. - static Sdl *instance_for_window_(uint32_t window_id); + static Sdl *instance_for_window(uint32_t window_id); SDL_Renderer *renderer_{}; SDL_Window *window_{}; SDL_Texture *texture_{}; diff --git a/esphome/components/snapshot/gif.cpp b/esphome/components/snapshot/gif.cpp index 5650c1c404b..73a16b896e2 100644 --- a/esphome/components/snapshot/gif.cpp +++ b/esphome/components/snapshot/gif.cpp @@ -50,9 +50,9 @@ struct BitWriter { uint32_t buffer{0}; unsigned count{0}; - void put(uint32_t code, unsigned size) { - this->buffer |= code << this->count; - this->count += size; + void put(uint32_t value, unsigned bits) { + this->buffer |= value << this->count; + this->count += bits; while (this->count >= 8) { this->out.push_back(static_cast(this->buffer & 0xFF)); this->buffer >>= 8; diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 58406f17ffd..fb99154cc06 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -55,7 +55,7 @@ std::string sanitise_filename(const char *const name, const char *extension, boo break; } char c = *p; - if (!(std::isalnum(static_cast(c)) || c == '.' || c == '_' || c == '-')) { + if (!std::isalnum(static_cast(c)) && c != '.' && c != '_' && c != '-') { c = '_'; changed = true; } diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 7fb704536dd..833ccc425d3 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -9,11 +9,11 @@ #error This HostUartComponent implementation is not supported on this host OS #endif -#include -#include +#include +#include +#include #include #include -#include #include #include @@ -210,7 +210,6 @@ void HostUartComponent::write_array(const uint8_t *data, size_t len) { this->debug_callback_.call(UART_DIRECTION_TX, data[i]); } #endif - return; } bool HostUartComponent::peek_byte(uint8_t *data) { diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index bca62debf10..27869e969c4 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -2,6 +2,8 @@ #ifdef USE_HOST +#include + #include "esphome/core/component.h" #include "esphome/core/log.h" #include "uart_component.h" @@ -10,7 +12,7 @@ namespace esphome::uart { class HostUartComponent final : public UARTComponent, public Component { public: - virtual ~HostUartComponent(); + ~HostUartComponent(); void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } @@ -19,7 +21,7 @@ class HostUartComponent final : public UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override; UARTFlushResult flush() override; - void set_name(std::string port_name) { port_name_ = port_name; }; + void set_name(std::string port_name) { this->port_name_ = std::move(port_name); } protected: void update_error_(const std::string &error); diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 873c5b5a497..50144b6df73 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -1,5 +1,5 @@ #include "web_server_base.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) namespace esphome::web_server_base { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 72d3bf75b1c..38098971f4f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) #include #include "esphome/core/progmem.h" diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 234160fc290..f589155c97f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -331,7 +331,7 @@ #define USE_NOISE #define USE_NOISE_SPARE_EPHEMERAL #define USE_SHA256 -#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 +#if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_WIREGUARD @@ -420,7 +420,9 @@ #define USE_ESPNOW #define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 +#ifndef USE_HOST // web_server, captive_portal: no ESPAsyncWebServer on host #define USE_CAPTIVE_PORTAL +#endif #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP32_BLE #define USE_ESP32_BLE_MAX_CONNECTIONS 3 @@ -478,6 +480,7 @@ #define USE_SPI #define USE_SPI_PSRAM_DMA #define USE_VOICE_ASSISTANT +#ifndef USE_HOST #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST @@ -489,6 +492,7 @@ #define USE_WEBSERVER_ALLOWED_ORIGINS #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP +#endif #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH @@ -691,6 +695,8 @@ #endif #ifdef USE_HOST +#define USE_ESPHOME_HOST_MAC_ADDRESS \ + { 0x98, 0x35, 0x69, 0xab, 0xf6, 0x79 } #define USE_HTTP_REQUEST_RESPONSE // Host only: the uart arm would shadow the native logger UART arms in other envs #define USE_IMPROV_SERIAL_UART diff --git a/platformio.ini b/platformio.ini index 96f3d25b6eb..9e05356665a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -654,6 +654,23 @@ build_flags = build_unflags = ${common.build_unflags} +[env:host-tidy] +extends = env:host +; every library a header under esphome/ may include: a missing one is a +; fatal error that breaks the all-include analysis for every later header +lib_deps = + ${common.lib_deps} + ${common:idf-component-libs.lib_deps} + ${env:host.lib_deps} +build_flags = + ${env:host.build_flags} + ${flags:clangtidy.build_flags} + ; analyze with the same language model as the embedded targets + -fno-exceptions + -fno-rtti + -I/usr/include/SDL2 ; sdl + -D__LINUX__ ; runtime_image: JPEGDEC's host detection, see its __init__.py + ;;;;;;;; nRF52 ;;;;;;;; [env:nrf52] diff --git a/script/clang-tidy b/script/clang-tidy index a2becb59cc8..3f923403a38 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -68,6 +68,9 @@ def clang_options(idedata, environment): # extract target architecture from triplet in g++ filename triplet = Path(idedata["cxx_path"]).name[:-4] + # env:host compiles with the system g++, so there is no triplet and the + # host's own target and headers are the right ones. + native = environment.startswith("host") if triplet.startswith("xtensa-"): # clang has an Xtensa frontend, but only a generic core -- the esp32 IDF # toolchain headers (xtruntime, xtensa/config) need the GCC core config @@ -83,6 +86,8 @@ def clang_options(idedata, environment): cmd.append("-D__XTENSA__") cmd.append("-D__XTENSA_EL__") cmd.append("-D_LIBC") + elif native: + cmd.append("-Qunused-arguments") else: # RISC-V (and other non-Xtensa targets) have a real clang backend, so # compile for the actual triplet. Espressif's RISC-V GCC -march adds @@ -118,7 +123,7 @@ def clang_options(idedata, environment): "-mfp16-format=ieee", "--param=min-pagesize=0", ) - else: + elif not native: cmd.extend( [ # disable built-in include directories from the host @@ -127,10 +132,11 @@ def clang_options(idedata, environment): ) # set flags + if not native: + # disable built-in include directories from the host + cmd.append("-nostdinc") cmd.extend( [ - # disable built-in include directories from the host - "-nostdinc", # allow to condition code on the presence of clang-tidy "-DCLANG_TIDY", # (esp-idf) Fix __once_callable in some libstdc++ headers @@ -314,11 +320,17 @@ def run_tidy( if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): # MMIO peripheral access on these bare-metal platforms is all # fixed-address. + disabled_checks.append("-clang-analyzer-core.FixedAddressDereference") + if args.environment.startswith("host"): + # glibc's assert() expands to a call the check mistakes for a + # constant condition. + disabled_checks += ["-misc-static-assert", "-cert-dcl03-c"] + if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx", "host")): # bugprone-pointer-arithmetic-on-polymorphic-object (and its # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher - # recursion on lvgl_esphome.h under the RP2 defines. + # recursion on lvgl_esphome.h under the RP2 defines and on the + # all-include file under the host defines. disabled_checks += [ - "-clang-analyzer-core.FixedAddressDereference", "-bugprone-pointer-arithmetic-on-polymorphic-object", "-cert-ctr56-cpp", ] @@ -327,6 +339,8 @@ def run_tidy( if disabled_checks: invocation.append("--checks=" + ",".join(disabled_checks)) invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") + # Vendored third-party headers are kept as upstream wrote them. + invocation.append("--exclude-header-filter=.*/http_request/httplib\\.h") invocation.append(str(Path(path).resolve())) invocation.append("--") invocation.extend(options) @@ -448,6 +462,11 @@ def main(): if args.grep: files = filter_grep(files, args.grep) + # Not every tidy environment carries googletest (LibreTiny does not), + # so a platform grep that matched a unit test would fail on + # . The tests are analyzed by the full ESP32 IDF run, + # which has no --grep. + files = [f for f in files if not f.startswith("tests/")] if args.exclude_grep: excluded = set(filter_grep(files, args.exclude_grep)) @@ -464,11 +483,16 @@ def main(): # Add header file for checking (before early exit check) if args.all_headers and args.split_at in (None, 1): + all_headers = [ + os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) + ] + # The path filter applies to headers too: a component left out because + # it does not build in this environment must not come back via the + # all-include file. + if args.files: + all_headers = [p for p in all_headers if file_name_re.search(p)] # When --changed is used, only include changed headers instead of all headers if args.changed: - all_headers = [ - os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) - ] changed_headers = filter_changed(all_headers) if changed_headers: build_all_include(changed_headers) @@ -476,7 +500,7 @@ def main(): else: print("No changed headers to check") else: - build_all_include() + build_all_include(all_headers) files.insert(0, temp_header_file) print(f"Added all-include header file, new total: {len(files)}") From 91e0edc11d1e750136080aadf39add0599046809 Mon Sep 17 00:00:00 2001 From: Adam <37312852+Aptimex@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:15:48 -0600 Subject: [PATCH 363/651] [pn532] Fix NDEF parsing greater than 255 bytes (#18188) 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 Co-authored-by: J. Nick Koston --- esphome/components/pn532/pn532.h | 11 +- .../pn532/pn532_mifare_ultralight.cpp | 88 ++++-- tests/components/pn532/pn532_test.cpp | 12 +- .../pn532/test_mifare_ultralight_ndef.cpp | 273 ++++++++++++++++++ 4 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 tests/components/pn532/test_mifare_ultralight_ndef.cpp diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index 5502eac6ec8..3a9099f3210 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -50,13 +50,15 @@ static constexpr size_t PN532_FRAME_MAX_DATA_SIZE = 254; static constexpr size_t PN532_FRAME_MAX_SIZE = 1 + 6 + PN532_FRAME_MAX_DATA_SIZE + 2; /// Holds one frame in either direction so bus traffic never allocates using PN532Frame = StaticVector; -/// Holds pages 3 to 6 (16 bytes) plus an NDEF message of up to 255 bytes and its TLV header, rounded up to whole reads -using UltralightReadBuffer = StaticVector; +/// Longest NDEF message accepted from a MIFARE Ultralight/NTAG tag (the user memory of an NTAG216) +static constexpr uint32_t MIFARE_ULTRALIGHT_MAX_NDEF_SIZE = 888; /// Longest NDEF message accepted from a MIFARE Classic tag (the capacity of a 4K tag) static constexpr uint32_t MIFARE_CLASSIC_MAX_NDEF_SIZE = 3440; /// A MIFARE READ answers with 16 bytes: one Classic block or four Ultralight pages static constexpr size_t MIFARE_READ_SIZE = 16; using MifareReadData = std::array; +/// Pages 3 to 6, read in one go: the capability container and the start of the NDEF TLV +using UltralightHeader = std::array; class PN532BinarySensor; @@ -134,10 +136,11 @@ class PN532 : public PollingComponent { bool write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); std::unique_ptr read_mifare_ultralight_tag_(nfc::NfcTagUid &uid); - bool read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data); + /// Fills `data` with the bytes from `start_page` onward + bool read_mifare_ultralight_bytes_(uint8_t start_page, std::span data); bool is_mifare_ultralight_formatted_(std::span page_3_to_6); uint16_t read_mifare_ultralight_capacity_(); - bool find_mifare_ultralight_ndef_(std::span page_3_to_6, uint8_t &message_length, + bool find_mifare_ultralight_ndef_(std::span page_3_to_6, uint32_t &message_length, uint8_t &message_start_index); bool write_mifare_ultralight_page_(uint8_t page_num, std::span write_data); bool write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); diff --git a/esphome/components/pn532/pn532_mifare_ultralight.cpp b/esphome/components/pn532/pn532_mifare_ultralight.cpp index afb60bd843a..e5d764d732f 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include "pn532.h" @@ -10,10 +11,9 @@ namespace esphome::pn532 { ESPHOME_LOG_TAG(TAG, "pn532.mifare_ultralight"); std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid &uid) { - UltralightReadBuffer data; + UltralightHeader data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all - if (!this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, - data)) { + if (!this->read_mifare_ultralight_bytes_(3, data)) { return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } @@ -22,49 +22,68 @@ std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid & return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } - uint8_t message_length; + uint32_t message_length; uint8_t message_start_index; if (!this->find_mifare_ultralight_ndef_(data, message_length, message_start_index)) { ESP_LOGW(TAG, "Couldn't find NDEF message"); return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } - ESP_LOGVV(TAG, "NDEF message length: %u, start: %u", message_length, message_start_index); + ESP_LOGVV(TAG, "NDEF message length: %" PRIu32 ", start: %u", message_length, message_start_index); if (message_length == 0) { return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } - // we already read pages 3-6 earlier -- pick up where we left off so we're not re-reading pages - const uint8_t read_length = message_length + message_start_index > 12 ? message_length + message_start_index - 12 : 0; + + // data[2] is the CC byte read earlier as part of pages 3-6, same value used by read_mifare_ultralight_capacity_() + const uint32_t capacity = data[2] * 8U; + const uint32_t total_length = message_length + message_start_index; + if (total_length > capacity) { + ESP_LOGW(TAG, "NDEF message of %" PRIu32 " bytes at offset %u exceeds tag capacity %" PRIu32, message_length, + message_start_index, capacity); + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + if (message_length > MIFARE_ULTRALIGHT_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + + // we already read pages 3-6 earlier, pick up where we left off so we're not re-reading pages + constexpr size_t header_message_bytes = MIFARE_READ_SIZE - nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // pages 4 to 6 + const uint16_t read_length = + total_length > header_message_bytes ? static_cast(total_length - header_message_bytes) : 0; + // A message up to the old 255 byte limit stays on the stack, a longer one borrows the heap for the read + SmallBufferWithHeapFallback buffer(data.size() + read_length); + std::span message(buffer.get(), data.size() + read_length); + std::copy(data.begin(), data.end(), message.begin()); if (read_length) { - if (!read_mifare_ultralight_bytes_(nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE + 3, read_length, data)) { + if (!this->read_mifare_ultralight_bytes_(nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE + 3, + message.subspan(data.size()))) { ESP_LOGE(TAG, "Error reading tag data"); return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } } // skip page 3 as well as any bytes ahead of message_start_index const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - if (skip >= data.size()) { + if (skip >= message.size()) { return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } return make_unique(uid, nfc::NFC_FORUM_TYPE_2, - make_unique(std::span(data).subspan(skip))); + make_unique(std::span(message).subspan(skip))); } -bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { +bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, std::span data) { MifareReadData chunk; - for (uint8_t i = 0; i * MIFARE_READ_SIZE < num_bytes; i++) { + for (uint8_t i = 0; i * MIFARE_READ_SIZE < data.size(); i++) { // a READ returns 4 pages (16 bytes) if (!this->mifare_read_(uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), chunk)) { return false; } // keep only the bytes still wanted from this read - const uint16_t remaining = num_bytes - i * MIFARE_READ_SIZE; - const size_t count = std::min(MIFARE_READ_SIZE, remaining); - for (const uint8_t byte : std::span(chunk).subspan(0, count)) { - data.push_back(byte); - } + const size_t offset = i * MIFARE_READ_SIZE; + const size_t count = std::min(MIFARE_READ_SIZE, data.size() - offset); + std::copy_n(chunk.begin(), count, data.begin() + offset); } char data_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -82,15 +101,15 @@ bool PN532::is_mifare_ultralight_formatted_(const std::span page_ } uint16_t PN532::read_mifare_ultralight_capacity_() { - UltralightReadBuffer data; - if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) && data.size() > 2) { + std::array data; + if (this->read_mifare_ultralight_bytes_(3, data)) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; } return 0; } -bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, +bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint32_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -98,16 +117,31 @@ bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_t return false; } + uint8_t tlv_offset; if (page_3_to_6[p4_offset + 0] == 0x03) { - message_length = page_3_to_6[p4_offset + 1]; - message_start_index = 2; - return true; + tlv_offset = 0; } else if (page_3_to_6[p4_offset + 5] == 0x03) { - message_length = page_3_to_6[p4_offset + 6]; - message_start_index = 7; - return true; + tlv_offset = 5; + } else { + return false; } - return false; + + // Long-form TLV length: 0x03 0xFF LEN_HI LEN_LO, used whenever the message is >= 255 bytes. + // Short-form is just 0x03 LEN. See NFC Forum Type 2 Tag Operation spec section 2.3. + if (page_3_to_6[p4_offset + tlv_offset + 1] == 0xFF) { + if (page_3_to_6.size() <= p4_offset + tlv_offset + 3) { + // long-form marker present but the length bytes weren't read -- fail explicitly instead of + // misreporting a 255-byte short-form message + return false; + } + message_length = + (static_cast(page_3_to_6[p4_offset + tlv_offset + 2]) << 8) | page_3_to_6[p4_offset + tlv_offset + 3]; + message_start_index = tlv_offset + 4; + } else { + message_length = page_3_to_6[p4_offset + tlv_offset + 1]; + message_start_index = tlv_offset + 2; + } + return true; } bool PN532::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { diff --git a/tests/components/pn532/pn532_test.cpp b/tests/components/pn532/pn532_test.cpp index 75f9cf6d088..25125877f25 100644 --- a/tests/components/pn532/pn532_test.cpp +++ b/tests/components/pn532/pn532_test.cpp @@ -163,9 +163,8 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { pn532.responses.push_back(first); pn532.responses.push_back(second); - UltralightReadBuffer data; - ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, 20, data)); - ASSERT_EQ(data.size(), 20u); + std::array data{}; + ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, data)); EXPECT_EQ(data[15], 15); EXPECT_EQ(data[16], 0x10); EXPECT_EQ(data[19], 0x13); @@ -177,15 +176,14 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { TEST(PN532Mifare, UltralightReadRejectsBadResponses) { FakePN532 pn532; - UltralightReadBuffer data; + std::array data{}; pn532.responses.push_back({0x00, 0x01, 0x02}); // short response - EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, data)); std::vector failed(17, 0x00); failed[0] = 0x01; // timeout status pn532.responses.push_back(failed); - data.clear(); - EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, data)); } } // namespace esphome::pn532 diff --git a/tests/components/pn532/test_mifare_ultralight_ndef.cpp b/tests/components/pn532/test_mifare_ultralight_ndef.cpp new file mode 100644 index 00000000000..8d1547fc0e4 --- /dev/null +++ b/tests/components/pn532/test_mifare_ultralight_ndef.cpp @@ -0,0 +1,273 @@ +#include + +#include +#include +#include + +#include "esphome/components/nfc/ndef_message.h" +#include "esphome/components/nfc/ndef_record.h" +#include "esphome/components/nfc/nfc.h" +#include "esphome/components/pn532/pn532.h" + +namespace esphome::pn532::testing { + +/// Exposes the protected Ultralight helpers and stands in for the bus. Each MIFARE READ is answered with the +/// 16 bytes of `tag_memory` starting at the requested page, so the full read path can run against synthetic tags. +class TestablePN532 : public PN532 { + public: + using PN532::find_mifare_ultralight_ndef_; + using PN532::read_mifare_ultralight_tag_; + + /// Tag memory starting at page 0 (4 bytes per page) + std::vector tag_memory; + + protected: + bool is_read_ready() override { return true; } + bool write_data(std::span data) override { + this->last_written_.assign(data.begin(), data.end()); + return true; + } + // only used for ACK frames; index 0 is the I2C status byte + bool read_data(PN532Frame &data, size_t len) override { + data = {0x01, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; + return true; + } + bool read_response(uint8_t command, PN532Frame &data) override { + // frame is preamble, start code (2), LEN, LCS, TFI, InDataExchange, Tg, READ, page, DCS, postamble + if (this->last_written_.size() < 3) + return false; + const size_t start = this->last_written_[this->last_written_.size() - 3] * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + data.clear(); + data.push_back(0x00); // status: success + for (size_t i = start; i < start + MIFARE_READ_SIZE; i++) { + data.push_back(i < this->tag_memory.size() ? this->tag_memory[i] : 0x00); + } + return true; + } + + std::vector last_written_; +}; + +/// Builds Ultralight/NTAG tag memory holding `tlv` at page 4, with a capability container that reports +/// `capacity_bytes` of user memory. +std::vector make_tag_memory(const std::vector &tlv, uint16_t capacity_bytes) { + // pages 0-2: UID, lock bytes; page 3: CC (magic, version, size / 8, access) + std::vector memory(12, 0x00); + memory.insert(memory.end(), {0xE1, 0x10, static_cast(capacity_bytes / 8), 0x00}); + memory.insert(memory.end(), tlv.begin(), tlv.end()); + memory.resize(16 + capacity_bytes, 0x00); + return memory; +} + +/// Wraps an encoded NDEF message in a Type 2 Tag TLV, using the long form when it is 255 bytes or more. +std::vector wrap_tlv(const std::vector &encoded) { + std::vector tlv = {0x03}; + if (encoded.size() < 255) { + tlv.push_back(static_cast(encoded.size())); + } else { + tlv.insert(tlv.end(), {0xFF, static_cast(encoded.size() >> 8), static_cast(encoded.size())}); + } + tlv.insert(tlv.end(), encoded.begin(), encoded.end()); + tlv.push_back(0xFE); + return tlv; +} + +// Builds a 16-byte page_3_to_6 buffer: 4 filler bytes for page 3 (unused by this function), +// followed by the given bytes starting at page 4, zero-padded out to the real 4-page read size. +std::vector make_page_3_to_6(std::initializer_list page4_onward) { + std::vector data = {0x00, 0x00, 0x00, 0x00}; + data.insert(data.end(), page4_onward); + data.resize(16, 0x00); + return data; +} + +TEST(FindMifareUltralightNdef, ShortFormAtPage4Start) { + TestablePN532 pn532; + // 0x03 LEN -- message starts right after. + auto data = make_page_3_to_6({0x03, 0x20}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 0x20u); + EXPECT_EQ(message_start_index, 2); +} + +TEST(FindMifareUltralightNdef, ShortFormAfterLeadingPadding) { + TestablePN532 pn532; + // A non-0x03 byte at page 4 (e.g. a lock/CC TLV) pushes the message TLV to offset 5. + auto data = make_page_3_to_6({0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0x10}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 0x10u); + EXPECT_EQ(message_start_index, 7); +} + +// Regression test: prior to this fix, a >=255 byte NDEF message's TLV length was always read as +// a single byte, so the 0xFF long-form marker was mistaken for a length of 255 and the two real +// length bytes were mistaken for message content. That misalignment is what produced the +// "Corrupt record encountered; NdefMessage constructor aborting" error for large tags +TEST(FindMifareUltralightNdef, LongFormAtPage4Start) { + TestablePN532 pn532; + // 0x03 FF 01 2C -> long-form length = 0x012C = 300 + auto data = make_page_3_to_6({0x03, 0xFF, 0x01, 0x2C, 0xD1, 0x01, 0x0C, 0x55}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 300u); + EXPECT_EQ(message_start_index, 4); +} + +TEST(FindMifareUltralightNdef, LongFormAfterLeadingPadding) { + TestablePN532 pn532; + auto data = make_page_3_to_6({0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0x02, 0x00}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 0x0200u); // 512 + EXPECT_EQ(message_start_index, 9); +} + +TEST(FindMifareUltralightNdef, TooShortBufferIsRejected) { + TestablePN532 pn532; + std::vector data = {0x00, 0x00, 0x00, 0x00, 0x03, 0x05}; // well under the size floor + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + EXPECT_FALSE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); +} + +TEST(FindMifareUltralightNdef, NoTlvTagIsRejected) { + TestablePN532 pn532; + auto data = make_page_3_to_6({0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + EXPECT_FALSE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); +} + +// Regression test: the entry size guard (page_3_to_6.size() > p4_offset + 6) is loose enough to admit +// buffers that are too short to hold the two long-form length bytes once the TLV is offset by leading +// padding (tlv_offset == 5). Before this fix, that case silently fell through to the short-form branch +// and reported a bogus 255-byte message instead of failing. +TEST(FindMifareUltralightNdef, LongFormMarkerWithMissingLengthBytesIsRejected) { + TestablePN532 pn532; + // Padding pushes the TLV to offset 5; 0x03 FF present, but the two length bytes are missing (size 11). + std::vector data = {0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF}; + ASSERT_EQ(data.size(), 11u); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + EXPECT_FALSE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); +} + +/// End-to-end regression guard: builds a real 3-record NDEF message (URL, text, and a MIME +/// record) padded past 255 bytes and wraps it in a Type 2 Tag TLV the way a real tag stores it, +/// then runs the wrapper through find_mifare_ultralight_ndef_ to locate it, and feeds the located slice back +/// into NdefMessage to confirm all three records survive the round trip. +TEST(FindMifareUltralightNdef, LongFormMessageRoundTripsThroughNdefMessage) { + nfc::NdefMessage message; + message.add_uri_record("https://esphome.io/"); + message.add_text_record("Hello, ESPHome!"); + + std::string json_payload = R"({"key":"value","pad":")" + std::string(255, 'x') + R"("})"; + auto json_record = make_unique(); + json_record->set_tnf(nfc::TNF_MIME_MEDIA); + json_record->set_type("application/json"); + json_record->set_payload(json_payload); + message.add_record(std::move(json_record)); + + auto encoded = message.encode(); + ASSERT_GE(encoded.size(), 255u) << "test fixture must exercise the long-form TLV path"; + + // Wrap exactly like write_mifare_ultralight_tag_ does: 0x03 FF LEN_HI LEN_LO ... 0xFE + std::vector wrapped = {0x03, 0xFF, static_cast((encoded.size() >> 8) & 0xFF), + static_cast(encoded.size() & 0xFF)}; + wrapped.insert(wrapped.end(), encoded.begin(), encoded.end()); + wrapped.push_back(0xFE); + + // Real tag memory is [page 3 (4 bytes, unused here)] [wrapped TLV ...]. + std::vector tag_memory = {0x00, 0x00, 0x00, 0x00}; + tag_memory.insert(tag_memory.end(), wrapped.begin(), wrapped.end()); + + // find_mifare_ultralight_ndef_ only ever sees the first 16 bytes (pages 3-6) when locating the + // TLV header -- exactly what read_mifare_ultralight_tag_'s first, fixed-size read provides. + std::vector page_3_to_6(tag_memory.begin(), tag_memory.begin() + 16); + + TestablePN532 pn532; + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(page_3_to_6, message_length, message_start_index)); + EXPECT_EQ(message_length, encoded.size()); + EXPECT_EQ(message_start_index, 4); + + // Reconstruct what read_mifare_ultralight_tag_ hands to NdefMessage: page 3 trimmed off, then + // sliced to [message_start_index, message_start_index + message_length). + std::vector full_tag_data(tag_memory.begin() + 4, tag_memory.end()); + std::vector message_bytes(full_tag_data.begin() + message_start_index, + full_tag_data.begin() + message_start_index + message_length); + + nfc::NdefMessage parsed(message_bytes); + const auto &records = parsed.get_records(); + ASSERT_EQ(records.size(), 3u); + EXPECT_EQ(records[0]->get_type(), "U"); + EXPECT_EQ(records[0]->get_payload(), "https://esphome.io/"); + EXPECT_EQ(records[1]->get_type(), "T"); + EXPECT_EQ(records[1]->get_payload(), "Hello, ESPHome!"); + EXPECT_EQ(records[2]->get_type(), "application/json"); + EXPECT_EQ(records[2]->get_payload(), json_payload); +} + +/// End-to-end: a tag holding a message well past 255 bytes is read in full. The read buffer must grow to fit the +/// whole message; a fixed 255-byte buffer would cut it short and the records would fail to parse. +TEST(ReadMifareUltralightTag, LongFormMessageIsReadInFull) { + nfc::NdefMessage message; + message.add_uri_record("https://esphome.io/"); + const std::string text(600, 'x'); + message.add_text_record(text); + auto encoded = message.encode(); + ASSERT_GT(encoded.size(), 600u); + + TestablePN532 pn532; + pn532.tag_memory = make_tag_memory(wrap_tlv(encoded), 888); // NTAG216 + nfc::NfcTagUid uid = {0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + auto tag = pn532.read_mifare_ultralight_tag_(uid); + ASSERT_TRUE(tag->has_ndef_message()); + const auto &records = tag->get_ndef_message()->get_records(); + ASSERT_EQ(records.size(), 2u); + EXPECT_EQ(records[0]->get_payload(), "https://esphome.io/"); + EXPECT_EQ(records[1]->get_payload(), text); +} + +TEST(ReadMifareUltralightTag, ShortFormMessageIsRead) { + nfc::NdefMessage message; + message.add_uri_record("https://esphome.io/"); + auto encoded = message.encode(); + + TestablePN532 pn532; + pn532.tag_memory = make_tag_memory(wrap_tlv(encoded), 144); // NTAG213 + nfc::NfcTagUid uid = {0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + auto tag = pn532.read_mifare_ultralight_tag_(uid); + ASSERT_TRUE(tag->has_ndef_message()); + const auto &records = tag->get_ndef_message()->get_records(); + ASSERT_EQ(records.size(), 1u); + EXPECT_EQ(records[0]->get_payload(), "https://esphome.io/"); +} + +/// A length larger than the tag's capacity is rejected before any further pages are read. +TEST(ReadMifareUltralightTag, LengthBeyondCapacityIsRejected) { + TestablePN532 pn532; + pn532.tag_memory = make_tag_memory({0x03, 0xFF, 0x01, 0x2C}, 144); // claims 300 bytes on a 144-byte tag + nfc::NfcTagUid uid = {0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + auto tag = pn532.read_mifare_ultralight_tag_(uid); + EXPECT_FALSE(tag->has_ndef_message()); +} + +} // namespace esphome::pn532::testing From 67307f0eeb1c1447cf547b0ad1adf3d756bbb6a7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:31:06 -0400 Subject: [PATCH 364/651] [ci] Select the feature-gated files the platform clang-tidy jobs were missing (#20743) --- .github/workflows/ci.yml | 10 +++++----- .../esp32_hosted/esp_now_hosted.cpp | 19 +++++++++++++------ esphome/components/ln882h_ble/ln882h_ble.cpp | 3 ++- .../components/shelly_dimmer/stm32flash.cpp | 4 ++-- .../socket/lwip_raw_common_impl.cpp | 7 +++---- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04df45d7df7..33534f1f63a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -680,7 +680,7 @@ jobs: include: - id: clang-tidy name: Run script/clang-tidy for ESP8266 - options: --environment esp8266-arduino-tidy --grep USE_ESP8266 + options: --environment esp8266-arduino-tidy --grep USE_ESP8266 --grep USE_SOCKET_IMPL_LWIP_TCP --grep USE_SHD_FIRMWARE_DATA pio_cache_key: tidyesp8266 - id: clang-tidy name: Run script/clang-tidy for ESP32 Arduino @@ -693,7 +693,7 @@ jobs: ignore_errors: false - id: clang-tidy name: Run script/clang-tidy for RP2 - options: --environment rp2-tidy --grep USE_RP2 + options: --environment rp2-tidy --grep USE_RP2 --grep USE_BLE_SCAN_RESPONSE_MERGER pio_cache_key: tidyrp2 - id: clang-tidy name: Run script/clang-tidy for host @@ -702,7 +702,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for LibreTiny environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy - options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X + options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X --grep USE_LN882H_BLE --grep USE_SOCKET_IMPL_LWIP_SOCKETS pio_cache_key: tidylibretiny steps: @@ -1003,14 +1003,14 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 # yamllint disable-line rule:line-length - options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM tidy_environment: esp32s3-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, # so their code paths differ -- lint them under the P4 build too. # yamllint disable-line rule:line-length - options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE + options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep CONFIG_IDF_TARGET_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE tidy_environment: esp32p4-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 C6 diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp index 03b03fd983f..d226a83c1eb 100644 --- a/esphome/components/esp32_hosted/esp_now_hosted.cpp +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -42,6 +42,7 @@ extern "C" { } #include "esp_now_hosted_rpc.h" +#include "esphome/core/log.h" namespace { @@ -52,6 +53,8 @@ ESPHOME_LOG_TAG(TAG, "esp_now_hosted"); // single esp-hosted RPC RX thread. Serializing requests keeps the shared // response slot race-free; a sequence number stops a late/stale response from // being mistaken for ours. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables): the shim is +// C-ABI state shared between the main loop and the esp-hosted RX thread. SemaphoreHandle_t g_req_mutex = nullptr; SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands bool g_setup_done = false; // set only after setup fully succeeds @@ -85,6 +88,7 @@ constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20; uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6]; size_t g_peer_count = 0; portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) // Caller must hold g_peer_lock. int peer_cache_find_locked(const uint8_t *mac) { @@ -231,12 +235,14 @@ esp_err_t ensure_setup() { g_resp_sem = xSemaphoreCreateBinary(); if (g_req_mutex == nullptr || g_resp_sem == nullptr) return ESP_ERR_NO_MEM; - esp_err_t err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK) + esp_err_t err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr); + if (err != ESP_OK) return err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK) + err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr); + if (err != ESP_OK) return err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK) + err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr); + if (err != ESP_OK) return err; g_setup_done = true; return ESP_OK; @@ -422,10 +428,11 @@ esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len uint8_t hdr[sizeof(esp_now_hosted_send_req_t)]; auto *s = reinterpret_cast(hdr); s->has_addr = peer_addr != nullptr ? 1 : 0; - if (peer_addr != nullptr) + if (peer_addr != nullptr) { memcpy(s->peer_addr, peer_addr, 6); - else + } else { memset(s->peer_addr, 0, 6); + } s->data_len = static_cast(len); // Fire-and-forget (wait=false): native esp_now_send returns once the frame is // queued, with the real TX result delivered later through the send callback. diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 8f569086de2..b903b2752ed 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -246,7 +246,7 @@ static void ble_scan_callback(void *param) { memcpy(slot->data, info->data, slot->data_len); s_ble->push_scan_report(slot); -} +} // NOLINT(clang-analyzer-unix.Malloc) the slot is a pool entry handed to push_scan_report(), not a leak BLEScanReport *LN882HBLE::allocate_scan_report() { BLEScanReport *slot = this->report_pool_.allocate(); @@ -287,6 +287,7 @@ void LN882HBLE::enable() { return; this->state_ = BLEComponentState::ENABLING; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - MMIO register at a fixed address *reinterpret_cast(BLE_COEX_PTI_REG_ADDR) = BLE_COEX_PTI_ENABLE_ALL; soc_module_clk_gate_enable(CLK_G_BLE); diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index c33876e4943..5996929ab84 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -117,7 +117,7 @@ namespace esphome::shelly_dimmer { namespace { int flash_addr_to_page_ceil(const stm32_unique_ptr &stm, uint32_t addr) { - if (!(addr >= stm->dev->fl_start && addr <= stm->dev->fl_end)) + if (addr < stm->dev->fl_start || addr > stm->dev->fl_end) return 0; int page = 0; @@ -729,7 +729,7 @@ stm32_err_t stm32_write_memory(const stm32_unique_ptr &stm, uint32_t address, co uint8_t buf[256 + 2]; buf[0] = aligned_len - 1; - for (auto i = 0; i < len; i++) { + for (unsigned int i = 0; i < len; i++) { cs ^= data[i]; buf[i + 1] = data[i]; } diff --git a/esphome/components/socket/lwip_raw_common_impl.cpp b/esphome/components/socket/lwip_raw_common_impl.cpp index 4ec0a373550..52e9c2eec5e 100644 --- a/esphome/components/socket/lwip_raw_common_impl.cpp +++ b/esphome/components/socket/lwip_raw_common_impl.cpp @@ -81,14 +81,13 @@ bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t 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; + const bool ok = sockaddr_to_lwip(addr, addrlen, ip, port); #if LWIP_IPV6 // Only promote wildcard binds — a specific address must keep filtering - if (family == AF_INET6 && ip_addr_isany_val(*ip)) + if (ok && family == AF_INET6 && ip_addr_isany_val(*ip)) IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); #endif - return true; + return ok; } int lwip_err_to_errno(err_t err) { From 91633594454b2d76be0cee83fc5187b016096da7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:31:39 -0400 Subject: [PATCH 365/651] [core] Analyze the ethernet component under ESP-IDF (#20745) --- esphome/core/defines.h | 4 ++-- esphome/idf_component.yml | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f589155c97f..3aa4c8e700b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -526,7 +526,8 @@ #endif #ifdef USE_ARDUINO -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 12) +#endif #define USE_ETHERNET #define USE_ETHERNET_LAN8720 #define USE_ETHERNET_RTL8201 @@ -557,7 +558,6 @@ #define USE_ETHERNET_DISCONNECT_TRIGGER #define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 -#endif #ifdef USE_ESP32 #define USE_MICRO_WAKE_WORD diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 87ec5cdcfc4..372059bd96f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -85,6 +85,9 @@ dependencies: version: "1.1.0" rules: - if: "idf_version >=6.0.0" + # Keep in sync with esphome/components/ethernet/__init__.py + espressif/ch390: + version: "0.3.0" espressif/esp_tinyusb: version: "2.2.1" rules: From 928459d16f13c9600ab91f2c41cd1bf0812fa049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Vo=C3=9F?= <46140304+mvoss96@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:33:11 +0200 Subject: [PATCH 366/651] [cse7766] Keep last power reading between measurements at low load (#20631) Co-authored-by: J. Nick Koston --- esphome/components/cse7766/cse7766.cpp | 26 +++- esphome/components/cse7766/cse7766.h | 4 +- tests/components/cse7766/common.h | 40 ++++++ .../cse7766/sensor/cse7766_test.cpp | 117 ++++++++++++++++++ 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 tests/components/cse7766/common.h create mode 100644 tests/components/cse7766/sensor/cse7766_test.cpp diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index bbe76855fb8..81f831672bd 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -8,6 +8,9 @@ namespace esphome::cse7766 { ESPHOME_LOG_TAG(TAG, "cse7766"); +// The chip only sends the time since the last pulse once a cycle is longer than 1 s (in us) +static constexpr uint32_t MIN_HELD_POWER_CYCLE = 1000000; + void CSE7766Component::loop() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_transmission_ >= 500) { @@ -154,18 +157,33 @@ void CSE7766Component::parse_data_() { if (power_cycle_exceeds_range) { // Datasheet: power cycle exceeding range means active power is 0 have_power = true; + this->last_power_cycle_ = 0; if (this->power_sensor_ != nullptr) { this->power_sensor_->publish_state(0.0f); } } else if (have_power) { power = power_coeff / float(power_cycle); + this->last_power_cycle_ = power_cycle; if (this->power_sensor_ != nullptr) { this->power_sensor_->publish_state(power); } - } else if (this->power_sensor_ != nullptr) { - // No valid power measurement from chip - publish 0W to avoid stale readings - // This typically happens when current is below the measurable threshold (~50mA) - this->power_sensor_->publish_state(0.0f); + } else if (this->last_power_cycle_ > MIN_HELD_POWER_CYCLE && power_cycle <= 2 * this->last_power_cycle_) { + // No new measurement in this frame. With cycles over 1 s the register holds the last + // cycle or the time elapsed since the last pulse. While that time is within twice the + // last cycle, the next pulse is not overdue, so the last measurement still applies. + // Shorter cycles never get here in normal operation and keep the previous behaviour. + have_power = true; + power = power_coeff / float(this->last_power_cycle_); + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(power); + } + } else { + // The next pulse is overdue: the load has dropped, report 0 W. A load that drops to less + // than half also reads 0 W here until its first new pulse arrives. + this->last_power_cycle_ = 0; + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(0.0f); + } } float current = 0.0f; diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 8a57816a590..1206ad4e71f 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -36,6 +36,7 @@ class CSE7766Component final : public Component, public uart::UARTDevice { uint8_t raw_data_[CSE7766_RAW_DATA_SIZE]; uint8_t raw_data_index_{0}; + uint16_t cf_pulses_last_{0}; uint32_t last_transmission_{0}; sensor::Sensor *voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; @@ -45,7 +46,8 @@ class CSE7766Component final : public Component, public uart::UARTDevice { sensor::Sensor *reactive_power_sensor_{nullptr}; sensor::Sensor *power_factor_sensor_{nullptr}; uint32_t cf_pulses_total_{0}; - uint16_t cf_pulses_last_{0}; + // Power cycle of the last frame that carried a new power measurement, 0 if none + uint32_t last_power_cycle_{0}; }; } // namespace esphome::cse7766 diff --git a/tests/components/cse7766/common.h b/tests/components/cse7766/common.h new file mode 100644 index 00000000000..b69c04d3287 --- /dev/null +++ b/tests/components/cse7766/common.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::cse7766::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(const std::vector &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override {} + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +} // namespace esphome::cse7766::testing diff --git a/tests/components/cse7766/sensor/cse7766_test.cpp b/tests/components/cse7766/sensor/cse7766_test.cpp new file mode 100644 index 00000000000..e5bcda8fa98 --- /dev/null +++ b/tests/components/cse7766/sensor/cse7766_test.cpp @@ -0,0 +1,117 @@ +#include + +#include +#include + +#include "esphome/components/cse7766/cse7766.h" +#include "esphome/components/sensor/sensor.h" +#include "../common.h" + +namespace esphome::cse7766::testing { + +namespace { + +// Coefficients and cycles taken from a Sonoff S60 (CSE7759B). With this power coefficient a power +// cycle of 2507613 is about 2.16 W, and the cycle register advances by about 1050000 per second. +constexpr uint32_t VOLTAGE_COEFF = 0x030BB0; +constexpr uint32_t VOLTAGE_CYCLE = 0x000352; +constexpr uint32_t CURRENT_COEFF = 0x004137; +constexpr uint32_t CURRENT_CYCLE = 0x053339; +constexpr uint32_t POWER_COEFF = 5425000; +constexpr uint32_t LOW_LOAD_CYCLE = 2507613; +constexpr uint32_t SHORT_CYCLE = 900000; // about 6 W, just under 1 s +constexpr uint32_t HIGH_LOAD_CYCLE = 2000; + +constexpr uint8_t HEADER_OK = 0x55; +constexpr uint8_t HEADER_POWER_CYCLE_EXCEEDS_RANGE = 0xF2; +constexpr uint8_t ADJ_NEW_POWER = 0x71; +constexpr uint8_t ADJ_NO_NEW_POWER = 0x61; + +void put24(std::vector &frame, uint32_t value) { + frame.push_back(value >> 16); + frame.push_back(value >> 8); + frame.push_back(value); +} + +std::vector make_frame(uint8_t header, uint32_t power_cycle, uint8_t adj) { + std::vector frame{header, 0x5A}; + put24(frame, VOLTAGE_COEFF); + put24(frame, VOLTAGE_CYCLE); + put24(frame, CURRENT_COEFF); + put24(frame, CURRENT_CYCLE); + put24(frame, POWER_COEFF); + put24(frame, power_cycle); + frame.push_back(adj); + frame.push_back(0x0B); + frame.push_back(0x81); + uint8_t checksum = 0; + for (size_t i = 2; i < frame.size(); i++) { + checksum += frame[i]; + } + frame.push_back(checksum); + return frame; +} + +class CSE7766Test : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_power_sensor(&this->power_); + } + + float feed_(uint8_t header, uint32_t power_cycle, uint8_t adj) { + this->uart_.push_rx(make_frame(header, power_cycle, adj)); + this->sut_.loop(); + return this->power_.state; + } + + MockUARTComponent uart_; + CSE7766Component sut_; + sensor::Sensor power_; +}; + +} // namespace + +TEST_F(CSE7766Test, NewMeasurementIsReported) { + EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER), 2.163f, 0.001f); +} + +// At low load one power cycle spans many frames. Frames in between carry the last cycle or the +// time since the last pulse, and must not be reported as 0 W. +TEST_F(CSE7766Test, LowLoadKeepsLastMeasurementBetweenPulses) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NO_NEW_POWER), 2.163f, 0.001f); + EXPECT_NEAR(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 2.163f, 0.001f); + EXPECT_NEAR(this->feed_(HEADER_OK, 4900000, ADJ_NO_NEW_POWER), 2.163f, 0.001f); +} + +// Below 1 s the chip does not send waiting times in normal operation, so such a frame +// means the pulse is overdue and 0 W is reported at once, as before +TEST_F(CSE7766Test, ShortCycleReportsZeroWithoutNewMeasurement) { + this->feed_(HEADER_OK, SHORT_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_OK, 1050000, ADJ_NO_NEW_POWER), 0.0f); +} + +TEST_F(CSE7766Test, OverduePulseReportsZero) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_OK, 2 * LOW_LOAD_CYCLE + 1, ADJ_NO_NEW_POWER), 0.0f); + // Stays 0 even when a later frame shows a short cycle again, until a new measurement arrives + EXPECT_EQ(this->feed_(HEADER_OK, 1000000, ADJ_NO_NEW_POWER), 0.0f); +} + +// A large load switching off: the first frame without a new measurement already shows about one +// second since the last pulse, far beyond the 2 ms cycle, so 0 W is reported at once. +TEST_F(CSE7766Test, HighLoadSwitchingOffReportsZeroAtOnce) { + EXPECT_NEAR(this->feed_(HEADER_OK, HIGH_LOAD_CYCLE, ADJ_NEW_POWER), 2712.5f, 0.1f); + EXPECT_EQ(this->feed_(HEADER_OK, 1072601, ADJ_NO_NEW_POWER), 0.0f); +} + +TEST_F(CSE7766Test, NoMeasurementYetReportsZero) { EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); } + +TEST_F(CSE7766Test, PowerCycleExceedsRangeReportsZero) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_POWER_CYCLE_EXCEEDS_RANGE, 9000000, ADJ_NO_NEW_POWER), 0.0f); + EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); +} + +} // namespace esphome::cse7766::testing From 7f144365ba6a7d9e7fff72f296b5f14c3240cb0d Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 20:35:29 +0200 Subject: [PATCH 367/651] [fusb302b] Add USB PD sink protocol (#20632) Co-authored-by: Claude Opus 5.5 --- CODEOWNERS | 1 + esphome/components/fusb302b/__init__.py | 2 + esphome/components/fusb302b/pd.cpp | 330 ++++++++++++++++ esphome/components/fusb302b/pd.h | 178 +++++++++ tests/components/fusb302b/pd_test.cpp | 481 ++++++++++++++++++++++++ 5 files changed, 992 insertions(+) create mode 100644 esphome/components/fusb302b/__init__.py create mode 100644 esphome/components/fusb302b/pd.cpp create mode 100644 esphome/components/fusb302b/pd.h create mode 100644 tests/components/fusb302b/pd_test.cpp diff --git a/CODEOWNERS b/CODEOWNERS index 1282e9d66b9..7473c80cdc6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -219,6 +219,7 @@ esphome/components/friedrich/* @rwrozelle esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio +esphome/components/fusb302b/* @remcom esphome/components/gcja5/* @gcormier esphome/components/gdk101/* @Szewcson esphome/components/gl_r01_i2c/* @pkejval diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py new file mode 100644 index 00000000000..3cf9043f418 --- /dev/null +++ b/esphome/components/fusb302b/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@remcom"] +DOMAIN = "fusb302b" diff --git a/esphome/components/fusb302b/pd.cpp b/esphome/components/fusb302b/pd.cpp new file mode 100644 index 00000000000..4b6931b38fa --- /dev/null +++ b/esphome/components/fusb302b/pd.cpp @@ -0,0 +1,330 @@ +#include "pd.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::fusb302b { + +ESPHOME_LOG_TAG(TAG, "fusb302b.pd"); + +static constexpr uint32_t AMS_TIMEOUT_MS = 2000; +// tSinkRequest: delay before repeating a request that the source answered with Wait +static constexpr uint32_t SINK_REQUEST_MS = 100; +static constexpr uint8_t MAX_WAIT_RETRIES = 10; + +// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object flags; only the first (5 V) object carries them +static constexpr uint32_t SINK_PDO_HIGHER_CAPABILITY = 1u << 28; +static constexpr uint32_t SINK_PDO_USB_COMMS_CAPABLE = 1u << 26; +// Operational current advertised for every sink object: 3 A (300 x 10 mA) +static constexpr uint16_t SINK_PDO_CURRENT = 300; + +// USB PD 6.4.1.2.3 Source Fixed Supply Power Data Object; other PDO types only report their type +static PdContract parse_pdo(uint32_t pdo) { + PdContract info{}; + info.type = static_cast(pdo >> 30); + if (info.type == PD_PDO_TYPE_FIXED_SUPPLY) { + info.max_v = (pdo >> 10) & 0x3FF; + info.max_i = pdo & 0x3FF; + } + return info; +} + +// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object; voltage in 50 mV units, current in 10 mA units +static constexpr uint32_t sink_fixed_pdo(uint16_t voltage, uint16_t current) { + return (static_cast(PD_PDO_TYPE_FIXED_SUPPLY) << 30) | (static_cast(voltage & 0x3FF) << 10) | + (current & 0x3FF); +} + +void PdMsg::set_header(uint16_t header) { + this->type = header & 0x1F; + this->spec_rev = static_cast((header >> 6) & 0x3); + this->id = (header >> 9) & 0x7; + this->num_of_obj = (header >> 12) & 0x7; + this->extended = (header >> 15) & 0x1; +} + +uint16_t PdMsg::get_coded_header() const { + // Data role UFP (bit 5 = 0), power role sink (bit 8 = 0) + return static_cast(this->type & 0x1F) | (static_cast(this->spec_rev) << 6) | + (static_cast(this->id & 0x7) << 9) | (static_cast(this->num_of_obj & 0x7) << 12) | + (static_cast(this->extended) << 15); +} + +PdMsg PowerDelivery::make_control_msg_(PdControlMsgType type) const { + PdMsg msg; + msg.type = type; + msg.id = this->msg_counter_ % 8; + return msg; +} + +PdMsg PowerDelivery::make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const { + PdMsg msg; + msg.type = type; + msg.id = this->msg_counter_ % 8; + msg.num_of_obj = len; + memcpy(msg.data_objects, objects, len * sizeof(uint32_t)); + return msg; +} + +void PowerDelivery::reset_protocol_() { + this->last_received_msg_id_ = 255; + this->msg_counter_ = 0; + this->active_ams_ = false; + this->ps_rdy_expected_ = false; + this->retry_request_pending_ = false; + this->wait_retries_ = 0; +} + +void PowerDelivery::set_state_(PdState state) { + if (this->state_.exchange(state) != state) + this->on_state_changed(); +} + +void PowerDelivery::set_contract_(const PdContract &contract) { + this->accepted_contract_ = contract; + const uint32_t packed = PackedContract::from(contract).value; + if (this->contract_.exchange(packed) != packed) + this->on_state_changed(); +} + +void PowerDelivery::clear_contract_() { + this->accepted_contract_ = {}; + if (this->contract_.exchange(0) != 0) + this->on_state_changed(); +} + +void PowerDelivery::set_state_and_contract_(PdState state, const PdContract &contract) { + this->accepted_contract_ = contract; + const uint32_t packed = PackedContract::from(contract).value; + // The contract is stored first, so a reader that sees the new state also sees the new contract + const bool contract_changed = this->contract_.exchange(packed) != packed; + const bool state_changed = this->state_.exchange(state) != state; + if (contract_changed || state_changed) + this->on_state_changed(); +} + +void PowerDelivery::set_ams_(bool active) { + this->active_ams_ = active; + // A new sequence has no accepted request yet, and an ended one can no longer complete + this->ps_rdy_expected_ = false; + if (active) + this->ams_start_ = millis(); +} + +bool PowerDelivery::check_ams_() { + if (this->active_ams_ && millis() - this->ams_start_ > AMS_TIMEOUT_MS) + this->set_ams_(false); + return this->active_ams_; +} + +void PowerDelivery::check_request_retry_() { + if (!this->retry_request_pending_ || millis() - this->wait_received_ms_ < SINK_REQUEST_MS) + return; + if (this->send_request_()) { + this->retry_request_pending_ = false; + } else if (this->wait_retries_ < MAX_WAIT_RETRIES) { + // Try again after another tSinkRequest; failed attempts count against the same limit as Wait replies + this->wait_retries_++; + this->wait_received_ms_ = millis(); + } else { + ESP_LOGW(TAG, "Giving up repeating the request"); + this->retry_request_pending_ = false; + this->request_failed_(); + } +} + +bool PowerDelivery::send_request_() { + this->set_ams_(true); + if (this->send_message(this->make_data_msg_(PD_DATA_REQUEST, &this->last_rdo_, 1))) + return true; + ESP_LOGW(TAG, "Sending the request failed"); + this->set_ams_(false); + return false; +} + +void PowerDelivery::request_failed_() { + // A contract already in place stays valid. Without one, report that negotiation is over at default power. + if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT) + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); +} + +void PowerDelivery::check_transition_timeout_() { + // The AMS that sent the request only ends on PS_RDY, so an ended AMS while in transition means a timeout + if (this->state_ != PdState::PD_STATE_TRANSITION || this->check_ams_()) + return; + ESP_LOGW(TAG, "Source did not report the new supply ready, falling back to default power"); + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->on_transition_timeout(); +} + +void PowerDelivery::handle_transmit_failed_() { + // USB PD 6.12.2.2: the counter also moves on after a failed transmission. The source may have received the + // message and only its GoodCRC was lost, so reusing the MessageID would make it drop the next one as a repeat. + ESP_LOGD(TAG, "Message %u not acknowledged", this->msg_counter_ % 8); + this->msg_counter_++; +} + +void PowerDelivery::handle_message_(const PdMsg &msg) { + if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_GOODCRC) { + // A GoodCRC carries the MessageID it acknowledges; a duplicate or stray one must not advance the counter + if (msg.id != this->msg_counter_ % 8) { + ESP_LOGD(TAG, "Ignoring GoodCRC for MessageID %u, expected %u", msg.id, this->msg_counter_ % 8); + return; + } + // Our last message was received, so the next one gets a new MessageID + this->msg_counter_++; + return; + } + // Soft_Reset always has MessageID 0, so it must not be dropped as a retransmission of the last message + if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_SOFT_RESET) { + this->handle_soft_reset_(msg); + return; + } + // Retransmissions repeat the MessageID of a message that was already handled + if (msg.id == this->last_received_msg_id_) + return; + this->last_received_msg_id_ = msg.id; + + if (msg.num_of_obj == 0) { + this->handle_control_message_(msg); + } else { + this->handle_data_message_(msg); + } +} + +void PowerDelivery::handle_data_message_(const PdMsg &msg) { + if (msg.extended || msg.type != PD_DATA_SOURCE_CAP) { + // BIST and Vendor_Defined messages must not be answered with Reject, so unsupported data messages are ignored + ESP_LOGV(TAG, "Ignoring data message type %u%s", msg.type, + msg.extended ? LOG_STR_LITERAL(" (extended)") : LOG_STR_LITERAL("")); + return; + } + this->on_source_caps_received(); + this->respond_to_source_caps_(msg); +} + +void PowerDelivery::handle_control_message_(const PdMsg &msg) { + switch (msg.type) { + case PD_CNTRL_ACCEPT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Accept outside a request"); + break; + } + this->ps_rdy_expected_ = true; + if (this->requested_contract_ != this->accepted_contract_) + this->set_state_and_contract_(PdState::PD_STATE_TRANSITION, this->requested_contract_); + break; + case PD_CNTRL_PS_RDY: + // check_ams_() first, so a PS_RDY that arrives after the sequence timed out is ignored too + if (!this->check_ams_() || !this->ps_rdy_expected_) { + ESP_LOGD(TAG, "Ignoring PS_RDY without an accepted request"); + break; + } + this->set_ams_(false); + this->set_state_(PdState::PD_STATE_EXPLICIT_CONTRACT); + break; + case PD_CNTRL_REJECT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Reject outside a request"); + break; + } + ESP_LOGW(TAG, "Source rejected the request"); + this->set_ams_(false); + this->request_failed_(); + break; + case PD_CNTRL_WAIT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Wait outside a request"); + break; + } + this->set_ams_(false); + if (this->wait_retries_ < MAX_WAIT_RETRIES) { + ESP_LOGD(TAG, "Source asked to wait, repeating the request"); + this->wait_retries_++; + this->retry_request_pending_ = true; + this->wait_received_ms_ = millis(); + } else { + ESP_LOGW(TAG, "Source kept answering Wait, giving up"); + this->request_failed_(); + } + break; + case PD_CNTRL_PING: + case PD_CNTRL_NOT_SUPPORTED: + break; + case PD_CNTRL_GET_SINK_CAP: { + // 5 V first, then the requested voltage. Higher Capability tells the source that 5 V is not enough for + // full operation. + const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units + uint32_t pdos[2]; + uint8_t count = 0; + pdos[count++] = sink_fixed_pdo(100, SINK_PDO_CURRENT) | SINK_PDO_USB_COMMS_CAPABLE | + (request_v > 100 ? SINK_PDO_HIGHER_CAPABILITY : 0); + if (request_v > 100) + pdos[count++] = sink_fixed_pdo(request_v, SINK_PDO_CURRENT); + this->send_message(this->make_data_msg_(PD_DATA_SINK_CAP, pdos, count)); + break; + } + default: + // Messages are sent as PD 2.0, which has no Not_Supported message + this->send_message(this->make_control_msg_(PD_CNTRL_REJECT)); + break; + } +} + +void PowerDelivery::handle_soft_reset_(const PdMsg &msg) { + this->reset_protocol_(); + this->last_received_msg_id_ = msg.id; + // If this fails the source escalates to a hard reset. The hook still runs, so the PHY starts waiting for source + // capabilities and recovers on its own if neither follows. + if (!this->send_message(this->make_control_msg_(PD_CNTRL_ACCEPT))) { + ESP_LOGW(TAG, "Accepting the soft reset failed"); + } + // A soft reset keeps the power level, but the outcome of a transition in progress is unknown + if (this->state_ == PdState::PD_STATE_TRANSITION) + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->on_soft_reset_received(); +} + +void PowerDelivery::respond_to_source_caps_(const PdMsg &msg) { + // Pick the highest fixed supply PDO that does not exceed the requested voltage. + // The first PDO is always the 5 V fixed supply. + const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units + PdContract selected{}; + uint8_t position = 0; // 1-based object position, 0 = none + for (uint8_t idx = 0; idx < msg.num_of_obj; idx++) { + PdContract info = parse_pdo(msg.data_objects[idx]); + if (info.type != PD_PDO_TYPE_FIXED_SUPPLY) + continue; + if (position == 0 || (info.max_v <= request_v && info.max_v > selected.max_v)) { + selected = info; + position = idx + 1; + } + } + + // USB PD 6.4.2 Request Data Object: no USB suspend (bit 24), USB communications capable (bit 25) + uint32_t rdo = (1u << 24) | (1u << 25); + if (position == 0) { + ESP_LOGW(TAG, "No usable PDO in source capabilities, requesting 5 V"); + // Object 1 (5 V fixed supply), 300 mA maximum, 100 mA operating current + selected = PdContract{PD_PDO_TYPE_FIXED_SUPPLY, 100, 30}; + position = 1; + rdo |= (30u << 0) | (10u << 10); + } else { + // Request the full current the source offers + const uint32_t current = selected.max_i; + rdo |= (current << 0) | (current << 10); + } + rdo |= static_cast(position) << 28; + ESP_LOGD(TAG, "Requesting PDO %u: %.2f V", position, selected.max_v * 0.05f); + this->requested_contract_ = selected; + this->last_rdo_ = rdo; + this->retry_request_pending_ = false; + this->wait_retries_ = 0; + // A repeat would come too late: the source resets if it gets no Request within tSenderResponse (24-30 ms) + if (!this->send_request_()) + this->request_failed_(); +} + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.h b/esphome/components/fusb302b/pd.h new file mode 100644 index 00000000000..26c75239152 --- /dev/null +++ b/esphome/components/fusb302b/pd.h @@ -0,0 +1,178 @@ +#pragma once + +#include +#include +#include +#include + +namespace esphome::fusb302b { + +static constexpr uint8_t PD_MAX_NUM_DATA_OBJECTS = 7; +// Standard Power Range fixed supplies, which are the only ones this sink negotiates +static constexpr uint8_t MIN_REQUEST_VOLTAGE = 5; +static constexpr uint8_t MAX_REQUEST_VOLTAGE = 20; + +enum PdSpecRevision : uint8_t { + PD_SPEC_REV_1 = 0, + PD_SPEC_REV_2 = 1, + PD_SPEC_REV_3 = 2, +}; + +enum PdDataMsgType : uint8_t { + PD_DATA_SOURCE_CAP = 0x01, + PD_DATA_REQUEST = 0x02, + PD_DATA_SINK_CAP = 0x04, +}; + +enum PdControlMsgType : uint8_t { + PD_CNTRL_GOODCRC = 0x01, + PD_CNTRL_ACCEPT = 0x03, + PD_CNTRL_REJECT = 0x04, + PD_CNTRL_PING = 0x05, + PD_CNTRL_PS_RDY = 0x06, + PD_CNTRL_GET_SINK_CAP = 0x08, + PD_CNTRL_WAIT = 0x0C, + PD_CNTRL_SOFT_RESET = 0x0D, + PD_CNTRL_NOT_SUPPORTED = 0x10, +}; + +enum PdPdoType : uint8_t { + PD_PDO_TYPE_FIXED_SUPPLY = 0, + PD_PDO_TYPE_BATTERY = 1, + PD_PDO_TYPE_VARIABLE_SUPPLY = 2, + PD_PDO_TYPE_AUGMENTED = 3, +}; + +enum class PdState : uint8_t { + PD_STATE_DISCONNECTED, + /// Negotiation is over without a PD contract (no capabilities, or the request was not accepted); the source + /// supplies Type-C default power. + PD_STATE_PD_TIMEOUT, + PD_STATE_DEFAULT_CONTRACT, + PD_STATE_TRANSITION, + PD_STATE_EXPLICIT_CONTRACT, + PD_STATE_ERROR, +}; + +/// Returns true for the states in which a source is attached and supplying power. +inline bool is_connected_state(PdState state) { + return state == PdState::PD_STATE_DEFAULT_CONTRACT || state == PdState::PD_STATE_TRANSITION || + state == PdState::PD_STATE_EXPLICIT_CONTRACT || state == PdState::PD_STATE_PD_TIMEOUT; +} + +struct PdContract { + PdPdoType type{PD_PDO_TYPE_FIXED_SUPPLY}; + uint16_t max_v{0}; // 50 mV units + uint16_t max_i{0}; // 10 mA units + + bool operator==(const PdContract &other) const { + return this->max_v == other.max_v && this->max_i == other.max_i && this->type == other.type; + } + bool operator!=(const PdContract &other) const { return !(*this == other); } +}; + +// Type-C default power: 5 V (100 x 50 mV) at 500 mA (50 x 10 mA) +static constexpr PdContract DEFAULT_CONTRACT{PD_PDO_TYPE_FIXED_SUPPLY, 100, 50}; + +/// A contract packed into one word so it can be shared between tasks as an atomic. +/// 0 means there is no contract (nothing attached). +struct PackedContract { + uint32_t value{0}; + + static PackedContract from(const PdContract &contract) { + return {(static_cast(contract.max_v) << 16) | contract.max_i}; + } + bool is_set() const { return this->value != 0; } + float voltage() const { return (this->value >> 16) * 0.05f; } + float current() const { return (this->value & 0xFFFF) * 0.01f; } +}; + +struct PdMsg { + uint8_t type{0}; + PdSpecRevision spec_rev{PD_SPEC_REV_2}; + uint8_t id{0}; + uint8_t num_of_obj{0}; + bool extended{false}; + uint32_t data_objects[PD_MAX_NUM_DATA_OBJECTS]{}; + + void set_header(uint16_t header); + uint16_t get_coded_header() const; +}; + +/// USB-PD sink protocol logic, independent of the PHY chip. +/// Everything except the getters and set_request_voltage() runs in the PHY task, which must also call +/// check_transition_timeout_() and check_request_retry_() regularly; the protocol has no timers of its own. +class PowerDelivery { + public: + void set_request_voltage(uint8_t voltage) { + this->request_voltage_ = std::clamp(voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE); + } + uint8_t get_request_voltage() const { return this->request_voltage_; } + + PdState get_state() const { return this->state_; } + bool is_connected() const { return is_connected_state(this->state_); } + + protected: + /// Returns false when the message could not be handed to the chip; the PHY logs the failure. + virtual bool send_message(const PdMsg &msg) = 0; + /// Called after state_ or contract_ changed, to let the main loop publish it. + virtual void on_state_changed() = 0; + /// Called after the source soft reset the protocol; it sends new source capabilities next. + virtual void on_soft_reset_received() = 0; + /// Called when source capabilities arrive, before the request for them is sent. + virtual void on_source_caps_received() = 0; + /// Called after an accepted request was not followed by PS_RDY in time and the state fell back to default power; + /// the PHY sends a soft reset so the source offers its capabilities again. + virtual void on_transition_timeout() = 0; + + void handle_message_(const PdMsg &msg); + /// Called by the PHY when the chip gave up on a message after its retries without receiving a GoodCRC. + void handle_transmit_failed_(); + void reset_protocol_(); + void set_state_(PdState state); + void set_contract_(const PdContract &contract); + void clear_contract_(); + /// Stores both before calling on_state_changed() once, so the main loop never sees the new state with the old + /// contract. + void set_state_and_contract_(PdState state, const PdContract &contract); + void set_ams_(bool active); + /// Returns true while an atomic message sequence is in progress; ends it after a timeout. + bool check_ams_(); + /// Falls back to default power when the source accepted a request but did not report the new supply ready in time. + void check_transition_timeout_(); + /// Sends the last request again once tSinkRequest has passed after the source answered Wait. + void check_request_retry_(); + + PdMsg make_control_msg_(PdControlMsgType type) const; + PdMsg make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const; + + // Shared with the main loop + std::atomic state_{PdState::PD_STATE_DISCONNECTED}; + std::atomic contract_{0}; + std::atomic request_voltage_{5}; + + // PHY task only + uint32_t ams_start_{0}; + uint32_t wait_received_ms_{0}; + bool retry_request_pending_{false}; + + private: + void handle_data_message_(const PdMsg &msg); + void handle_control_message_(const PdMsg &msg); + void handle_soft_reset_(const PdMsg &msg); + void respond_to_source_caps_(const PdMsg &msg); + bool send_request_(); + void request_failed_(); + + PdContract requested_contract_{}; + PdContract accepted_contract_{}; + uint32_t last_rdo_{0}; + bool active_ams_{false}; + // Set by Accept; PS_RDY is only valid after the source accepted the request of the current sequence + bool ps_rdy_expected_{false}; + uint8_t last_received_msg_id_{255}; + uint8_t msg_counter_{0}; + uint8_t wait_retries_{0}; +}; + +} // namespace esphome::fusb302b diff --git a/tests/components/fusb302b/pd_test.cpp b/tests/components/fusb302b/pd_test.cpp new file mode 100644 index 00000000000..c122d59b039 --- /dev/null +++ b/tests/components/fusb302b/pd_test.cpp @@ -0,0 +1,481 @@ +#include +#include + +#include "esphome/components/fusb302b/pd.h" + +namespace esphome::fusb302b::testing { + +static constexpr uint32_t fixed_pdo(uint16_t mv, uint16_t ma) { return ((mv / 50u) << 10) | (ma / 10u); } +// An augmented (PPS) object; only its type matters to the sink +static constexpr uint32_t AUGMENTED_PDO = static_cast(PD_PDO_TYPE_AUGMENTED) << 30; +// Unknown to the sink, so it must be answered with Reject +static constexpr uint8_t PD_CNTRL_DR_SWAP = 0x09; + +class FakePhy : public PowerDelivery { + public: + using PowerDelivery::ams_start_; + using PowerDelivery::check_ams_; + using PowerDelivery::check_transition_timeout_; + using PowerDelivery::check_request_retry_; + using PowerDelivery::contract_; + using PowerDelivery::handle_message_; + using PowerDelivery::handle_transmit_failed_; + using PowerDelivery::retry_request_pending_; + using PowerDelivery::set_state_and_contract_; + using PowerDelivery::wait_received_ms_; + + std::vector sent; + bool send_ok{true}; + int send_attempts{0}; + int state_changes{0}; + int soft_resets{0}; + int source_caps{0}; + int transition_timeouts{0}; + + void receive(uint8_t type, uint8_t id, std::vector objects = {}) { + PdMsg msg; + msg.type = type; + msg.id = id; + msg.num_of_obj = objects.size(); + for (size_t i = 0; i < objects.size(); i++) + msg.data_objects[i] = objects[i]; + this->handle_message_(msg); + } + void receive_source_caps(uint8_t id) { + this->receive(PD_DATA_SOURCE_CAP, id, + {fixed_pdo(5000, 3000), fixed_pdo(9000, 3000), fixed_pdo(15000, 3000), fixed_pdo(20000, 2250)}); + } + PackedContract contract() const { return PackedContract{this->contract_}; } + // What the PHY does when a source is attached + void attach() { this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); } + // Moves the timestamps back instead of sleeping, since millis() is real time on the host + void advance_ms(uint32_t ms) { + this->ams_start_ -= ms; + this->wait_received_ms_ -= ms; + } + + protected: + bool send_message(const PdMsg &msg) override { + this->send_attempts++; + if (!this->send_ok) + return false; + this->sent.push_back(msg); + return true; + } + void on_state_changed() override { this->state_changes++; } + void on_soft_reset_received() override { this->soft_resets++; } + void on_source_caps_received() override { + // The hook runs before the request is sent + EXPECT_EQ(this->sent.size(), static_cast(this->source_caps)); + this->source_caps++; + } + void on_transition_timeout() override { this->transition_timeouts++; } +}; + +TEST(FUSB302BPd, HeaderRoundTrip) { + PdMsg msg; + msg.type = PD_DATA_REQUEST; + msg.spec_rev = PD_SPEC_REV_2; + msg.id = 5; + msg.num_of_obj = 1; + // Type 2, spec revision 1 at bit 6, MessageID 5 at bit 9, one object at bit 12 + EXPECT_EQ(msg.get_coded_header(), 0x1A42); + + PdMsg decoded; + decoded.set_header(0x1A42 | (1u << 15)); + EXPECT_EQ(decoded.type, PD_DATA_REQUEST); + EXPECT_EQ(decoded.spec_rev, PD_SPEC_REV_2); + EXPECT_EQ(decoded.id, 5); + EXPECT_EQ(decoded.num_of_obj, 1); + EXPECT_TRUE(decoded.extended); +} + +TEST(FUSB302BPd, RequestsHighestVoltageNotAboveLimit) { + FakePhy pd; + pd.set_request_voltage(18); + pd.receive_source_caps(0); + EXPECT_EQ(pd.source_caps, 1); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_DATA_REQUEST); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); // 15 V is object 3 + EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 300u); +} + +TEST(FUSB302BPd, LowestRequestPicksFirstObject) { + FakePhy pd; + pd.set_request_voltage(5); + pd.receive_source_caps(0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 1u); +} + +TEST(FUSB302BPd, NonFixedObjectKeepsItsPosition) { + FakePhy pd; + pd.set_request_voltage(9); + pd.receive(PD_DATA_SOURCE_CAP, 0, {fixed_pdo(5000, 3000), AUGMENTED_PDO, fixed_pdo(9000, 2000)}); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); + EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 200u); +} + +TEST(FUSB302BPd, NoFixedObjectFallsBackToObjectOne) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive(PD_DATA_SOURCE_CAP, 0, {AUGMENTED_PDO, AUGMENTED_PDO}); + ASSERT_EQ(pd.sent.size(), 1u); + const uint32_t rdo = pd.sent[0].data_objects[0]; + EXPECT_EQ(rdo >> 28, 1u); + EXPECT_EQ(rdo & 0x3FF, 30u); // 300 mA maximum + EXPECT_EQ((rdo >> 10) & 0x3FF, 10u); // 100 mA operating +} + +TEST(FUSB302BPd, AcceptThenPsRdyReachesExplicitContract) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 2.25f); +} + +TEST(FUSB302BPd, AcceptPublishesStateAndContractOnce) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.state_changes = 0; + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); +} + +TEST(FUSB302BPd, AcceptForSameContractSkipsTransition) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_ACCEPT, 4); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + pd.receive(PD_CNTRL_PS_RDY, 5); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FALSE(pd.check_ams_()); +} + +TEST(FUSB302BPd, PsRdyWithoutAcceptIsIgnored) { + FakePhy pd; + pd.receive(PD_CNTRL_PS_RDY, 0); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); + + pd.receive_source_caps(1); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); +} + +TEST(FUSB302BPd, PsRdyAfterRejectIsIgnored) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + EXPECT_FALSE(pd.check_ams_()); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); +} + +TEST(FUSB302BPd, PsRdyAfterSequenceTimeoutIsIgnored) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.advance_ms(2001); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); +} + +TEST(FUSB302BPd, TransitionTimeoutFallsBackToDefault) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.check_transition_timeout_(); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + EXPECT_EQ(pd.transition_timeouts, 0); + + pd.advance_ms(2001); + pd.state_changes = 0; + pd.check_transition_timeout_(); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.transition_timeouts, 1); + + // Reported once, and a late PS_RDY does not bring back the unconfirmed contract + pd.check_transition_timeout_(); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.transition_timeouts, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); +} + +TEST(FUSB302BPd, RequestVoltageIsClamped) { + FakePhy pd; + pd.set_request_voltage(255); + EXPECT_EQ(pd.get_request_voltage(), MAX_REQUEST_VOLTAGE); + pd.set_request_voltage(0); + EXPECT_EQ(pd.get_request_voltage(), MIN_REQUEST_VOLTAGE); +} + +TEST(FUSB302BPd, WaitRepeatsRequestAfterSinkRequestTime) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + EXPECT_FALSE(pd.check_ams_()); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 1u); + pd.advance_ms(101); + pd.check_request_retry_(); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].type, PD_DATA_REQUEST); + EXPECT_EQ(pd.sent[1].data_objects[0], pd.sent[0].data_objects[0]); + EXPECT_TRUE(pd.check_ams_()); +} + +TEST(FUSB302BPd, WaitRetriesStopAtLimit) { + FakePhy pd; + pd.attach(); + pd.receive_source_caps(0); + for (uint8_t i = 1; i <= 10; i++) { + pd.receive(PD_CNTRL_WAIT, i % 8); + pd.advance_ms(101); + pd.check_request_retry_(); + } + EXPECT_EQ(pd.sent.size(), 11u); + pd.receive(PD_CNTRL_WAIT, 3); + EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 11u); +} + +TEST(FUSB302BPd, FailedRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.send_ok = false; + pd.receive_source_caps(0); + EXPECT_EQ(pd.send_attempts, 1); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); +} + +TEST(FUSB302BPd, FailedRetryIsTriedAgain) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + pd.send_ok = false; + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.send_attempts, 2); + EXPECT_TRUE(pd.retry_request_pending_); + EXPECT_FALSE(pd.check_ams_()); + + pd.send_ok = true; + pd.check_request_retry_(); + EXPECT_EQ(pd.send_attempts, 2); // waits another tSinkRequest first + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 2u); + EXPECT_FALSE(pd.retry_request_pending_); +} + +TEST(FUSB302BPd, FailedRetriesStopAtLimit) { + FakePhy pd; + pd.attach(); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + pd.send_ok = false; + for (int i = 0; i < 20 && pd.retry_request_pending_; i++) { + pd.advance_ms(101); + pd.check_request_retry_(); + } + EXPECT_FALSE(pd.retry_request_pending_); + // The first request, then one attempt for each of the 10 allowed repeats + EXPECT_EQ(pd.send_attempts, 11); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); +} + +TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); +} + +TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.set_request_voltage(9); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_REJECT, 4); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); +} + +TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) { + FakePhy pd; + pd.attach(); + pd.receive(PD_CNTRL_REJECT, 0); + pd.receive(PD_CNTRL_WAIT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FALSE(pd.retry_request_pending_); +} + +TEST(FUSB302BPd, UnsupportedDataMessageIsIgnored) { + FakePhy pd; + pd.receive(PD_DATA_REQUEST, 0, {0}); + EXPECT_TRUE(pd.sent.empty()); + EXPECT_EQ(pd.source_caps, 0); +} + +TEST(FUSB302BPd, AcceptOutsideSequenceIsIgnored) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + pd.receive(PD_CNTRL_ACCEPT, 2); + pd.receive(PD_CNTRL_PS_RDY, 3); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); + EXPECT_FALSE(pd.contract().is_set()); +} + +TEST(FUSB302BPd, GoodCrcMustMatchMessageId) { + FakePhy pd; + pd.receive(PD_CNTRL_DR_SWAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].id, 0); + + pd.receive(PD_CNTRL_GOODCRC, 1); + pd.receive(PD_CNTRL_DR_SWAP, 1); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].id, 0); + + pd.receive(PD_CNTRL_GOODCRC, 0); + pd.receive(PD_CNTRL_GOODCRC, 0); // duplicate + pd.receive(PD_CNTRL_DR_SWAP, 2); + ASSERT_EQ(pd.sent.size(), 3u); + EXPECT_EQ(pd.sent[2].id, 1); +} + +TEST(FUSB302BPd, FailedTransmitAdvancesMessageId) { + FakePhy pd; + pd.receive_source_caps(0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].id, 0); + pd.handle_transmit_failed_(); + // A late GoodCRC for the failed message must not advance the counter a second time + pd.receive(PD_CNTRL_GOODCRC, 0); + pd.receive(PD_CNTRL_DR_SWAP, 1); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].id, 1); +} + +TEST(FUSB302BPd, RetransmissionIsIgnored) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive_source_caps(0); + EXPECT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.source_caps, 1); +} + +TEST(FUSB302BPd, SoftResetIsAccepted) { + FakePhy pd; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_CNTRL_ACCEPT); + EXPECT_EQ(pd.soft_resets, 1); +} + +TEST(FUSB302BPd, SoftResetHookRunsWhenAcceptFails) { + FakePhy pd; + pd.send_ok = false; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + EXPECT_EQ(pd.send_attempts, 1); + EXPECT_EQ(pd.soft_resets, 1); +} + +TEST(FUSB302BPd, SoftResetDuringTransitionFallsBackToDefault) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.state_changes = 0; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f); +} + +TEST(FUSB302BPd, UnknownControlMessageIsRejected) { + FakePhy pd; + pd.receive(PD_CNTRL_DR_SWAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_CNTRL_REJECT); +} + +TEST(FUSB302BPd, SinkCapAt5VHasOneObject) { + FakePhy pd; + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_DATA_SINK_CAP); + ASSERT_EQ(pd.sent[0].num_of_obj, 1); + const uint32_t pdo = pd.sent[0].data_objects[0]; + EXPECT_EQ((pdo >> 10) & 0x3FF, 100u); + EXPECT_EQ(pdo & 0x3FF, 300u); + EXPECT_FALSE(pdo & (1u << 28)); + EXPECT_TRUE(pdo & (1u << 26)); +} + +TEST(FUSB302BPd, SinkCapListsRequestedVoltage) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + ASSERT_EQ(pd.sent[0].num_of_obj, 2); + EXPECT_TRUE(pd.sent[0].data_objects[0] & (1u << 28)); + const uint32_t pdo = pd.sent[0].data_objects[1]; + EXPECT_EQ(pdo >> 30, 0u); + EXPECT_EQ((pdo >> 10) & 0x3FF, 400u); + EXPECT_EQ(pdo & 0x3FF, 300u); + EXPECT_EQ(pdo >> 25, 0u); // capability flags are only valid in the first object +} + +TEST(FUSB302BPd, SinkCapForOutOfRangeVoltageStaysValid) { + FakePhy pd; + pd.set_request_voltage(255); + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + ASSERT_EQ(pd.sent[0].num_of_obj, 2); + const uint32_t pdo = pd.sent[0].data_objects[1]; + EXPECT_EQ((pdo >> 10) & 0x3FF, 400u); + EXPECT_EQ(pdo >> 20, 0u); // nothing spills into the peak current or flag bits +} + +} // namespace esphome::fusb302b::testing From a1f740b71bda88bcf060d9f5ce05491e0ef5ba0d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:36:17 -0400 Subject: [PATCH 368/651] [lvgl] Keep LV_CONF_PATH one argument when the build path has a space (#20758) --- esphome/components/lvgl/__init__.py | 14 +++++-- .../lvgl/test_lv_conf_path_flag.py | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/test_lv_conf_path_flag.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6e3087ba9f0..6806eab0cf3 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -335,6 +335,16 @@ def final_validation(config_list): widget_type.final_validate(name, conf, widget_conf, path[1:]) +def lv_conf_path_flag(lv_conf_h_file: Path) -> str: + """The ``-DLV_CONF_PATH`` build flag, decoded identically by the shell + lexers (PlatformIO, host, ESP8266) and the quoted CMake arguments + (ESP-IDF, Zephyr) the build flags pass through.""" + # forward slashes for Windows, then escape what shlex or a quoted CMake + # argument would not pass through literally + path = re.sub(r"[^\w/.:+=,-]", r"\\\g<0>", Path(lv_conf_h_file).as_posix()) + return f'-DLV_CONF_PATH=\\"{path}\\"' + + async def to_code(configs): config_0 = configs[0] # Global configuration @@ -563,9 +573,7 @@ async def to_code(configs): if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()): clean_build(clear_pio_cache=False) cg.add_build_flag("-DLV_CONF_H=1") - # handle windows paths in a way that doesn't break the generated C++ - lv_conf_h_path = Path(lv_conf_h_file).as_posix() - cg.add_build_flag(f'-DLV_CONF_PATH=\\"{lv_conf_h_path}\\"') + cg.add_build_flag(lv_conf_path_flag(lv_conf_h_file)) cg.add_build_flag("-DLV_KCONFIG_IGNORE") for prop in df.get_remapped_uses(): diff --git a/tests/component_tests/lvgl/test_lv_conf_path_flag.py b/tests/component_tests/lvgl/test_lv_conf_path_flag.py new file mode 100644 index 00000000000..e13a0bcc376 --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf_path_flag.py @@ -0,0 +1,39 @@ +"""The ``-DLV_CONF_PATH`` build flag carries a path. PlatformIO, the host +build and the native ESP8266 build shell-lex build flags, while the ESP-IDF +and Zephyr builds write each flag into a quoted CMake argument; both must +decode it to one ``-DLV_CONF_PATH="..."`` argument, including when the +project path contains a space.""" + +from __future__ import annotations + +from pathlib import Path +import re +import shlex + +import pytest + +from esphome.components.lvgl import lv_conf_path_flag + + +def _cmake_quoted_argument(flag: str) -> str: + """Decode ``flag`` as CMake decodes a quoted argument: a backslash escapes the next character.""" + return re.sub(r"\\(.)", r"\1", flag) + + +@pytest.mark.parametrize( + "build_path", + [ + "/home/user/.esphome/build/node", + "/Users/me/with space/.esphome/build/node", + "/srv/odd 'quote' $(and)/build", + "C:/Users/Kris Maddens/esphome/.esphome/build/node", + ], + ids=["plain", "space", "shell-specials", "windows"], +) +def test_flag_is_one_argument_everywhere(build_path: str) -> None: + lv_conf_h_file = Path(build_path) / "src" / "lv_conf.h" + flag = lv_conf_path_flag(lv_conf_h_file) + expected = f'-DLV_CONF_PATH="{lv_conf_h_file.as_posix()}"' + + assert shlex.split(flag) == [expected] + assert _cmake_quoted_argument(flag) == expected From b994e081c30b21a8ecf222e794030bc439353e67 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:42:47 +0100 Subject: [PATCH 369/651] [bm8563] Convert the weekday to and from the chip's 0-6 range (#20737) --- esphome/components/bm8563/bm8563.cpp | 5 +- tests/components/bm8563/__init__.py | 6 ++ tests/components/bm8563/test_bm8563.cpp | 77 +++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/components/bm8563/__init__.py create mode 100644 tests/components/bm8563/test_bm8563.cpp diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index df1cf96d698..de6b51ab1f5 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -100,7 +100,8 @@ void BM8563::get_date_(ESPTime &time) { this->read_register(DATE_FIRST_REG, buf, sizeof(buf)); time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f); - time.day_of_week = this->bcd2_to_byte_(buf[1] & 0x07); + // The chip counts weekdays 0-6 from Sunday (not BCD), ESPTime counts 1-7 + time.day_of_week = (buf[1] & 0x07) + 1; time.month = this->bcd2_to_byte_(buf[2] & 0x1f); uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff); @@ -115,7 +116,7 @@ void BM8563::get_date_(ESPTime &time) { void BM8563::set_date_(const ESPTime &time) { uint8_t buf[4] = { this->byte_to_bcd2_(time.day_of_month), - this->byte_to_bcd2_(time.day_of_week), + static_cast(time.day_of_week - 1), this->byte_to_bcd2_(time.month), this->byte_to_bcd2_(time.year % 100), }; diff --git a/tests/components/bm8563/__init__.py b/tests/components/bm8563/__init__.py new file mode 100644 index 00000000000..9465ea845e2 --- /dev/null +++ b/tests/components/bm8563/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the time platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/bm8563/test_bm8563.cpp b/tests/components/bm8563/test_bm8563.cpp new file mode 100644 index 00000000000..3514702e3b4 --- /dev/null +++ b/tests/components/bm8563/test_bm8563.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include + +#include "esphome/components/bm8563/bm8563.h" +#include "esphome/core/time.h" + +namespace esphome::bm8563::testing { + +static constexpr uint8_t ADDRESS = 0x51; +static constexpr uint8_t WEEKDAYS_REG = 0x06; +static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0 + +// The chip's 16 registers behind an address pointer that moves on after every byte. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +// write_time() takes the host's current time, so reading it back never changes the host clock. +class BM8563Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; }); + this->rtc_.setup(); + } + + RegisterBus bus_; + BM8563 rtc_; + unsigned syncs_{0}; +}; + +TEST_F(BM8563Test, WriteTimeStoresTheChipsWeekday) { + // Read the clock on both sides of the write, in case UTC midnight falls in between. + const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + this->rtc_.write_time(); + const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + const int stored = this->bus_.registers[WEEKDAYS_REG]; + EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before; +} + +// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight. +// Seven steps from the written value visit every weekday once, and each has to read back as a valid time. +// Only the weekday moves, so the time read back is still the host's time. +TEST_F(BM8563Test, WeekdayRoundTripsThroughSundayAndSaturday) { + this->rtc_.write_time(); + uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG]; + for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) { + weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK; + const unsigned syncs_before = this->syncs_; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday); + } +} + +} // namespace esphome::bm8563::testing From fee47fe2f16340a5f39f50c556fe65575f9acdaa Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:43:23 +0100 Subject: [PATCH 370/651] [pcf85063] Convert the weekday to and from the chip's 0-6 range (#20736) --- esphome/components/pcf85063/pcf85063.cpp | 5 +- tests/components/pcf85063/__init__.py | 6 ++ tests/components/pcf85063/test_pcf85063.cpp | 77 +++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/components/pcf85063/__init__.py create mode 100644 tests/components/pcf85063/test_pcf85063.cpp diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 8ce76e9781c..bafe19ed2a4 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -37,7 +37,8 @@ void PCF85063Component::read_time() { .second = uint8_t(pcf85063_.reg.second + 10 * pcf85063_.reg.second_10), .minute = uint8_t(pcf85063_.reg.minute + 10u * pcf85063_.reg.minute_10), .hour = uint8_t(pcf85063_.reg.hour + 10u * pcf85063_.reg.hour_10), - .day_of_week = uint8_t(pcf85063_.reg.weekday), + // The chip counts weekdays 0-6 from Sunday, ESPTime counts 1-7 + .day_of_week = uint8_t(this->pcf85063_.reg.weekday + 1), .day_of_month = uint8_t(pcf85063_.reg.day + 10u * pcf85063_.reg.day_10), .month = uint8_t(pcf85063_.reg.month + 10u * pcf85063_.reg.month_10), .year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000), @@ -62,7 +63,7 @@ void PCF85063Component::write_time() { pcf85063_.reg.month_10 = now.month / 10; pcf85063_.reg.day = now.day_of_month % 10; pcf85063_.reg.day_10 = now.day_of_month / 10; - pcf85063_.reg.weekday = now.day_of_week; + this->pcf85063_.reg.weekday = now.day_of_week - 1; pcf85063_.reg.hour = now.hour % 10; pcf85063_.reg.hour_10 = now.hour / 10; pcf85063_.reg.minute = now.minute % 10; diff --git a/tests/components/pcf85063/__init__.py b/tests/components/pcf85063/__init__.py new file mode 100644 index 00000000000..9465ea845e2 --- /dev/null +++ b/tests/components/pcf85063/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the time platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/pcf85063/test_pcf85063.cpp b/tests/components/pcf85063/test_pcf85063.cpp new file mode 100644 index 00000000000..0e5c5e6c20d --- /dev/null +++ b/tests/components/pcf85063/test_pcf85063.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include + +#include "esphome/components/pcf85063/pcf85063.h" +#include "esphome/core/time.h" + +namespace esphome::pcf85063::testing { + +static constexpr uint8_t ADDRESS = 0x51; +static constexpr uint8_t WEEKDAYS_REG = 0x08; +static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0 + +// The chip's 18 registers behind an address pointer that moves on after every byte. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +// write_time() takes the host's current time, so reading it back never changes the host clock. +class PCF85063Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; }); + this->rtc_.setup(); + } + + RegisterBus bus_; + PCF85063Component rtc_; + unsigned syncs_{0}; +}; + +TEST_F(PCF85063Test, WriteTimeStoresTheChipsWeekday) { + // Read the clock on both sides of the write, in case UTC midnight falls in between. + const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + this->rtc_.write_time(); + const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + const int stored = this->bus_.registers[WEEKDAYS_REG]; + EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before; +} + +// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight. +// Seven steps from the written value visit every weekday once, and each has to read back as a valid time. +// Only the weekday moves, so the time read back is still the host's time. +TEST_F(PCF85063Test, WeekdayRoundTripsThroughSundayAndSaturday) { + this->rtc_.write_time(); + uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG]; + for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) { + weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK; + const unsigned syncs_before = this->syncs_; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday); + } +} + +} // namespace esphome::pcf85063::testing From 56f1a2bf460c8cdf3e19efad97c03689b9259d19 Mon Sep 17 00:00:00 2001 From: Fexiven <48439988+Fexiven@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:45:21 +0200 Subject: [PATCH 371/651] [midea_portasplit] Add Midea PortaSplit climate component (#18033) Co-authored-by: Claude Opus 4.8 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 App <223556219+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/midea_portasplit/__init__.py | 1 + .../components/midea_portasplit/climate.py | 30 ++ .../midea_portasplit/midea_portasplit.cpp | 337 ++++++++++++++++++ .../midea_portasplit/midea_portasplit.h | 63 ++++ tests/components/midea_portasplit/common.yaml | 4 + .../midea_portasplit/test.esp32-idf.yaml | 3 + .../midea_portasplit/test.esp8266-ard.yaml | 3 + 8 files changed, 442 insertions(+) create mode 100644 esphome/components/midea_portasplit/__init__.py create mode 100644 esphome/components/midea_portasplit/climate.py create mode 100644 esphome/components/midea_portasplit/midea_portasplit.cpp create mode 100644 esphome/components/midea_portasplit/midea_portasplit.h create mode 100644 tests/components/midea_portasplit/common.yaml create mode 100644 tests/components/midea_portasplit/test.esp32-idf.yaml create mode 100644 tests/components/midea_portasplit/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 7473c80cdc6..56cb685ec53 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -387,6 +387,7 @@ esphome/components/microphone/* @jesserockz @kahrendt esphome/components/mics_4514/* @jesserockz esphome/components/midea/* @dudanov esphome/components/midea_ir/* @dudanov +esphome/components/midea_portasplit/* @Fexiven esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow diff --git a/esphome/components/midea_portasplit/__init__.py b/esphome/components/midea_portasplit/__init__.py new file mode 100644 index 00000000000..e94ead74d25 --- /dev/null +++ b/esphome/components/midea_portasplit/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea_portasplit" diff --git a/esphome/components/midea_portasplit/climate.py b/esphome/components/midea_portasplit/climate.py new file mode 100644 index 00000000000..efac42a403b --- /dev/null +++ b/esphome/components/midea_portasplit/climate.py @@ -0,0 +1,30 @@ +import esphome.codegen as cg +from esphome.components import climate, uart +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@Fexiven"] +DEPENDENCIES = ["uart"] + +midea_portasplit_ns = cg.esphome_ns.namespace("midea_portasplit") +PortaSplitClimate = midea_portasplit_ns.class_( + "PortaSplitClimate", climate.Climate, uart.UARTDevice, cg.Component +) + +CONFIG_SCHEMA = ( + climate.climate_schema(PortaSplitClimate) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, baud_rate=9600, require_tx=True, require_rx=True +) + + +async def to_code(config: ConfigType) -> None: + var = await climate.new_climate(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/midea_portasplit/midea_portasplit.cpp b/esphome/components/midea_portasplit/midea_portasplit.cpp new file mode 100644 index 00000000000..a1f22226d9c --- /dev/null +++ b/esphome/components/midea_portasplit/midea_portasplit.cpp @@ -0,0 +1,337 @@ +#include "midea_portasplit.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::midea_portasplit { + +ESPHOME_LOG_TAG(TAG, "midea_portasplit"); + +static constexpr uint32_t FOLLOWUP_TIMEOUT_ID = 0; + +static constexpr uint32_t STATUS_INTERVAL_MS = 30000; +static constexpr uint32_t NETWORK_INTERVAL_MS = 10000; +static constexpr uint32_t FOLLOWUP_DELAY_MS = 1500; +// At 9600 baud bytes within a frame arrive about 1 ms apart. +static constexpr uint32_t RX_TIMEOUT_MS = 100; + +// Ten header bytes plus CRC8 and checksum. +static constexpr size_t FRAME_OVERHEAD = 12; +static constexpr size_t MAX_TX_PAYLOAD = 24; + +static constexpr float MIN_TARGET_TEMP = 16.0f; +static constexpr float MAX_TARGET_TEMP = 30.0f; + +// The AC expects its Wi-Fi module to answer the 0x63 keepalive poll and to send periodic 0x0D network status +// frames. These frames use the XOR sync byte (0x1E ^ 0xAC = 0xB2). The AC does not use the IP address; the +// fixed frame below was captured from the original module. +static constexpr uint8_t MODULE_FRAME[31] PROGMEM = {0xAA, 0x1E, 0xAC, 0xB2, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, + 0x01, 0x04, 0xC0, 0xA8, 0xB2, 0x72, 0x00, 0x00, 0x00, 0x01, 0x01, + 0x01, 0x28, 0x09, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00}; +static constexpr uint8_t STATUS_QUERY[12] PROGMEM = {0x41, 0x21, 0x00, 0xFF, 0x03, 0xFF, + 0x00, 0x02, 0x00, 0x00, 0x00, 0x00}; +// Same capability queries the original module sends at boot. +static constexpr uint8_t CAPS_0[] PROGMEM = {0xB5, 0x01, 0x00}; +static constexpr uint8_t CAPS_1[] PROGMEM = {0xB5, 0x01, 0x01, 0x01}; + +// Two's complement of the sum of all bytes after the 0xAA start marker. +static uint8_t checksum(const uint8_t *frame, size_t len) { return (uint8_t) -sum_bytes(frame + 1, len - 1); } + +static uint8_t climate_mode_to_midea(climate::ClimateMode mode) { + switch (mode) { + case climate::CLIMATE_MODE_COOL: + return 2; + case climate::CLIMATE_MODE_DRY: + return 3; + case climate::CLIMATE_MODE_HEAT: + return 4; + case climate::CLIMATE_MODE_FAN_ONLY: + return 5; + case climate::CLIMATE_MODE_HEAT_COOL: + default: + return 1; + } +} + +static climate::ClimateMode midea_mode_to_climate(uint8_t mode) { + switch (mode) { + case 2: + return climate::CLIMATE_MODE_COOL; + case 3: + return climate::CLIMATE_MODE_DRY; + case 4: + return climate::CLIMATE_MODE_HEAT; + case 5: + return climate::CLIMATE_MODE_FAN_ONLY; + case 1: + default: + return climate::CLIMATE_MODE_HEAT_COOL; + } +} + +static uint8_t fan_mode_to_midea(climate::ClimateFanMode mode) { + switch (mode) { + case climate::CLIMATE_FAN_QUIET: + return 20; + case climate::CLIMATE_FAN_LOW: + return 40; + case climate::CLIMATE_FAN_MEDIUM: + return 60; + case climate::CLIMATE_FAN_HIGH: + return 80; + default: + return FAN_AUTO; + } +} + +// The AC reports its own fan steps (30 for low, 50 for medium), so classify by range. +static climate::ClimateFanMode midea_fan_to_climate(uint8_t fan) { + if (fan > 100) + return climate::CLIMATE_FAN_AUTO; + if (fan <= 30) + return climate::CLIMATE_FAN_QUIET; + if (fan <= 50) + return climate::CLIMATE_FAN_LOW; + if (fan <= 70) + return climate::CLIMATE_FAN_MEDIUM; + return climate::CLIMATE_FAN_HIGH; +} + +void PortaSplitClimate::setup() { + // The status interval starts with a random offset of up to 5 s, which also serves as the boot query. + this->set_timeout(10000, [this]() { this->send_capability_queries_(); }); + this->set_interval(STATUS_INTERVAL_MS, [this]() { this->send_status_query_(); }); + this->set_interval(NETWORK_INTERVAL_MS, [this]() { this->send_module_frame_(0x0D, this->net_counter_++, 0x08); }); +} + +void PortaSplitClimate::loop() { + const uint32_t now = App.get_loop_component_start_time(); + uint8_t buf[32]; + size_t n; + while ((n = std::min((size_t) this->available(), sizeof(buf))) != 0 && this->read_array(buf, n)) { + for (size_t i = 0; i < n; i++) + this->rx_feed_(buf[i]); + this->rx_last_byte_ = now; + } + // The line went quiet mid-frame; drop it so its bytes cannot swallow the next one. + if (this->rx_len_ != 0 && now - this->rx_last_byte_ > RX_TIMEOUT_MS) + this->rx_len_ = 0; +} + +void PortaSplitClimate::dump_config() { + ESP_LOGCONFIG(TAG, "Midea PortaSplit:"); + this->dump_traits_(TAG); +} + +climate::ClimateTraits PortaSplitClimate::traits() { + auto traits = climate::ClimateTraits(); + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_visual_min_temperature(MIN_TARGET_TEMP); + traits.set_visual_max_temperature(MAX_TARGET_TEMP); + traits.set_visual_temperature_step(0.5f); + traits.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + }); + traits.set_supported_fan_modes({ + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + traits.set_supported_swing_modes({ + climate::CLIMATE_SWING_OFF, + climate::CLIMATE_SWING_VERTICAL, + }); + traits.set_supported_presets({ + climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_BOOST, + climate::CLIMATE_PRESET_ECO, + climate::CLIMATE_PRESET_AWAY, // Frost protection (8 degC heating) + }); + return traits; +} + +void PortaSplitClimate::control(const climate::ClimateCall &call) { + if (call.get_mode().has_value()) { + const climate::ClimateMode mode = *call.get_mode(); + this->st_power_ = mode != climate::CLIMATE_MODE_OFF; + if (this->st_power_) + this->st_mode_ = climate_mode_to_midea(mode); + // Frost protection only runs while heating. + if (mode != climate::CLIMATE_MODE_HEAT) + this->st_frost_ = false; + } + if (call.get_target_temperature().has_value()) { + const float target = clamp(*call.get_target_temperature(), MIN_TARGET_TEMP, MAX_TARGET_TEMP); + this->st_target_half_ = (uint8_t) lroundf(target * 2.0f); + } + if (call.get_fan_mode().has_value()) + this->st_fan_ = fan_mode_to_midea(*call.get_fan_mode()); + if (call.get_swing_mode().has_value()) + this->st_swing_ = *call.get_swing_mode() == climate::CLIMATE_SWING_VERTICAL; + if (call.get_preset().has_value()) { + const climate::ClimatePreset preset = *call.get_preset(); + this->st_turbo_ = preset == climate::CLIMATE_PRESET_BOOST; + this->st_sleep_ = preset == climate::CLIMATE_PRESET_SLEEP; + this->st_eco_ = preset == climate::CLIMATE_PRESET_ECO; + this->st_frost_ = preset == climate::CLIMATE_PRESET_AWAY; + if (this->st_frost_) { + this->st_mode_ = climate_mode_to_midea(climate::CLIMATE_MODE_HEAT); + this->st_power_ = true; + } + } + this->send_set_command_(); +} + +void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len) { + uint8_t f[FRAME_OVERHEAD + MAX_TX_PAYLOAD] = {0}; + const size_t total = FRAME_OVERHEAD + len; + f[0] = 0xAA; + f[1] = (uint8_t) (total - 1); + f[2] = 0xAC; + // f[3] is the sync byte and must stay 0x00 on the PortaSplit; f[4..7] are reserved. + f[8] = proto; + f[9] = type; + progmem_memcpy(&f[10], payload, len); + f[10 + len] = crc8(&f[10], (uint8_t) len); + f[11 + len] = checksum(f, 11 + len); + this->write_array(f, total); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(sizeof(f))]; + ESP_LOGV(TAG, "TX: %s", format_hex_pretty_to(hex_buf, f, total)); +#endif +} + +void PortaSplitClimate::send_set_command_() { + uint8_t p[MAX_TX_PAYLOAD] = {0}; + const uint8_t whole = this->st_target_half_ >> 1; + const bool half = this->st_target_half_ & 1; + p[0] = 0x40; + p[1] = 0x42; // 0x02 is mandatory, 0x40 enables the beeper + if (this->st_power_) + p[1] |= 0x01; + p[2] = (uint8_t) ((this->st_mode_ << 5) | (half ? 0x10 : 0x00) | ((whole - 16) & 0x0F)); + p[3] = this->st_fan_; + p[4] = 0x7F; + p[5] = 0x7F; + p[6] = 0xF0; + p[7] = this->st_swing_ ? 0x3C : 0x30; + p[8] = this->st_turbo_ ? 0x20 : 0x00; + p[9] = (uint8_t) ((this->st_ion_ ? 0x20 : 0x00) | (this->st_eco_ ? 0x80 : 0x00)); + p[10] = this->st_sleep_ ? 0x01 : 0x00; + p[18] = (uint8_t) ((whole - 12) & 0x1F); + p[19] = 0x80; + p[21] = 0x0A; + p[22] = this->st_frost_ ? 0x80 : 0x00; + p[23] = this->msg_id_++; + this->send_frame_(0x02, 0x02, p, sizeof(p)); + // Read back the state the AC actually applied. + this->set_timeout(FOLLOWUP_TIMEOUT_ID, FOLLOWUP_DELAY_MS, [this]() { this->send_status_query_(); }); +} + +void PortaSplitClimate::send_status_query_() { + uint8_t p[sizeof(STATUS_QUERY)]; + progmem_memcpy(p, STATUS_QUERY, sizeof(p)); + p[11] = this->msg_id_++; + this->send_frame_(0x00, 0x03, p, sizeof(p)); +} + +void PortaSplitClimate::send_capability_queries_() { + this->send_frame_(0x03, 0x03, CAPS_0, sizeof(CAPS_0)); + this->set_timeout(1000, [this]() { this->send_frame_(0x03, 0x03, CAPS_1, sizeof(CAPS_1)); }); +} + +void PortaSplitClimate::send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags) { + uint8_t f[sizeof(MODULE_FRAME)]; + progmem_memcpy(f, MODULE_FRAME, sizeof(f)); + f[6] = counter; + f[9] = type; + f[25] = wifi_flags; + f[30] = checksum(f, 30); + this->write_array(f, sizeof(f)); +} + +void PortaSplitClimate::rx_feed_(uint8_t b) { + if (this->rx_len_ == 0 && b != 0xAA) + return; + this->rx_[this->rx_len_++] = b; + if (this->rx_len_ < 3) + return; + const size_t total = (size_t) this->rx_[1] + 1; + // Every frame carries the appliance type 0xAC; anything else is a false start. + if (this->rx_len_ == 3 && (total < FRAME_OVERHEAD || total > MAX_FRAME || this->rx_[2] != 0xAC)) { + this->rx_len_ = 0; + return; + } + if (this->rx_len_ < total) + return; + this->rx_len_ = 0; + if (checksum(this->rx_.data(), total - 1) != this->rx_[total - 1]) { + ESP_LOGW(TAG, "RX checksum mismatch, dropping frame"); + return; + } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MAX_FRAME)]; + ESP_LOGV(TAG, "RX: %s", format_hex_pretty_to(hex_buf, this->rx_.data(), total)); +#endif + this->handle_frame_(this->rx_.data(), total); +} + +void PortaSplitClimate::handle_frame_(const uint8_t *f, size_t len) { + if (f[9] == 0x63) { + this->send_module_frame_(0x63, 0, 0x00); + } else if (f[9] == 0x05 && f[10] == 0xB5) { + // The original module acknowledges telemetry pushes by echoing them back. + this->write_array(f, len); + } else if (f[10] == 0xC0) { + this->handle_c0_(f, len); + } +} + +void PortaSplitClimate::handle_c0_(const uint8_t *f, size_t len) { + if (len < 26) + return; + const uint8_t mode = (f[12] >> 5) & 0x07; + const uint8_t target_half = (uint8_t) ((((f[12] & 0x0F) + 16) << 1) | ((f[12] & 0x10) ? 1 : 0)); + const climate::ClimateFanMode fan = midea_fan_to_climate(f[13]); + + this->st_power_ = f[11] & 0x01; + if (mode >= 1 && mode <= 5) + this->st_mode_ = mode; + this->st_target_half_ = target_half; + this->st_fan_ = fan_mode_to_midea(fan); + this->st_swing_ = (f[17] & 0x0F) == 0x0C; + this->st_turbo_ = f[18] & 0x20; + this->st_ion_ = f[19] & 0x20; + this->st_eco_ = f[19] & 0x10; // Set command uses bit 7, readback uses bit 4. + this->st_sleep_ = f[20] & 0x01; + this->st_frost_ = len > 31 && (f[31] & 0x80); + + this->mode = this->st_power_ ? midea_mode_to_climate(this->st_mode_) : climate::CLIMATE_MODE_OFF; + this->target_temperature = target_half / 2.0f; + // 0x00 and 0xFF mean the indoor probe gave no reading. + const uint8_t raw_temp = f[21]; + this->current_temperature = (raw_temp == 0x00 || raw_temp == 0xFF) ? NAN : ((float) raw_temp - 50.0f) / 2.0f; + this->fan_mode = fan; + this->swing_mode = this->st_swing_ ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + if (this->st_frost_) { + this->preset = climate::CLIMATE_PRESET_AWAY; + } else if (this->st_turbo_) { + this->preset = climate::CLIMATE_PRESET_BOOST; + } else if (this->st_sleep_) { + this->preset = climate::CLIMATE_PRESET_SLEEP; + } else if (this->st_eco_) { + this->preset = climate::CLIMATE_PRESET_ECO; + } else { + this->preset = climate::CLIMATE_PRESET_NONE; + } + this->publish_state(); +} + +} // namespace esphome::midea_portasplit diff --git a/esphome/components/midea_portasplit/midea_portasplit.h b/esphome/components/midea_portasplit/midea_portasplit.h new file mode 100644 index 00000000000..22e71a42793 --- /dev/null +++ b/esphome/components/midea_portasplit/midea_portasplit.h @@ -0,0 +1,63 @@ +#pragma once + +// Midea PortaSplit climate component. +// +// The PortaSplit uses sync byte 0x00 for climate frames and silently drops the +// XOR sync byte (LEN ^ 0xAC) frames that the `midea` component sends, which is +// why the stock `midea` platform cannot drive this unit. +// +// Frame: AA | LEN | AC | SYNC | 00 00 00 00 | PROTO | TYPE | PAYLOAD | CRC8 | CS + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/climate/climate.h" + +#include + +namespace esphome::midea_portasplit { + +static constexpr uint8_t FAN_AUTO = 102; + +class PortaSplitClimate : public climate::Climate, public uart::UARTDevice, public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + + climate::ClimateTraits traits() override; + void control(const climate::ClimateCall &call) override; + + protected: + static constexpr size_t MAX_FRAME = 128; + + // The payload may live in PROGMEM. + void send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len); + void send_set_command_(); + void send_status_query_(); + void send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags); + void send_capability_queries_(); + + void rx_feed_(uint8_t b); + void handle_frame_(const uint8_t *f, size_t len); + void handle_c0_(const uint8_t *f, size_t len); + + uint32_t rx_last_byte_{0}; + // Last known device state, mirrored from C0 so a set command does not clobber it. + uint8_t st_target_half_{48}; // Target temperature in half degrees + uint8_t st_mode_{1}; + uint8_t st_fan_{FAN_AUTO}; + bool st_power_ : 1 {false}; + bool st_swing_ : 1 {false}; + bool st_turbo_ : 1 {false}; + bool st_ion_ : 1 {false}; + bool st_sleep_ : 1 {false}; + bool st_eco_ : 1 {false}; + bool st_frost_ : 1 {false}; + + uint8_t msg_id_{1}; + uint8_t net_counter_{0}; + uint8_t rx_len_{0}; + std::array rx_{}; +}; + +} // namespace esphome::midea_portasplit diff --git a/tests/components/midea_portasplit/common.yaml b/tests/components/midea_portasplit/common.yaml new file mode 100644 index 00000000000..8e5c9519479 --- /dev/null +++ b/tests/components/midea_portasplit/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: midea_portasplit + id: midea_portasplit_unit + name: PortaSplit diff --git a/tests/components/midea_portasplit/test.esp32-idf.yaml b/tests/components/midea_portasplit/test.esp32-idf.yaml new file mode 100644 index 00000000000..844ec272d2c --- /dev/null +++ b/tests/components/midea_portasplit/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea_portasplit: !include common.yaml diff --git a/tests/components/midea_portasplit/test.esp8266-ard.yaml b/tests/components/midea_portasplit/test.esp8266-ard.yaml new file mode 100644 index 00000000000..a84a5a57722 --- /dev/null +++ b/tests/components/midea_portasplit/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + midea_portasplit: !include common.yaml From 7afc2768f84fb68bdd44a9ab2991030fb782fe8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 08:45:51 -1000 Subject: [PATCH 372/651] [esp8266] Check that every component's saved values fit together (#20686) --- esphome/components/esp8266/__init__.py | 11 ++ esphome/components/esp8266/const.py | 87 +++++++++++++- esphome/components/esp8266/crash_handler.cpp | 12 +- esphome/components/esp8266/preferences.cpp | 30 +++-- esphome/components/esp8266/rtc_layout.h | 18 +++ esphome/core/defines.h | 3 + esphome/preferences.py | 14 +++ .../components/esp8266/test_preferences.py | 107 ++++++++++++++++++ 8 files changed, 257 insertions(+), 25 deletions(-) create mode 100644 esphome/components/esp8266/rtc_layout.h create mode 100644 tests/unit_tests/components/esp8266/test_preferences.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d95cbd239b5..27c6fb1958d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -53,10 +53,14 @@ from .const import ( KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, + PREFERENCES_RTC_EBOOT_WORDS, + PREFERENCES_RTC_NORMAL_WORDS, THROW_STUBS_HEADER, + check_preferences_fit, enable_serial, enable_serial1, esp8266_ns, + preferences_flash_words, ) from .gpio import PinInitialState, add_pin_initial_states_array @@ -467,6 +471,12 @@ async def to_code(config: ConfigType) -> None: # Other projects like Tasmota & ESPEasy also use this cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + cg.add_define("ESP8266_PREFERENCES_RTC_NORMAL_WORDS", PREFERENCES_RTC_NORMAL_WORDS) + cg.add_define("ESP8266_PREFERENCES_RTC_EBOOT_WORDS", PREFERENCES_RTC_EBOOT_WORDS) + cg.add_define( + "ESP8266_PREFERENCES_FLASH_WORDS", + preferences_flash_words(config[CONF_RESTORE_FROM_FLASH]), + ) if config[CONF_RESTORE_FROM_FLASH]: cg.add_define("USE_ESP8266_PREFERENCES_FLASH") @@ -528,6 +538,7 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(add_pin_initial_states_array) CORE.add_job(finalize_waveform_config) CORE.add_job(finalize_serial_config) + CORE.add_job(check_preferences_fit) @coroutine_with_priority(CoroPriority.WORKAROUNDS) diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index fb50b6a5ca2..177bf540473 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -1,9 +1,13 @@ +import logging + import esphome.codegen as cg # Re-exported from the shared definition; here it indexes the BOARDS # metadata dicts, whose entries in boards.py spell the literal. from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import -from esphome.core import CORE +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority + +_LOGGER = logging.getLogger(__name__) KEY_ESP8266 = "esp8266" KEY_BOARD = "board" @@ -24,6 +28,87 @@ KEY_LDSCRIPT = "ldscript" # Crash-decode tools, resolved once per run by __init__ KEY_DECODE_TOOLS = "decode_tools" +# Saved-settings space in 4-byte words; preferences.cpp gets these as defines. A value cannot span the two +# RTC regions, and the flash store is smaller when only explicit flash preferences use it. +PREFERENCES_RTC_NORMAL_WORDS = 78 +PREFERENCES_RTC_EBOOT_WORDS = 32 +PREFERENCES_RTC_WORDS = PREFERENCES_RTC_NORMAL_WORDS + PREFERENCES_RTC_EBOOT_WORDS +PREFERENCES_FLASH_WORDS = 128 +PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE = 64 +KEY_PREFERENCES = "preferences" + + +def preferences_flash_words(restore_from_flash: bool) -> int: + """Size of the flash store for this configuration.""" + return ( + PREFERENCES_FLASH_WORDS + if restore_from_flash + else PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE + ) + + +def preference_words(value_bytes: int) -> int: + """Words a saved value takes, checksum word included (rtc_pref_bytes_to_words plus one).""" + return (value_bytes + 3) // 4 + 1 + + +def reserve_preferences( + count: int, value_bytes: int, in_flash: bool | None = None +) -> None: + """Record saved values a component creates; check_preferences_fit() adds them all up. + + in_flash is the value's own storage, as passed to make_preference(); None is the two-argument + form, which follows restore_from_flash. + """ + if in_flash is None: + in_flash = CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH] + reservations = CORE.data.setdefault(KEY_ESP8266, {}).setdefault(KEY_PREFERENCES, []) + reservations.extend([(preference_words(value_bytes), in_flash)] * count) + + +def _rtc_values_can_split(values: list[int]) -> bool: + """Whether some order fills the two RTC regions without a value spanning them.""" + total = sum(values) + if total <= PREFERENCES_RTC_NORMAL_WORDS: + return True + # Bit i of reachable is set when some subset of the values sums to i + reachable = 1 + for words in values: + reachable |= reachable << words + lowest = max(0, total - PREFERENCES_RTC_EBOOT_WORDS) + highest = min(total, PREFERENCES_RTC_NORMAL_WORDS) + return any(reachable >> normal & 1 for normal in range(lowest, highest + 1)) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def check_preferences_fit() -> None: + """Fail the build when the reserved saved values cannot fit the space preferences.cpp has.""" + flash_store = preferences_flash_words( + CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH] + ) + reservations = CORE.data[KEY_ESP8266].get(KEY_PREFERENCES, []) + flash_words = sum(w for w, in_flash in reservations if in_flash) + rtc_values = [w for w, in_flash in reservations if not in_flash] + if flash_words > flash_store: + raise EsphomeError( + f"Saved values need {flash_words} words but the ESP8266 flash store has {flash_store}; " + "use fewer or smaller saved values" + ) + if not _rtc_values_can_split(rtc_values): + raise EsphomeError( + f"Saved values need {sum(rtc_values)} words but ESP8266 RTC memory has {PREFERENCES_RTC_WORDS} in " + f"regions of {PREFERENCES_RTC_NORMAL_WORDS} and {PREFERENCES_RTC_EBOOT_WORDS} that a value cannot " + "span; use fewer or smaller saved values, or store them in flash" + ) + if sum(rtc_values) > PREFERENCES_RTC_NORMAL_WORDS: + _LOGGER.warning( + "Saved values reserve %d of %d words of ESP8266 RTC memory; the fit depends on setup order, and " + "saved settings that do not reserve are not counted", + sum(rtc_values), + PREFERENCES_RTC_WORDS, + ) + + # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 1ba96eaebb2..8513d213155 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -4,6 +4,7 @@ #ifdef USE_ESP8266_CRASH_HANDLER #include "crash_handler.h" +#include "rtc_layout.h" #include "esphome/core/log.h" #include @@ -46,11 +47,8 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t val) { // Recover the actual code address from a windowed-ABI return address on the stack. static inline uint32_t IRAM_ATTR recover_code_addr(uint32_t val) { return (val & XTENSA_ADDR_MASK) | XTENSA_CODE_BASE; } -// RTC user memory layout for crash backtrace data. -// User-accessible RTC memory: blocks 64-191 (each block = 4 bytes). -// We use blocks 174-191 (last 18 blocks, 72 bytes) to minimize conflicts. +// Crash data lives in the last esphome::esp8266::RTC_CRASH_WORDS of user RTC memory (rtc_layout.h). // Store 16 raw candidates, filter to real return addresses at log time. -static constexpr uint8_t RTC_CRASH_BASE = 174; static constexpr size_t MAX_BACKTRACE = 16; // Magic word packs sentinel, version, and count into one uint32_t: @@ -78,7 +76,7 @@ struct RtcCrashData { uint32_t backtrace[MAX_BACKTRACE]; uint32_t epc1; // Fault PC, used to filter duplicates }; -static_assert(sizeof(RtcCrashData) == 72, "RtcCrashData must fit in 18 RTC blocks"); +static_assert(sizeof(RtcCrashData) == esphome::esp8266::RTC_CRASH_WORDS * 4, "RtcCrashData must fill its RTC words"); namespace esphome::esp8266 { @@ -129,7 +127,7 @@ static const LogString *get_reset_reason(uint32_t reason) { // Returns the number of valid backtrace entries (0 if no data found). static uint8_t read_rtc_backtrace(uint32_t *backtrace, size_t max_entries) { RtcCrashData rtc_data; - if (!system_rtc_mem_read(RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) + if (!system_rtc_mem_read(esphome::esp8266::RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) return 0; uint32_t magic = rtc_data.magic; if ((magic & CRASH_SENTINEL_MASK) != CRASH_SENTINEL || (magic & CRASH_VERSION_MASK) != CRASH_VERSION) @@ -233,7 +231,7 @@ extern "C" void IRAM_ATTR custom_crash_callback(struct rst_info *rst_info, uint3 data.epc1 = epc1; data.magic = CRASH_SENTINEL | CRASH_VERSION | count; - system_rtc_mem_write(RTC_CRASH_BASE, &data, sizeof(data)); + system_rtc_mem_write(esphome::esp8266::RTC_CRASH_BASE, &data, sizeof(data)); } #endif // USE_ESP8266_CRASH_HANDLER diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index b731187f9c3..3c1e89673ea 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -6,6 +6,7 @@ extern "C" { } #include "preferences.h" +#include "rtc_layout.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences_rtc.h" @@ -17,31 +18,26 @@ namespace esphome::esp8266 { ESPHOME_LOG_TAG(TAG, "preferences"); static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; -static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = RTC_USER_MEM_WORDS; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; -// RTC memory layout: +// RTC memory layout (sizes come from const.py through codegen): // - Eboot region: RTC words 0-31 (reserved, mapped from preference offset 78-109) // - Normal region: RTC words 32-109 (mapped from preference offset 0-77) // - Crash handler: RTC words 110-127 (reserved for crash_handler.cpp backtrace data) -static constexpr uint32_t RTC_EBOOT_REGION_WORDS = 32; // Words 0-31 reserved for eboot -static constexpr uint32_t RTC_NORMAL_REGION_WORDS = 78; // Words 32-109 for normal prefs +static constexpr uint32_t RTC_EBOOT_REGION_WORDS = ESP8266_PREFERENCES_RTC_EBOOT_WORDS; +static constexpr uint32_t RTC_NORMAL_REGION_WORDS = ESP8266_PREFERENCES_RTC_NORMAL_WORDS; static constexpr uint32_t PREF_TOTAL_WORDS = RTC_EBOOT_REGION_WORDS + RTC_NORMAL_REGION_WORDS; // 110 +static_assert(PREF_TOTAL_WORDS <= RTC_CRASH_BASE - RTC_SYSTEM_USER_BASE, + "preference regions overlap the crash handler's RTC words"); // Maximum preference size in words (limited by uint8_t length_words field) static constexpr uint32_t MAX_PREFERENCE_WORDS = 255; #define ESP_RTC_USER_MEM ((uint32_t *) ESP_RTC_USER_MEM_START) -// Flash storage size depends on esp8266 -> restore_from_flash YAML option (default: false). -// When enabled (USE_ESP8266_PREFERENCES_FLASH), all preferences default to flash and need -// 128 words (512 bytes). When disabled, only explicit flash prefs use this storage so -// 64 words (256 bytes) suffices since most preferences go to RTC memory instead. -#ifdef USE_ESP8266_PREFERENCES_FLASH -static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; -#else -static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; -#endif +// Smaller without restore_from_flash, when only explicit flash preferences live here +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = ESP8266_PREFERENCES_FLASH_WORDS; static uint32_t s_flash_storage[ESP8266_FLASH_STORAGE_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -60,7 +56,7 @@ static inline bool esp_rtc_user_mem_write(uint32_t index, uint32_t value) { if (index >= ESP_RTC_USER_MEM_SIZE_WORDS) { return false; } - if (index < 32 && s_prevent_write) { + if (index < RTC_EBOOT_REGION_WORDS && s_prevent_write) { return false; } @@ -123,7 +119,7 @@ static bool load_from_rtc(size_t offset, uint32_t *data, size_t len) { // Maximum buffer for any single preference - bounded by storage sizes. // Flash prefs: bounded by ESP8266_FLASH_STORAGE_SIZE (128 or 64 words). -// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (96) - a single pref can't span both RTC regions. +// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (78) - a single pref can't span both RTC regions. static constexpr size_t PREF_MAX_BUFFER_WORDS = ESP8266_FLASH_STORAGE_SIZE > RTC_NORMAL_REGION_WORDS ? ESP8266_FLASH_STORAGE_SIZE : RTC_NORMAL_REGION_WORDS; @@ -180,8 +176,8 @@ ESPPreferenceObject ESP8266Preferences::make_preference(size_t length, uint32_t } else { uint32_t start = this->current_offset; bool in_normal = start < RTC_NORMAL_REGION_WORDS; - // Normal: offset 0-95 maps to RTC offset 32-127 - // Eboot: offset 96-127 maps to RTC offset 0-31 + // Normal: offset 0-77 maps to RTC offset 32-109 + // Eboot: offset 78-109 maps to RTC offset 0-31 if (in_normal && start + total_words > RTC_NORMAL_REGION_WORDS) { // start is in normal but end is not -> switch to Eboot this->current_offset = start = RTC_NORMAL_REGION_WORDS; diff --git a/esphome/components/esp8266/rtc_layout.h b/esphome/components/esp8266/rtc_layout.h new file mode 100644 index 00000000000..0320d67a198 --- /dev/null +++ b/esphome/components/esp8266/rtc_layout.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include + +namespace esphome::esp8266 { + +// User RTC memory is system blocks 64-191, one 4-byte word each. preferences.cpp owns the first +// words, crash_handler.cpp the last; const.py hands preferences.cpp its region sizes. +static constexpr uint32_t RTC_SYSTEM_USER_BASE = 64; +static constexpr uint32_t RTC_USER_MEM_WORDS = 128; +static constexpr uint32_t RTC_CRASH_WORDS = 18; +static constexpr uint32_t RTC_CRASH_BASE = RTC_SYSTEM_USER_BASE + RTC_USER_MEM_WORDS - RTC_CRASH_WORDS; + +} // namespace esphome::esp8266 + +#endif // USE_ESP8266 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3aa4c8e700b..50d8285cc1c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -607,6 +607,9 @@ #define USE_ESP8266_LOGGER_SERIAL #define USE_ESP8266_LOGGER_SERIAL1 #define USE_ESP8266_PREFERENCES_FLASH +#define ESP8266_PREFERENCES_FLASH_WORDS 128 +#define ESP8266_PREFERENCES_RTC_EBOOT_WORDS 32 +#define ESP8266_PREFERENCES_RTC_NORMAL_WORDS 78 #define USE_ESP8266_UART_SERIAL #define USE_ESP8266_UART_SERIAL1 #define USE_HTTP_REQUEST_ESP8266_HTTPS diff --git a/esphome/preferences.py b/esphome/preferences.py index 6e7e4eda33d..c9a465d6252 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -102,3 +102,17 @@ def is_in_flash(value: str) -> bool: if not in_flash: request_rtc_storage() return in_flash + + +def reserve_preferences( + count: int, value_bytes: int, in_flash: bool | None = None +) -> None: + """Reserve saved values from to_code so a platform with a small store can check they all fit. + + in_flash is the value's own storage (is_in_flash(config[CONF_STORAGE])); leave it None for a + component that calls the two-argument make_preference() and takes the platform default. + """ + if CORE.is_esp8266: + from esphome.components.esp8266.const import reserve_preferences as reserve + + reserve(count, value_bytes, in_flash) diff --git a/tests/unit_tests/components/esp8266/test_preferences.py b/tests/unit_tests/components/esp8266/test_preferences.py new file mode 100644 index 00000000000..2111e068245 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_preferences.py @@ -0,0 +1,107 @@ +"""The config-time fit check must agree with make_preference() in preferences.cpp.""" + +import asyncio + +import pytest + +from esphome import preferences +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + check_preferences_fit, + preference_words, + reserve_preferences, +) +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def esp8266_core() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp8266"} + CORE.data[KEY_ESP8266] = {} + CORE.config = {KEY_ESP8266: {"restore_from_flash": False}} + + +def _check() -> None: + asyncio.run(check_preferences_fit()) + + +@pytest.mark.parametrize( + ("value_bytes", "words"), [(0, 1), (1, 2), (4, 2), (5, 3), (94, 25)] +) +def test_preference_words_rounds_up_and_adds_the_checksum( + value_bytes: int, words: int +) -> None: + assert preference_words(value_bytes) == words + + +@pytest.mark.parametrize( + ("restore_from_flash", "in_flash", "fitting", "match"), + [ + # 25 words each: 3 in the 78-word region and 1 in the 32-word one + (False, False, 4, "RTC memory has 110"), + (False, None, 4, "RTC memory has 110"), + # an explicit RTC value stays in RTC whatever restore_from_flash says + (True, False, 4, "RTC memory has 110"), + # 125 of 128 flash words, by the two-argument default or explicitly + (True, None, 5, "flash store has 128"), + (True, True, 5, "flash store has 128"), + # explicit flash values without restore_from_flash use the 64-word store + (False, True, 2, "flash store has 64"), + ], +) +def test_reservations_add_up_against_the_store_they_land_in( + restore_from_flash: bool, in_flash: bool | None, fitting: int, match: str +) -> None: + CORE.config[KEY_ESP8266]["restore_from_flash"] = restore_from_flash + reserve_preferences(fitting - 1, 94, in_flash) # one component + reserve_preferences(1, 94, in_flash) # another component + _check() + reserve_preferences(1, 94, in_flash) + with pytest.raises(EsphomeError, match=match): + _check() + + +def test_values_that_cannot_split_between_the_regions_fail() -> None: + # 5 x 20 words is 100 of 110, but the normal region takes 3 and the eboot region 1 + reserve_preferences(5, 76) + with pytest.raises(EsphomeError, match="cannot span"): + _check() + + +def test_a_value_larger_than_either_region_fails() -> None: + reserve_preferences(1, 400) + with pytest.raises(EsphomeError, match="cannot span"): + _check() + + +def test_a_split_that_works_in_some_order_passes_with_a_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + # 78 + 32: one value fills each region exactly + reserve_preferences(1, 308) + reserve_preferences(1, 124) + _check() + assert "depends on setup order" in caplog.text + + +def test_nothing_reserved_passes_quietly(caplog: pytest.LogCaptureFixture) -> None: + _check() + assert caplog.text == "" + + +def test_the_check_runs_as_a_scheduled_job() -> None: + reserve_preferences(3, 400) + CORE.add_job(check_preferences_fit) + with pytest.raises(EsphomeError, match="cannot span"): + CORE.flush_tasks() + + +def test_components_reserve_through_esphome_preferences() -> None: + preferences.reserve_preferences(2, 94, in_flash=True) + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(25, True), (25, True)] + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP8266] = {} + preferences.reserve_preferences(2, 94) + assert KEY_PREFERENCES not in CORE.data[KEY_ESP8266] From 163b09a473def4aea19488a7363564bf32a812c4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 14:47:25 -0400 Subject: [PATCH 373/651] [microphone] Speed up MicrophoneSource audio conversion (#20755) --- .../microphone/microphone_source.cpp | 73 ++++++++----------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/esphome/components/microphone/microphone_source.cpp b/esphome/components/microphone/microphone_source.cpp index 288e9f8c8e5..8ef091d3a40 100644 --- a/esphome/components/microphone/microphone_source.cpp +++ b/esphome/components/microphone/microphone_source.cpp @@ -1,9 +1,20 @@ #include "microphone_source.h" +#include + +#include // esp-audio-libs +#include // esp-audio-libs + namespace esphome::microphone { -static const int32_t Q25_MAX_VALUE = (1 << 25) - 1; -static const int32_t Q25_MIN_VALUE = ~Q25_MAX_VALUE; +// Builds the exact LinearGain for an integer factor without the float dB conversion: the factor is +// mantissa / 2^31 * 2^exponent, with the exponent set to the factor's bit width. +static esp_audio_libs::gain::LinearGain factor_to_linear_gain(uint32_t factor) { + esp_audio_libs::gain::LinearGain gain; + gain.exponent = 32 - __builtin_clz(factor); + gain.mantissa_q31 = static_cast(factor << (31 - gain.exponent)); + return gain; +} audio::AudioStreamInfo MicrophoneSource::get_audio_stream_info() { return audio::AudioStreamInfo(this->bits_per_sample_, this->channels_.count(), @@ -26,50 +37,26 @@ void MicrophoneSource::stop() { } void MicrophoneSource::process_audio_(const std::vector &data, std::vector &filtered_data) { - // - Bit depth conversions are obtained by truncating bits or padding with zeros - no dithering is applied. - // - In the comments, Qxx refers to a fixed point number with xx bits of precision for representing fractional values. - // For example, audio with a bit depth of 16 can store a sample in a int16, which can be considered a Q15 number. - // - All samples are converted to Q25 before applying the gain factor - this results in a small precision loss for - // data with 32 bits per sample. Since the maximum gain factor is 64 = (1<<6), this ensures that applying the gain - // will never overflow a 32 bit signed integer. This still retains more bit depth than what is audibly noticeable. - // - Loops for reading/writing data buffers are unrolled, assuming little endian, for a small performance increase. + const audio::AudioStreamInfo source_info = this->mic_->get_audio_stream_info(); + const uint32_t total_frames = source_info.bytes_to_frames(data.size()); + const uint8_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8; - const size_t source_bytes_per_sample = this->mic_->get_audio_stream_info().samples_to_bytes(1); - const uint32_t source_channels = this->mic_->get_audio_stream_info().get_channels(); - - const size_t source_bytes_per_frame = this->mic_->get_audio_stream_info().frames_to_bytes(1); - - const uint32_t total_frames = this->mic_->get_audio_stream_info().bytes_to_frames(data.size()); - const size_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8; - const size_t target_bytes_per_frame = target_bytes_per_sample * this->channels_.count(); - - filtered_data.resize(target_bytes_per_frame * total_frames); - - uint8_t *current_data = filtered_data.data(); - - for (uint32_t frame_index = 0; frame_index < total_frames; ++frame_index) { - for (uint32_t channel_index = 0; channel_index < source_channels; ++channel_index) { - if (this->channels_.test(channel_index)) { - // Channel's current sample is included in the target mask. Convert bits per sample, if necessary. - - const uint32_t sample_index = frame_index * source_bytes_per_frame + channel_index * source_bytes_per_sample; - - int32_t sample = audio::unpack_audio_sample_to_q31(&data[sample_index], source_bytes_per_sample); // Q31 - sample >>= 6; // Q31 -> Q25 - - // Apply gain using multiplication - sample *= this->gain_factor_; // Q25 - - // Clamp ``sample`` in case gain multiplication overflows 25 bits - sample = clamp(sample, Q25_MIN_VALUE, Q25_MAX_VALUE); // Q25 - - sample *= (1 << 6); // Q25 -> Q31 - - audio::pack_q31_as_audio_sample(sample, current_data, target_bytes_per_sample); - current_data = current_data + target_bytes_per_sample; - } + // Enabled channels in ascending order, which is their order in each output frame + std::array channel_map; + uint8_t target_channels = 0; + for (size_t channel = 0; channel < this->channels_.size(); ++channel) { + if (this->channels_.test(channel)) { + channel_map[target_channels++] = static_cast(channel); } } + + filtered_data.resize(static_cast(total_frames) * target_bytes_per_sample * target_channels); + + // Applies the gain at full precision before narrowing; samples saturate at full scale, and narrowing rounds + esp_audio_libs::pcm_convert::copy_frames_with_gain(data.data(), filtered_data.data(), source_info.samples_to_bytes(1), + source_info.get_channels(), target_bytes_per_sample, + channel_map.data(), target_channels, + factor_to_linear_gain(this->gain_factor_), total_frames); } } // namespace esphome::microphone From 3fa72e61c60b55a3ba2570edf2dd084baa9aa58f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 08:55:39 -1000 Subject: [PATCH 374/651] [mqtt] Build the mqtt triggers with build_trigger_callback (#20433) --- esphome/components/mqtt/__init__.py | 85 ++++++++-------- esphome/components/mqtt/mqtt_client.cpp | 96 ++++++++----------- esphome/components/mqtt/mqtt_client.h | 90 +++++++++-------- esphome/components/mqtt/mqtt_component.cpp | 8 -- esphome/components/mqtt/mqtt_component.h | 8 +- esphome/core/helpers.h | 35 ++++++- tests/components/core/test_callback.cpp | 92 ++++++++++++++++++ tests/components/mqtt/common-triggers.yaml | 25 +++++ .../mqtt/test-triggers.esp32-idf.yaml | 3 + 9 files changed, 284 insertions(+), 158 deletions(-) create mode 100644 tests/components/core/test_callback.cpp create mode 100644 tests/components/mqtt/common-triggers.yaml create mode 100644 tests/components/mqtt/test-triggers.esp32-idf.yaml diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 826d4e32ef2..fe426827ce6 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -53,7 +53,6 @@ from esphome.const import ( CONF_SUBSCRIBE_QOS, CONF_TOPIC, CONF_TOPIC_PREFIX, - CONF_TRIGGER_ID, CONF_USE_ABBREVIATIONS, CONF_USERNAME, CONF_WILL_MESSAGE, @@ -119,18 +118,6 @@ MQTTMessage = mqtt_ns.struct("MQTTMessage") MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason") MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component) MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action) -MQTTMessageTrigger = mqtt_ns.class_( - "MQTTMessageTrigger", automation.Trigger.template(cg.std_string), cg.Component -) -MQTTJsonMessageTrigger = mqtt_ns.class_( - "MQTTJsonMessageTrigger", automation.Trigger.template(cg.JsonObjectConst) -) -MQTTConnectTrigger = mqtt_ns.class_( - "MQTTConnectTrigger", automation.Trigger.template(cg.bool_) -) -MQTTDisconnectTrigger = mqtt_ns.class_( - "MQTTDisconnectTrigger", automation.Trigger.template(MQTTClientDisconnectReason) -) MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component) MQTTAlarmControlPanelComponent = mqtt_ns.class_( @@ -229,7 +216,6 @@ 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, }, @@ -295,21 +281,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REBOOT_TIMEOUT, default="15min" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_CONNECT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTConnectTrigger), - } - ), - cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - MQTTDisconnectTrigger - ), - } - ), + cv.Optional(CONF_ON_CONNECT): automation.validate_automation(), + cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(), cv.Optional(CONF_ON_MESSAGE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTMessageTrigger), cv.Required(CONF_TOPIC): cv.subscribe_topic, cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, cv.Optional(CONF_PAYLOAD): cv.string_strict, @@ -346,6 +321,18 @@ def exp_mqtt_message(config): ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CONNECT, "set_on_connect", [(cg.bool_, "session_present")] + ), + automation.CallbackAutomation( + CONF_ON_DISCONNECT, + "set_on_disconnect", + [(MQTTClientDisconnectReason, "reason")], + ), +) + + @coroutine_with_priority(CoroPriority.WEB) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -460,29 +447,37 @@ async def to_code(config): cg.add_define("USE_MQTT_IDF_ENQUEUE") # end esp-idf + # The client queues subscriptions until it connects, so they can be made at construction. for conf in config.get(CONF_ON_MESSAGE, []): - trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC]) - cg.add(trig.set_qos(conf[CONF_QOS])) - if CONF_PAYLOAD in conf: - cg.add(trig.set_payload(conf[CONF_PAYLOAD])) - await cg.register_component(trig, conf) - await automation.build_automation(trig, [(cg.std_string, "x")], conf) + callback = await automation.build_trigger_callback( + [(cg.std_string, "x")], + conf, + params=[(cg.std_string, "topic"), (cg.std_string, "payload")], + forward=["payload"], + # The length is compared first; the literal stays in flash on ESP8266. + when=automation.ApplyCall( + "StringRef(payload) == {}", + ((CONF_PAYLOAD, cg.std_string, automation.string_ref_literal),), + ), + ) + cg.add( + var.subscribe(cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS]) + ) for conf in config.get(CONF_ON_JSON_MESSAGE, []): - trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC], conf[CONF_QOS]) - await automation.build_automation(trig, [(cg.JsonObjectConst, "x")], conf) - - for conf in config.get(CONF_ON_CONNECT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.bool_, "session_present")], conf + callback = await automation.build_trigger_callback( + [(cg.JsonObjectConst, "x")], + conf, + params=[(cg.std_string, "topic"), (cg.JsonObject, "root")], + forward=["root"], + ) + cg.add( + var.subscribe_json( + cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS] + ) ) - for conf in config.get(CONF_ON_DISCONNECT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(MQTTClientDisconnectReason, "reason")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_publish_nan_as_none(config[CONF_PUBLISH_NAN_AS_NONE])) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 6e5fad3efc7..803f486333a 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -461,34 +461,18 @@ void MQTTClientComponent::resubscribe_subscriptions_() { } } -void MQTTClientComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) { - MQTTSubscription subscription{ - .topic = topic, +void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, + uint8_t qos) { + auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{ + .topic = std::move(topic), .qos = qos, - .callback = std::move(callback), .subscribed = false, + .boxed = boxed, + .removed = false, + .callback = callback, .resubscribe_timeout = 0, - }; + }); this->resubscribe_subscription_(&subscription); - this->subscriptions_.push_back(subscription); -} - -void MQTTClientComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) { - auto f = [callback](const std::string &topic, const std::string &payload) { - json::parse_json(payload, [topic, callback](JsonObject root) -> bool { - callback(topic, root); - return true; - }); - }; - MQTTSubscription subscription{ - .topic = topic, - .qos = qos, - .callback = f, - .subscribed = false, - .resubscribe_timeout = 0, - }; - this->resubscribe_subscription_(&subscription); - this->subscriptions_.push_back(subscription); } void MQTTClientComponent::unsubscribe(const std::string &topic) { @@ -502,10 +486,25 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) { this->status_momentary_warning(1000); } - auto it = subscriptions_.begin(); - while (it != subscriptions_.end()) { - if (it->topic == topic) { - it = subscriptions_.erase(it); + for (auto &subscription : this->subscriptions_) { + if (subscription.topic == topic) + subscription.removed = true; + } + // A callback may be running; the outermost dispatch erases them once it returns. + if (this->dispatching_) { + this->removed_while_dispatching_ = true; + } else { + this->erase_removed_subscriptions_(); + } +} + +void MQTTClientComponent::erase_removed_subscriptions_() { + auto it = this->subscriptions_.begin(); + while (it != this->subscriptions_.end()) { + if (it->removed) { + if (it->boxed) + it->callback.free_boxed(); + it = this->subscriptions_.erase(it); } else { ++it; } @@ -660,9 +659,21 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // in simple tests but will cause crashes with complex automations. this->defer([this, topic, payload]() { #endif - for (auto &subscription : this->subscriptions_) { - if (topic_match(topic.c_str(), subscription.topic.c_str())) - subscription.callback(topic, payload); + // Indexed with the count taken up front: a callback may subscribe, which can reallocate the + // vector, and the new entry waits for the next message. + // Restored rather than cleared, so only the outermost dispatch erases what was unsubscribed meanwhile. + const bool was_dispatching = this->dispatching_; + this->dispatching_ = true; + const size_t count = this->subscriptions_.size(); + for (size_t i = 0; i < count; i++) { + const auto &subscription = this->subscriptions_[i]; + if (!subscription.removed && topic_match(topic.c_str(), subscription.topic.c_str())) + subscription.callback.call(topic, payload); + } + this->dispatching_ = was_dispatching; + if (!was_dispatching && this->removed_while_dispatching_) { + this->removed_while_dispatching_ = false; + this->erase_removed_subscriptions_(); } #ifdef USE_ESP8266 }); @@ -762,29 +773,6 @@ void MQTTClientComponent::set_on_disconnect(mqtt_on_disconnect_callback_t &&call MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -// MQTTMessageTrigger -MQTTMessageTrigger::MQTTMessageTrigger(std::string topic) : topic_(std::move(topic)) {} -void MQTTMessageTrigger::setup() { - global_mqtt_client->subscribe( - this->topic_, - [this](const std::string &topic, const std::string &payload) { - if (this->payload_.has_value() && payload != *this->payload_) { - return; - } - - this->trigger(payload); - }, - this->qos_); -} -void MQTTMessageTrigger::dump_config() { - ESP_LOGCONFIG(TAG, - "MQTT Message Trigger:\n" - " Topic: '%s'\n" - " QoS: %u", - this->topic_.c_str(), this->qos_); -} -float MQTTMessageTrigger::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } - } // namespace esphome::mqtt #endif // USE_MQTT diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index ced9c84e100..30edb812975 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -31,19 +31,24 @@ namespace esphome::mqtt { using mqtt_on_connect_callback_t = std::function; using mqtt_on_disconnect_callback_t = std::function; -/** Callback for MQTT subscriptions. - * - * First parameter is the topic, the second one is the payload. - */ -using mqtt_callback_t = std::function; -using mqtt_json_callback_t = std::function; +/// Stored callback of an MQTT subscription: the topic, then the payload. +using mqtt_subscription_callback_t = Callback; + +// Remove before 2027.5.0 +using mqtt_callback_t ESPDEPRECATED("subscribe() takes any callable; pass the lambda directly. Removed in 2027.5.0", + "2026.11.0") = std::function; +using mqtt_json_callback_t ESPDEPRECATED( + "subscribe_json() takes any callable; pass the lambda directly. Removed in 2027.5.0", + "2026.11.0") = std::function; /// internal struct for MQTT subscriptions. struct MQTTSubscription { std::string topic; uint8_t qos; - mqtt_callback_t callback; bool subscribed; + bool boxed; // made by create_boxed(), freed on unsubscribe + bool removed; // unsubscribed while a callback ran, erased once dispatch returns + mqtt_subscription_callback_t callback; uint32_t resubscribe_timeout; }; @@ -165,12 +170,21 @@ class MQTTClientComponent final : public Component { bool is_log_message_enabled() const; /** Subscribe to an MQTT topic and call callback when a message is received. + * + * A callback that fits in a pointer (capture at most `this`) is stored inline; a larger one is + * boxed on the heap and freed by unsubscribe(). * * @param topic The topic. Wildcards are currently not supported. * @param callback The callback function. * @param qos The QoS of this subscription. */ - void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0); + template void subscribe(std::string topic, F &&callback, uint8_t qos = 0) { + constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline(); + this->add_subscription_(std::move(topic), + boxed ? mqtt_subscription_callback_t::create_boxed(std::forward(callback)) + : mqtt_subscription_callback_t::create(std::forward(callback)), + boxed, qos); + } /** Subscribe to a MQTT topic and automatically parse JSON payload. * @@ -181,11 +195,21 @@ class MQTTClientComponent final : public Component { * received. * @param qos The QoS of this subscription. */ - void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); + template void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) { + static_assert(std::is_invocable_v &, const std::string &, JsonObject>, + "subscribe_json() needs a callback that does not mutate its captures"); + this->subscribe( + std::move(topic), + [cb = std::forward(callback)](const std::string &topic, const std::string &payload) { + call_json(cb, topic, payload); + }, + qos); + } /** Unsubscribe from an MQTT topic. * * If multiple existing subscriptions to the same topic exist, all of them will be removed. + * From inside a subscription callback they stop receiving messages at once and are erased once it returns. * * @param topic The topic to unsubscribe from. * Must match the topic in the original subscribe or subscribe_json call exactly. @@ -285,6 +309,14 @@ class MQTTClientComponent final : public Component { bool subscribe_(const char *topic, uint8_t qos); void resubscribe_subscription_(MQTTSubscription *sub); + template static void call_json(const F &callback, const std::string &topic, const std::string &payload) { + JsonDocument doc = json::parse_json(payload); + if (doc.overflowed() || doc.isNull()) + return; + callback(topic, doc.as()); + } + void erase_removed_subscriptions_(); + void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos); void resubscribe_subscriptions_(); MQTTCredentials credentials_; @@ -336,48 +368,12 @@ class MQTTClientComponent final : public Component { bool publish_nan_as_none_{false}; bool wait_for_connection_{false}; + bool dispatching_{false}; + bool removed_while_dispatching_{false}; }; extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class MQTTMessageTrigger final : public Trigger, public Component { - public: - explicit MQTTMessageTrigger(std::string topic); - - void set_qos(uint8_t qos) { this->qos_ = qos; } - void set_payload(const std::string &payload) { this->payload_ = payload; } - void setup() override; - void dump_config() override; - float get_setup_priority() const override; - - protected: - std::string topic_; - uint8_t qos_{0}; - optional payload_; -}; - -class MQTTJsonMessageTrigger final : public Trigger { - public: - explicit MQTTJsonMessageTrigger(const std::string &topic, uint8_t qos) { - global_mqtt_client->subscribe_json( - topic, [this](const std::string &topic, JsonObject root) { this->trigger(root); }, qos); - } -}; - -class MQTTConnectTrigger final : public Trigger { - public: - explicit MQTTConnectTrigger(MQTTClientComponent *client) { - client->set_on_connect([this](bool session_present) { this->trigger(session_present); }); - } -}; - -class MQTTDisconnectTrigger final : public Trigger { - public: - explicit MQTTDisconnectTrigger(MQTTClientComponent *client) { - client->set_on_disconnect([this](MQTTClientDisconnectReason reason) { this->trigger(reason); }); - } -}; - template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index adb5a958287..7e4b8be725d 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -342,14 +342,6 @@ bool MQTTComponent::is_discovery_enabled() const { return this->discovery_enabled_ && global_mqtt_client->is_discovery_enabled(); } -void MQTTComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) { - global_mqtt_client->subscribe(topic, std::move(callback), qos); -} - -void MQTTComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) { - global_mqtt_client->subscribe_json(topic, callback, qos); -} - MQTTComponent::MQTTComponent() = default; float MQTTComponent::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 141b5f12b38..461db5d3e76 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -259,7 +259,9 @@ class MQTTComponent : public Component { * @param callback The callback that will be called when a message with matching topic is received. * @param qos The MQTT quality of service. Defaults to 0. */ - void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0); + template void subscribe(std::string topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe(std::move(topic), std::forward(callback), qos); + } /** Subscribe to a MQTT topic and automatically parse JSON payload. * @@ -270,7 +272,9 @@ class MQTTComponent : public Component { * received. * @param qos The MQTT quality of service. Defaults to 0. */ - void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); + template void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe_json(std::move(topic), std::forward(callback), qos); + } protected: static constexpr uint32_t SEND_DEFER_ID = 0; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 15f247cadbc..e628b02ac94 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1725,7 +1725,8 @@ enum class TemperatureUnit : uint8_t { /// No null check, no exceptions, no heap allocation for small trivially-copyable callables. /// /// With C++20 if constexpr, automatically detects [this] lambdas (sizeof <= sizeof(void*), -/// trivially copyable) and stores them inline. Larger callables are heap-allocated. +/// trivially copyable, callable as const) and stores them inline. Larger callables, and mutable +/// ones whose state must survive between calls, are heap-allocated. template struct Callback; template struct Callback { @@ -1740,11 +1741,19 @@ template struct Callback { /// Invoke the callback. Only valid on Callbacks created via create(), never on default-constructed instances. void call(Ts... args) const { this->fn_(this->ctx_, std::forward(args)...); } + /// Whether create() stores F inline in the ctx pointer. The inline path invokes a copy, so a + /// callable that mutates its captures (a mutable lambda) is kept whole on the heap instead. + template static constexpr bool fits_inline() { + using DecayF = std::decay_t; + return sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v && + std::is_invocable_v; + } + /// Create from any callable. Small trivially-copyable callables (like [this] lambdas) /// are stored inline in the ctx pointer without heap allocation. template static Callback create(F &&callable) { using DecayF = std::decay_t; - if constexpr (sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v) { + if constexpr (fits_inline()) { // Small trivial callable (e.g. [this]() { this->method(); }) - store inline in ctx. // Safe under C++20 (P0593R6): byte copy into aligned storage implicitly // creates objects of implicit-lifetime types (trivially copyable qualifies). @@ -1767,6 +1776,28 @@ template struct Callback { return {[](void *c, Ts... args) { (*static_cast(c))(args...); }, static_cast(stored)}; } } + + /// Heap home of a callable stored by create_boxed(); the header knows how to free it. + struct Box { + void (*free)(Box *box); + }; + + /// Store any callable on the heap with a deleter, for an owner that can later free_boxed() it. + template static Callback create_boxed(F &&callable) { + struct Boxed : Box { + std::decay_t fn; + }; + auto *box = new Boxed{{[](Box *b) { delete static_cast(b); }}, // NOLINT(cppcoreguidelines-owning-memory) + std::forward(callable)}; + return {[](void *c, Ts... args) { static_cast(static_cast(c))->fn(args...); }, + static_cast(box)}; + } + + /// Free the callable of a Callback made by create_boxed(); the Callback must not be called afterwards. + void free_boxed() const { + auto *box = static_cast(this->ctx_); + box->free(box); + } }; /// Grow a CallbackManager's backing array to exactly size+1. Defined in helpers.cpp. diff --git a/tests/components/core/test_callback.cpp b/tests/components/core/test_callback.cpp new file mode 100644 index 00000000000..7cf969734ad --- /dev/null +++ b/tests/components/core/test_callback.cpp @@ -0,0 +1,92 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +using IntCallback = Callback; + +// Counts destructions of the copies a callable carries, to check a boxed capture is freed exactly once. +struct DestroyCounter { + int *destroyed; + bool live{true}; + explicit DestroyCounter(int *count) : destroyed(count) {} + DestroyCounter(const DestroyCounter &other) = delete; + DestroyCounter(DestroyCounter &&other) noexcept : destroyed(other.destroyed) { other.live = false; } + DestroyCounter &operator=(const DestroyCounter &other) = delete; + DestroyCounter &operator=(DestroyCounter &&other) = delete; + ~DestroyCounter() { + if (this->live) + (*this->destroyed)++; + } +}; + +template bool fits_inline(const F & /*callable*/) { return IntCallback::fits_inline(); } + +TEST(CallbackFitsInline, PointerSizedConstCallableFits) { + int value = 0; + EXPECT_TRUE(fits_inline([&value](int x) { value = x; })); +} + +TEST(CallbackFitsInline, MutableCallableDoesNotFit) { + int calls = 0; + EXPECT_FALSE(fits_inline([calls](int) mutable { calls++; })); +} + +TEST(CallbackFitsInline, LargeCallableDoesNotFit) { + int a = 0; + int b = 0; + EXPECT_FALSE(fits_inline([&a, &b](int x) { a = b = x; })); +} + +TEST(CallbackCreate, InlineCallableIsCalled) { + int value = 0; + auto cb = IntCallback::create([&value](int x) { value = x; }); + cb.call(42); + EXPECT_EQ(value, 42); +} + +int mutable_seen = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// A pointer-sized mutable lambda used to be stored inline and run on a copy, losing its state. +TEST(CallbackCreate, MutableStateIsKeptAcrossCalls) { + auto counter = [n = 0](int x) mutable { mutable_seen = (n += x); }; + static_assert(sizeof(counter) <= sizeof(void *) && std::is_trivially_copyable_v); + // create() never frees its heap path; held for the whole run like a callback registered in setup(). + static auto cb = IntCallback::create(counter); + cb.call(1); + cb.call(2); + EXPECT_EQ(mutable_seen, 3); +} + +TEST(CallbackCreateBoxed, MutableStateIsKeptAcrossCalls) { + int seen = 0; + auto cb = IntCallback::create_boxed([count = 0, &seen](int x) mutable { + count += x; + seen = count; + }); + cb.call(1); + cb.call(2); + cb.call(3); + EXPECT_EQ(seen, 6); + cb.free_boxed(); +} + +TEST(CallbackCreateBoxed, FreeDestroysCaptureExactlyOnce) { + int destroyed = 0; + int calls = 0; + { + auto cb = IntCallback::create_boxed([counter = DestroyCounter(&destroyed), &calls](int) { calls++; }); + EXPECT_EQ(destroyed, 0); + cb.call(0); + cb.call(0); + EXPECT_EQ(destroyed, 0); + cb.free_boxed(); + EXPECT_EQ(destroyed, 1); + } + // The Callback itself owns nothing, so going out of scope frees nothing more + EXPECT_EQ(destroyed, 1); + EXPECT_EQ(calls, 2); +} + +} // namespace esphome::core::testing diff --git a/tests/components/mqtt/common-triggers.yaml b/tests/components/mqtt/common-triggers.yaml new file mode 100644 index 00000000000..3bb22c7c53c --- /dev/null +++ b/tests/components/mqtt/common-triggers.yaml @@ -0,0 +1,25 @@ +# on_message with a payload filter, a subscription callback larger than a pointer with its unsubscribe, +# a callback that unsubscribes itself, and a mutable callback, which keeps its state instead of being copied per call. +mqtt: + id: mqtt_client + on_message: + - topic: livingroom/ota_mode + payload: "ON" + qos: 1 + then: + - logger.log: Got livingroom/ota_mode ON + +esphome: + on_boot: + - lambda: |- + std::string tag = "boxed"; + id(mqtt_client).subscribe("some/topic/boxed", [tag](const std::string &topic, const std::string &payload) { + ESP_LOGD("test", "%s %s", tag.c_str(), payload.c_str()); + }); + id(mqtt_client).unsubscribe("some/topic/boxed"); + id(mqtt_client).subscribe("some/topic/once", [](const std::string &topic, const std::string &payload) { + id(mqtt_client).unsubscribe(topic); + }); + id(mqtt_client).subscribe("some/topic/counted", [n = 0](const std::string &topic, const std::string &payload) mutable { + ESP_LOGD("test", "message %d: %s", ++n, payload.c_str()); + }); diff --git a/tests/components/mqtt/test-triggers.esp32-idf.yaml b/tests/components/mqtt/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..b34863e8737 --- /dev/null +++ b/tests/components/mqtt/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + common: !include common.yaml + triggers: !include common-triggers.yaml From 15fb7f775e67cf500707de098597de4ef660a56c Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 20:59:21 +0200 Subject: [PATCH 375/651] [tmc2209] Add chopper and PWM tuning actions (#20753) --- esphome/components/tmc22xx/__init__.py | 77 ++++++++++++++++++++++++++ esphome/components/tmc22xx/tmc22xx.cpp | 7 +++ esphome/components/tmc22xx/tmc22xx.h | 24 ++++++++ tests/components/tmc2209/common.yaml | 15 +++++ 4 files changed, 123 insertions(+) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index da760dde2c5..82ac29c7d5b 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -24,7 +24,9 @@ CONF_CLOCK_FREQUENCY = "clock_frequency" CONF_DIAG_PIN = "diag_pin" CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" CONF_ENN_PIN = "enn_pin" +CONF_HEND = "hend" CONF_HOLD_CURRENT = "hold_current" +CONF_HSTRT = "hstrt" CONF_IHOLD = "ihold" CONF_IHOLDDELAY = "iholddelay" CONF_INDEX_PIN = "index_pin" @@ -34,9 +36,18 @@ CONF_IRUN = "irun" CONF_MICROSTEPS = "microsteps" CONF_ON_STATUS = "on_status" CONF_OTTRIM = "ottrim" +CONF_PWM_AUTOGRAD = "pwm_autograd" +CONF_PWM_AUTOSCALE = "pwm_autoscale" +CONF_PWM_FREQ = "pwm_freq" +CONF_PWM_GRAD = "pwm_grad" +CONF_PWM_LIM = "pwm_lim" +CONF_PWM_OFS = "pwm_ofs" +CONF_PWM_REG = "pwm_reg" CONF_RSENSE = "rsense" CONF_RUN_CURRENT = "run_current" CONF_STANDSTILL_MODE = "standstill_mode" +CONF_TBL = "tbl" +CONF_TOFF = "toff" CONF_TPOWERDOWN = "tpowerdown" CONF_TPWM_THRESHOLD = "tpwm_threshold" CONF_VSENSE = "vsense" @@ -110,6 +121,29 @@ def _validate_analog_current_scale(config: ConfigType) -> ConfigType: return config +def _validate_chopconf(config: ConfigType) -> ConfigType: + # Only values given as constants can be checked here, not lambdas + toff, tbl = config.get(CONF_TOFF), config.get(CONF_TBL) + # TBL is never written at setup, so the chip may still hold a blank time below 2 + if toff == 1 and (tbl is None or (isinstance(tbl, int) and tbl < 2)): + raise cv.Invalid( + f"'{CONF_TOFF}: 1' needs '{CONF_TBL}' set to 2 or higher in the same " + "action", + path=[CONF_TOFF], + ) + hend, hstrt = config.get(CONF_HEND), config.get(CONF_HSTRT) + # Register values are offsets: HEND 0-15 is -3 to 12, HSTRT 0-7 is 1 to 8 + if isinstance(hend, int) and isinstance(hstrt, int): + hysteresis = (hend - 3) + (hstrt + 1) + if hysteresis > 16: + raise cv.Invalid( + f"'{CONF_HEND}' and '{CONF_HSTRT}' give a hysteresis of " + f"{hysteresis}, the limit is 16", + path=[CONF_HEND], + ) + 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", []): @@ -225,3 +259,46 @@ def register_actions(domain: str, cls: MockObjClass) -> None: CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode ), ) + + automation.register_apply_action( + f"{domain}.chopconf", + cv.All( + cv.Schema( + { + **id_schema, + cv.Optional(CONF_TOFF): cv.templatable(cv.int_range(1, 15)), + cv.Optional(CONF_TBL): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_HEND): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_HSTRT): cv.templatable(cv.int_range(0, 7)), + } + ), + _validate_chopconf, + ), + automation.ApplyField(CONF_TOFF, "set_toff", cg.uint8), + automation.ApplyField(CONF_TBL, "set_tbl", cg.uint8), + automation.ApplyField(CONF_HEND, "set_hend", cg.uint8), + automation.ApplyField(CONF_HSTRT, "set_hstrt", cg.uint8), + ) + + automation.register_apply_action( + f"{domain}.pwmconf", + cv.Schema( + { + **id_schema, + cv.Optional(CONF_PWM_OFS): cv.templatable(cv.uint8_t), + cv.Optional(CONF_PWM_GRAD): cv.templatable(cv.uint8_t), + cv.Optional(CONF_PWM_FREQ): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_PWM_AUTOSCALE): cv.templatable(cv.boolean), + cv.Optional(CONF_PWM_AUTOGRAD): cv.templatable(cv.boolean), + cv.Optional(CONF_PWM_REG): cv.templatable(cv.int_range(1, 15)), + cv.Optional(CONF_PWM_LIM): cv.templatable(cv.int_range(0, 15)), + } + ), + automation.ApplyField(CONF_PWM_OFS, "set_pwm_ofs", cg.uint8), + automation.ApplyField(CONF_PWM_GRAD, "set_pwm_grad", cg.uint8), + automation.ApplyField(CONF_PWM_FREQ, "set_pwm_freq", cg.uint8), + automation.ApplyField(CONF_PWM_AUTOSCALE, "set_pwm_autoscale", cg.bool_), + automation.ApplyField(CONF_PWM_AUTOGRAD, "set_pwm_autograd", cg.bool_), + automation.ApplyField(CONF_PWM_REG, "set_pwm_reg", cg.uint8), + automation.ApplyField(CONF_PWM_LIM, "set_pwm_lim", cg.uint8), + ) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 2aed97bea10..7bb756819bd 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -362,6 +362,13 @@ void TMC22XXStepper::set_enabled(bool enabled) { this->enabled_ = enabled; } +void TMC22XXStepper::set_toff(uint8_t toff) { + this->toff_ = toff; + // Without an ENN pin, TOFF=0 is what keeps the outputs disabled + if (this->enabled_ || this->enn_pin_ != nullptr) + this->write_field(TOFF, toff); +} + void TMC22XXStepper::set_microsteps(uint16_t microsteps) { for (uint8_t mres = 0; mres <= 8; mres++) { if ((256u >> mres) == microsteps) { diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 19e4813b70d..2e8644de53a 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -57,12 +57,22 @@ 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 HSTRT{REG_CHOPCONF, 4, 3, false}; +static constexpr RegisterField HEND{REG_CHOPCONF, 7, 4, false}; +static constexpr RegisterField TBL{REG_CHOPCONF, 15, 2, 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 CS_ACTUAL{REG_DRV_STATUS, 16, 5, false}; +static constexpr RegisterField PWM_OFS{REG_PWMCONF, 0, 8, false}; +static constexpr RegisterField PWM_GRAD{REG_PWMCONF, 8, 8, false}; +static constexpr RegisterField PWM_FREQ{REG_PWMCONF, 16, 2, false}; +static constexpr RegisterField PWM_AUTOSCALE{REG_PWMCONF, 18, 1, false}; +static constexpr RegisterField PWM_AUTOGRAD{REG_PWMCONF, 19, 1, false}; static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; +static constexpr RegisterField PWM_REG{REG_PWMCONF, 24, 4, false}; +static constexpr RegisterField PWM_LIM{REG_PWMCONF, 28, 4, false}; static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false}; static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true}; static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false}; @@ -160,6 +170,20 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U 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); } + + /// Chopper off time (1-15). TOFF=0 is used to disable the outputs, so this is applied on the next enable. + void set_toff(uint8_t toff); + void set_tbl(uint8_t tbl) { this->write_field(TBL, tbl); } + void set_hend(uint8_t hend) { this->write_field(HEND, hend); } + void set_hstrt(uint8_t hstrt) { this->write_field(HSTRT, hstrt); } + + void set_pwm_ofs(uint8_t ofs) { this->write_field(PWM_OFS, ofs); } + void set_pwm_grad(uint8_t grad) { this->write_field(PWM_GRAD, grad); } + void set_pwm_freq(uint8_t freq) { this->write_field(PWM_FREQ, freq); } + void set_pwm_autoscale(bool enable) { this->write_field(PWM_AUTOSCALE, enable); } + void set_pwm_autograd(bool enable) { this->write_field(PWM_AUTOGRAD, enable); } + void set_pwm_reg(uint8_t reg) { this->write_field(PWM_REG, reg); } + void set_pwm_lim(uint8_t lim) { this->write_field(PWM_LIM, lim); } /// Convert a current scale (0-31) to A RMS, without analog current scale. float scale_to_current(uint8_t scale); diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index c39d3fd8ba2..8714db23f42 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -78,6 +78,21 @@ esphome: seup: 1 sedn: 0 seimin: true + - tmc2209.chopconf: + id: tmc2209_step_dir + toff: 4 + tbl: 2 + hend: 0 + hstrt: !lambda "return 5;" + - tmc2209.pwmconf: + id: tmc2209_serial + pwm_ofs: 36 + pwm_grad: 14 + pwm_freq: 1 + pwm_autoscale: true + pwm_autograd: true + pwm_reg: 4 + pwm_lim: 12 - tmc2209.enable: tmc2209_serial - stepper.set_target: id: tmc2209_serial From c5a624a1f2e9be7985bd03bd81ae8c3f0eb36568 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:21:35 -0400 Subject: [PATCH 376/651] [tests] Count OTA key probe retries on a fake clock (#20761) --- tests/unit_tests/test_espota2_noise.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 1adace67395..f447e03986e 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -858,16 +858,23 @@ def test_probe_ota_key_resolution_failure( ) -> None: """A name that never resolves is retried until the deadline, then reported.""" pytest.importorskip("aioesphomeapi.noise") + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + with ( patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve, - patch("time.sleep"), + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), caplog.at_level(logging.WARNING), ): assert ( - espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2) + espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=10) is not ProbeResult.ACCEPTED ) - assert resolve.call_count > 1 + # Looked up at 0, 3, 6 and 9 s; the last pause stops at the deadline + assert resolve.call_count == 4 assert any("failed, unreachable" in r.message for r in caplog.records) From e1a996435dc1a3373a72e1c8740449179510b07c Mon Sep 17 00:00:00 2001 From: Fabio Pugliese Ornellas Date: Wed, 14 Oct 2026 20:29:16 +0100 Subject: [PATCH 377/651] [ld2450] Add polygon zones evaluated on the device (#20154) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/ld2450/__init__.py | 11 + esphome/components/ld2450/binary_sensor.py | 36 ++- esphome/components/ld2450/ld2450.cpp | 34 +- esphome/components/ld2450/ld2450.h | 13 +- esphome/components/ld2450/polygon.cpp | 123 +++++++ esphome/components/ld2450/polygon.h | 53 ++++ esphome/components/ld2450/polygon_zone.cpp | 60 ++++ esphome/components/ld2450/polygon_zone.h | 63 ++++ esphome/components/ld2450/sensor.py | 2 +- esphome/components/ld2450/text.py | 120 +++++++ esphome/components/ld2450/text_sensor.py | 2 +- esphome/core/defines.h | 1 + tests/component_tests/ld2450/__init__.py | 0 .../ld2450/config/esp8266_zones.yaml | 31 ++ .../ld2450/test_polygon_pattern.py | 64 ++++ .../ld2450/test_polygon_zone_presence.py | 45 +++ .../ld2450/test_polygon_zones.py | 163 ++++++++++ tests/components/ld2450/__init__.py | 9 + tests/components/ld2450/common.yaml | 14 + tests/components/ld2450/ld2450_polygon.cpp | 162 ++++++++++ .../uart_mock_ld2450_has_target_only.yaml | 248 +++++++++++++++ ...uart_mock_ld2450_polygon_zone_restore.yaml | 55 ++++ ...uart_mock_ld2450_polygon_zone_timeout.yaml | 300 ++++++++++++++++++ .../uart_mock_ld2450_polygon_zones.yaml | 246 ++++++++++++++ .../test_uart_mock_ld2450_has_target_only.py | 64 ++++ ...t_uart_mock_ld2450_polygon_zone_restore.py | 117 +++++++ ...t_uart_mock_ld2450_polygon_zone_timeout.py | 102 ++++++ .../test_uart_mock_ld2450_polygon_zones.py | 160 ++++++++++ 28 files changed, 2291 insertions(+), 7 deletions(-) create mode 100644 esphome/components/ld2450/polygon.cpp create mode 100644 esphome/components/ld2450/polygon.h create mode 100644 esphome/components/ld2450/polygon_zone.cpp create mode 100644 esphome/components/ld2450/polygon_zone.h create mode 100644 esphome/components/ld2450/text.py create mode 100644 tests/component_tests/ld2450/__init__.py create mode 100644 tests/component_tests/ld2450/config/esp8266_zones.yaml create mode 100644 tests/component_tests/ld2450/test_polygon_pattern.py create mode 100644 tests/component_tests/ld2450/test_polygon_zone_presence.py create mode 100644 tests/component_tests/ld2450/test_polygon_zones.py create mode 100644 tests/components/ld2450/__init__.py create mode 100644 tests/components/ld2450/ld2450_polygon.cpp create mode 100644 tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml create mode 100644 tests/integration/test_uart_mock_ld2450_has_target_only.py create mode 100644 tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py create mode 100644 tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py create mode 100644 tests/integration/test_uart_mock_ld2450_polygon_zones.py diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index a54e83f2875..066ace871f8 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE from esphome.types import ConfigType @@ -16,6 +17,8 @@ LD2450Component = ld2450_ns.class_("LD2450Component", cg.Component, uart.UARTDev CONF_LD2450_ID = "ld2450_id" +CONF_POLYGON_ZONE_ID = "polygon_zone_id" +CONF_POLYGON_ZONES = "polygon_zones" CONFIG_SCHEMA = cv.All( cv.Schema( @@ -50,6 +53,14 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), ) +# Polygon zone sources build only when zones are configured +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "polygon.cpp": "LD2450_POLYGON_ZONE_COUNT", + "polygon_zone.cpp": "LD2450_POLYGON_ZONE_COUNT", + } +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index c7c1b6b46af..41f514472e5 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import ( + CONF_FILTERS, CONF_HAS_MOVING_TARGET, CONF_HAS_STILL_TARGET, CONF_HAS_TARGET, @@ -11,7 +12,14 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import ( # noqa: F401 pylint: disable=unused-import + CONF_LD2450_ID, + CONF_POLYGON_ZONE_ID, + CONF_POLYGON_ZONES, + FILTER_SOURCE_FILES, + LD2450Component, +) +from .text import PolygonZone DEPENDENCIES = ["ld2450"] @@ -19,6 +27,18 @@ ICON_MEDITATION = "mdi:meditation" ICON_SHIELD_ACCOUNT = "mdi:shield-account" ICON_TARGET_ACCOUNT = "mdi:target-account" + +def _reject_filters(config: ConfigType) -> ConfigType: + # Clearing a polygon makes presence unknown without going through filters, so they would get out of step + if CONF_FILTERS in config: + raise cv.Invalid( + "Polygon zone presence binary sensors can't have filters; use the ld2450 number platform's " + "'presence_timeout' to keep presence on after targets leave", + path=[CONF_FILTERS], + ) + return config + + CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), @@ -37,6 +57,16 @@ CONFIG_SCHEMA = { filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_MEDITATION, ), + cv.Optional(CONF_POLYGON_ZONES): cv.ensure_list( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ).extend( + { + cv.Required(CONF_POLYGON_ZONE_ID): cv.use_id(PolygonZone), + } + ), + _reject_filters, + ), } @@ -46,3 +76,7 @@ async def to_code(config: ConfigType) -> None: await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) + for presence_conf in config.get(CONF_POLYGON_ZONES, []): + presence = await binary_sensor.new_binary_sensor(presence_conf) + zone = await cg.get_variable(presence_conf[CONF_POLYGON_ZONE_ID]) + cg.add(zone.set_presence_binary_sensor(presence)) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index b43493c26e9..d2a0e04d122 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -179,6 +179,11 @@ void LD2450Component::setup() { this->pref_ = this->presence_timeout_number_->make_entity_preference(); this->set_presence_timeout(); } +#endif +#ifdef LD2450_POLYGON_ZONE_COUNT + for (PolygonZone *zone : this->polygon_zones_) { + zone->setup(); + } #endif this->restart_and_read_all_info(); } @@ -265,6 +270,12 @@ void LD2450Component::dump_config() { LOG_BUTTON(" ", "FactoryReset", this->factory_reset_button_); LOG_BUTTON(" ", "Restart", this->restart_button_); #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + ESP_LOGCONFIG(TAG, "Polygon Zones:"); + for (PolygonZone *zone : this->polygon_zones_) { + zone->dump_config(); + } +#endif } void LD2450Component::loop() { @@ -465,15 +476,15 @@ void LD2450Component::handle_periodic_data_() { #if defined(USE_BINARY_SENSOR) || defined(USE_SENSOR) || defined(USE_TEXT_SENSOR) // Loop thru targets for (index = 0; index < MAX_TARGETS; index++) { -#ifdef USE_SENSOR // X start = TARGET_X + index * 8; is_moving = false; - // tx is used for further calculations, so always needs to be populated + // tx and ty are used for further calculations, so always need to be populated tx = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); // Y start = TARGET_Y + index * 8; ty = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); +#ifdef USE_SENSOR // RESOLUTION start = TARGET_RESOLUTION + index * 8; res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; @@ -542,6 +553,23 @@ void LD2450Component::handle_periodic_data_() { still_target_count = target_count - moving_target_count; #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + const uint32_t now = App.get_loop_component_start_time(); + for (PolygonZone *zone : this->polygon_zones_) { + if (!zone->has_polygon()) + continue; + bool target_inside = false; + for (const Target &target : this->target_info_) { + // Untracked targets are stored as (0, 0) + if ((target.x != 0 || target.y != 0) && zone->contains(target.x, target.y)) { + target_inside = true; + break; + } + } + zone->update(target_inside, now, this->get_timeout_status_(zone->get_last_inside_ms())); + } +#endif + #ifdef USE_SENSOR // Loop thru zones uint8_t zone_still_targets = 0; @@ -602,7 +630,7 @@ void LD2450Component::handle_periodic_data_() { } } #endif -#ifdef USE_SENSOR +#ifdef USE_BINARY_SENSOR // For presence timeout check if (target_count > 0) { this->presence_millis_ = App.get_loop_component_start_time(); diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index d6285cce8af..f85a26ba675 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -24,6 +24,10 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef LD2450_POLYGON_ZONE_COUNT +#include "polygon_zone.h" +#endif + #include "esphome/components/ld24xx/ld24xx.h" #include "esphome/components/uart/uart.h" #include "esphome/core/helpers.h" @@ -137,6 +141,9 @@ class LD2450Component : public Component, public uart::UARTDevice { void set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s); void set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s); void set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s); +#endif +#ifdef LD2450_POLYGON_ZONE_COUNT + void register_polygon_zone(PolygonZone *zone) { this->polygon_zones_.push_back(zone); } #endif void reset_radar_zone(); void set_radar_zone(int32_t zone_type, int32_t zone1_x1, int32_t zone1_y1, int32_t zone1_x2, int32_t zone1_y2, @@ -167,7 +174,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t presence_millis_ = 0; uint32_t still_presence_millis_ = 0; uint32_t moving_presence_millis_ = 0; - uint32_t timeout_ = 5; + uint32_t timeout_ = DEFAULT_PRESENCE_TIMEOUT * 1000; // presence_timeout, in ms uint8_t buffer_data_[MAX_LINE_LENGTH]; uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; @@ -200,6 +207,10 @@ class LD2450Component : public Component, public uart::UARTDevice { std::array direction_text_sensors_{}; #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + StaticVector polygon_zones_; +#endif + LazyCallbackManager data_callback_; }; diff --git a/esphome/components/ld2450/polygon.cpp b/esphome/components/ld2450/polygon.cpp new file mode 100644 index 00000000000..46d607d3519 --- /dev/null +++ b/esphome/components/ld2450/polygon.cpp @@ -0,0 +1,123 @@ +#include "polygon.h" + +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ld2450 { + +namespace { + +class Scanner { + public: + Scanner(const char *str, size_t len) : pos_(str), end_(str + len) {} + + bool at_end() const { return this->pos_ == this->end_; } + + void skip_spaces() { + while (this->pos_ != this->end_ && *this->pos_ == ' ') + this->pos_++; + } + + bool consume(char c) { + this->skip_spaces(); + if (this->pos_ != this->end_ && *this->pos_ == c) { + this->pos_++; + return true; + } + return false; + } + + /// Read an integer in [min, max]; returns false if there is none or it is out of range. + bool read_int(int16_t min, int16_t max, int16_t &out) { + bool negative = this->consume('-'); + int32_t value = 0; + uint8_t digits = 0; + while (this->pos_ != this->end_ && *this->pos_ >= '0' && *this->pos_ <= '9') { + // Six digits is already beyond any valid coordinate; stop before value can overflow + if (++digits > 6) + return false; + value = value * 10 + (*this->pos_ - '0'); + this->pos_++; + } + if (digits == 0) + return false; + if (negative) + value = -value; + if (value < min || value > max) + return false; + out = static_cast(value); + return true; + } + + protected: + const char *pos_; + const char *end_; +}; + +} // namespace + +bool Polygon::is_valid() const { + if (this->count == 0) + return true; + if (this->count < MIN_POLYGON_POINTS || this->count > MAX_POLYGON_POINTS) + return false; + for (uint8_t i = 0; i < this->count; i++) { + const Point &p = this->points[i]; + if (p.x < POLYGON_MIN_X || p.x > POLYGON_MAX_X || p.y < POLYGON_MIN_Y || p.y > POLYGON_MAX_Y) + return false; + } + return true; +} + +bool Polygon::contains(int16_t x, int16_t y) const { + bool inside = false; + for (uint8_t i = 0, j = this->count - 1; i < this->count; j = i++) { + const Point &a = this->points[i]; + const Point &b = this->points[j]; + if ((a.y > y) == (b.y > y)) + continue; + // Does the edge a-b cross the ray going from (x, y) towards +x? Compare + // x - a.x < (y - a.y) * (b.x - a.x) / (b.y - a.y) without division; values fit in 32 bits. + const int32_t dy = static_cast(b.y) - a.y; + const int32_t lhs = (static_cast(x) - a.x) * dy; + const int32_t rhs = (static_cast(y) - a.y) * (static_cast(b.x) - a.x); + if (dy > 0 ? lhs < rhs : lhs > rhs) + inside = !inside; + } + return inside; +} + +bool Polygon::parse(const char *str, size_t len) { + Polygon result; + Scanner scanner(str, len); + scanner.skip_spaces(); + while (!scanner.at_end()) { + if (result.count == MAX_POLYGON_POINTS) + return false; + Point &p = result.points[result.count]; + if (!scanner.read_int(POLYGON_MIN_X, POLYGON_MAX_X, p.x) || !scanner.consume(',') || + !scanner.read_int(POLYGON_MIN_Y, POLYGON_MAX_Y, p.y)) + return false; + result.count++; + bool separator = scanner.consume(';'); + scanner.skip_spaces(); + if (!separator && !scanner.at_end()) + return false; + } + if (!result.is_valid()) + return false; + *this = result; + return true; +} + +void Polygon::format(char *buf) const { + size_t pos = 0; + buf[0] = '\0'; + for (uint8_t i = 0; i < this->count; i++) { + pos = buf_append_printf(buf, POLYGON_STR_SIZE, pos, "%s%d,%d", i == 0 ? "" : ";", this->points[i].x, + this->points[i].y); + } +} + +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/polygon.h b/esphome/components/ld2450/polygon.h new file mode 100644 index 00000000000..24e5be8855b --- /dev/null +++ b/esphome/components/ld2450/polygon.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +namespace esphome::ld2450 { + +static constexpr uint8_t MIN_POLYGON_POINTS = 3; +// The most points whose text always fits Home Assistant's 255-character limit: 23 x "-4860,7560" + 22 x ';' = 252 +static constexpr uint8_t MAX_POLYGON_POINTS = 23; +// Coordinate limits of the radar detection area, in mm +static constexpr int16_t POLYGON_MIN_X = -4860; +static constexpr int16_t POLYGON_MAX_X = 4860; +static constexpr int16_t POLYGON_MIN_Y = 0; +static constexpr int16_t POLYGON_MAX_Y = 7560; +// Longest point is "-4860,7560;" (11 characters), plus the null terminator +static constexpr size_t POLYGON_STR_SIZE = MAX_POLYGON_POINTS * 11 + 1; + +struct Point { + int16_t x; + int16_t y; +}; + +/// A polygon in radar coordinates. Trivially copyable so it can be saved to preferences as-is. +struct Polygon { + std::array points{}; + uint8_t count{0}; + + /// True when the polygon has no points, which disables the zone. + bool empty() const { return this->count == 0; } + + /// True when the polygon is empty, or has a valid number of points that are all inside the radar area. + bool is_valid() const; + + /// Return whether the point is inside the polygon (even-odd rule). Always false for an empty polygon. + bool contains(int16_t x, int16_t y) const; + + /** Parse "x,y;x,y;..." (mm) into this polygon. + * + * Spaces around numbers and a trailing ';' are allowed. An empty or all-space string gives an empty polygon. + * @return false if the text is invalid; the polygon is then left unchanged. + */ + bool parse(const char *str, size_t len); + + /// Write the polygon as "x,y;x,y;..." into buf, which must hold at least POLYGON_STR_SIZE bytes. + void format(char *buf) const; +}; + +// text.py counts on this size +static_assert(sizeof(Polygon) == 94, "Polygon layout changed; update POLYGON_SAVED_BYTES in text.py"); + +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/polygon_zone.cpp b/esphome/components/ld2450/polygon_zone.cpp new file mode 100644 index 00000000000..ecafd40360b --- /dev/null +++ b/esphome/components/ld2450/polygon_zone.cpp @@ -0,0 +1,60 @@ +#include "polygon_zone.h" +#ifdef LD2450_POLYGON_ZONE_COUNT + +#include "esphome/core/log.h" + +namespace esphome::ld2450 { + +ESPHOME_LOG_TAG(TAG, "ld2450.polygon_zone"); + +void PolygonZone::setup() { + // Bump the version when Polygon's layout changes + this->pref_ = this->make_entity_preference(1); + Polygon saved; + if (this->pref_.load(&saved)) { + if (saved.is_valid()) { + this->polygon_ = saved; + } else { + ESP_LOGW(TAG, "'%s': saved polygon is invalid; zone disabled", this->get_name().c_str()); + } + } + // Presence stays unknown until the first radar frame + this->publish_polygon_(); +} + +void PolygonZone::dump_config() { + char polygon_s[POLYGON_STR_SIZE]; + this->polygon_.format(polygon_s); + LOG_TEXT(" ", "PolygonZone", this); + ESP_LOGCONFIG(TAG, " Polygon: '%s'", polygon_s); + LOG_BINARY_SENSOR(" ", "Presence", this->presence_); +} + +void PolygonZone::control(const std::string &value) { + Polygon polygon; + if (!polygon.parse(value.c_str(), value.size())) { + ESP_LOGW(TAG, "'%s': invalid polygon '%s'; expected 'x,y;x,y;...' with %u to %u points", this->get_name().c_str(), + value.c_str(), MIN_POLYGON_POINTS, MAX_POLYGON_POINTS); + } else if (!this->pref_.save(&polygon)) { + // On ESP8266, the space for saved settings is small and can run out with several zones + ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", this->get_name().c_str()); + } else { + this->polygon_ = polygon; + // A target seen inside the previous polygon must not keep the new one on + this->last_inside_ms_ = 0; + if (this->polygon_.empty()) + this->presence_->invalidate_state(); + } + // On error this publishes the previous polygon again, so the frontend shows the value in use + this->publish_polygon_(); +} + +void PolygonZone::publish_polygon_() { + char polygon_s[POLYGON_STR_SIZE]; + this->polygon_.format(polygon_s); + this->publish_state(polygon_s); +} + +} // namespace esphome::ld2450 + +#endif // LD2450_POLYGON_ZONE_COUNT diff --git a/esphome/components/ld2450/polygon_zone.h b/esphome/components/ld2450/polygon_zone.h new file mode 100644 index 00000000000..5a6caaa3282 --- /dev/null +++ b/esphome/components/ld2450/polygon_zone.h @@ -0,0 +1,63 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef LD2450_POLYGON_ZONE_COUNT + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text/text.h" +#include "esphome/core/preferences.h" +#include "polygon.h" + +namespace esphome::ld2450 { + +/** A zone with any polygon shape. The text state holds the polygon, so it can be edited and read back from HA. + * + * The polygon is in the radar's raw coordinates (mm), and targets are checked against it as decoded from each + * radar frame, before any sensor filter. Filters on the target X/Y sensors change only what they publish, so a + * filter that changes their values (multiply, offset, lambda, ...) puts them in a different frame from the polygon. + * + * Like the hub's has_target binary sensors, presence stays on until presence_timeout has passed since a target was + * last inside the zone. + */ +class PolygonZone : public text::Text { + public: + /// Config validation guarantees every zone gets exactly one presence binary sensor. + void set_presence_binary_sensor(binary_sensor::BinarySensor *presence) { this->presence_ = presence; } + + /// Load the saved polygon and publish it. Called from the hub's setup(). + void setup(); + void dump_config(); + /** Publish presence for one radar frame. A zone without a polygon keeps presence unknown. + * + * @param target_inside whether any target is inside the zone in this frame. + * @param now the frame's time, in ms. + * @param timed_out whether presence_timeout has passed since get_last_inside_ms(). + */ + void update(bool target_inside, uint32_t now, bool timed_out) { + if (this->polygon_.empty()) + return; + if (target_inside) { + this->last_inside_ms_ = now; + this->presence_->publish_state(true); + } else if (timed_out) { + this->presence_->publish_state(false); + } + } + /// When a target was last inside the zone, in ms; 0 if never since the polygon was set. + uint32_t get_last_inside_ms() const { return this->last_inside_ms_; } + bool contains(int16_t x, int16_t y) const { return this->polygon_.contains(x, y); } + bool has_polygon() const { return !this->polygon_.empty(); } + + protected: + void control(const std::string &value) override; + void publish_polygon_(); + + binary_sensor::BinarySensor *presence_{nullptr}; + Polygon polygon_; + ESPPreferenceObject pref_; + uint32_t last_inside_ms_{0}; +}; + +} // namespace esphome::ld2450 + +#endif // LD2450_POLYGON_ZONE_COUNT diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 6f2056330bd..4600f27ce0d 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -17,7 +17,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["ld2450"] diff --git a/esphome/components/ld2450/text.py b/esphome/components/ld2450/text.py new file mode 100644 index 00000000000..ebe8fc77131 --- /dev/null +++ b/esphome/components/ld2450/text.py @@ -0,0 +1,120 @@ +from esphome import preferences +import esphome.codegen as cg +from esphome.components import text +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_PLATFORM, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import ( # noqa: F401 pylint: disable=unused-import + CONF_LD2450_ID, + CONF_POLYGON_ZONE_ID, + CONF_POLYGON_ZONES, + DOMAIN, + FILTER_SOURCE_FILES, + LD2450Component, + ld2450_ns, +) + +DEPENDENCIES = ["ld2450"] + +ICON_VECTOR_POLYGON = "mdi:vector-polygon" + +# Must match MIN_POLYGON_POINTS and MAX_POLYGON_POINTS in polygon.h +MIN_POLYGON_POINTS = 3 +MAX_POLYGON_POINTS = 23 +# Matches what Polygon::parse() accepts, apart from coordinate ranges, which only the device checks: +# empty, or MIN_POLYGON_POINTS to MAX_POLYGON_POINTS "x,y" points separated by ';'. HA validates input with it. +# No character classes: the HA frontend compiles it with the JS "v" flag, HA core with Python re. +POLYGON_PATTERN = ( + r"^ *$|^ *-?\d{1,4} *, *\d{1,4} *" + rf"(; *-?\d{{1,4}} *, *\d{{1,4}} *){{{MIN_POLYGON_POINTS - 1},{MAX_POLYGON_POINTS - 1}}};? *$" +) +# Home Assistant's limit for a state; the longest polygon the device publishes is 252 characters +POLYGON_MAX_LENGTH = 255 + +# sizeof(Polygon), pinned by a static_assert in polygon.h +POLYGON_SAVED_BYTES = 94 + +PolygonZone = ld2450_ns.class_("PolygonZone", text.Text) + +_request_polygon_zone_slot = cg.slot_counter("LD2450_POLYGON_ZONE_COUNT") + + +def _require_manual_id(config: ConfigType) -> ConfigType: + if not config[CONF_ID].is_manual: + raise cv.Invalid( + f"'{CONF_ID}' is required, so the zone's presence binary sensor can refer to it", + path=[CONF_ID], + ) + return config + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), + # Polygons are in raw radar mm, checked before sensor filters (polygon_zone.h) + cv.Required(CONF_POLYGON_ZONES): cv.ensure_list( + text.text_schema( + PolygonZone, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_VECTOR_POLYGON, + mode="TEXT", + ), + _require_manual_id, + ), + } +) + + +def _final_validate(config: ConfigType) -> None: + """Each polygon zone must have exactly one presence binary sensor, on the same LD2450.""" + full_config = fv.full_config.get() + # Polygon zone id -> the LD2450 id of each presence binary sensor that uses it + references: dict[str, list[str]] = {} + for binary_sensor_conf in full_config.get("binary_sensor", []): + if binary_sensor_conf.get(CONF_PLATFORM) != "ld2450": + continue + hub_id = binary_sensor_conf[CONF_LD2450_ID].id + for presence_conf in binary_sensor_conf.get(CONF_POLYGON_ZONES, []): + zone_id = presence_conf[CONF_POLYGON_ZONE_ID].id + references.setdefault(zone_id, []).append(hub_id) + + zone_hub_id = config[CONF_LD2450_ID].id + for index, zone_conf in enumerate(config[CONF_POLYGON_ZONES]): + zone_id = zone_conf[CONF_ID].id + hub_ids = references.get(zone_id, []) + if len(hub_ids) != 1: + problem = ( + "has no presence binary sensor" + if not hub_ids + else "is used by more than one presence binary sensor" + ) + raise cv.Invalid( + f"Polygon zone '{zone_id}' {problem}; each zone needs exactly one, under " + f"'binary_sensor: - platform: ld2450' -> '{CONF_POLYGON_ZONES}'", + path=[CONF_POLYGON_ZONES, index], + ) + if hub_ids[0] != zone_hub_id: + raise cv.Invalid( + f"Polygon zone '{zone_id}' belongs to LD2450 '{zone_hub_id}', but its presence " + f"binary sensor is under LD2450 '{hub_ids[0]}'; use the same '{CONF_LD2450_ID}'", + path=[CONF_POLYGON_ZONES, index], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2450_ID]) + # Each zone saves its polygon; the ESP8266 checks that every component's saved values fit + preferences.reserve_preferences( + len(config[CONF_POLYGON_ZONES]), POLYGON_SAVED_BYTES + ) + for zone_conf in config[CONF_POLYGON_ZONES]: + zone = await text.new_text( + zone_conf, max_length=POLYGON_MAX_LENGTH, pattern=POLYGON_PATTERN + ) + _request_polygon_zone_slot(str(hub)) + cg.add(hub.register_polygon_zone(zone)) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 3eaf6c7fcad..01821a4f7ef 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -14,7 +14,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["ld2450"] diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 50d8285cc1c..6075fc93797 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -157,6 +157,7 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 +#define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 diff --git a/tests/component_tests/ld2450/__init__.py b/tests/component_tests/ld2450/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/ld2450/config/esp8266_zones.yaml b/tests/component_tests/ld2450/config/esp8266_zones.yaml new file mode 100644 index 00000000000..19794626809 --- /dev/null +++ b/tests/component_tests/ld2450/config/esp8266_zones.yaml @@ -0,0 +1,31 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +uart: + tx_pin: GPIO0 + rx_pin: GPIO2 + baud_rate: 256000 + +ld2450: + id: radar + +text: + - platform: ld2450 + ld2450_id: radar + polygon_zones: + - id: couch + name: Couch + - id: desk + name: Desk + +binary_sensor: + - platform: ld2450 + ld2450_id: radar + polygon_zones: + - polygon_zone_id: couch + name: Couch Occupied + - polygon_zone_id: desk + name: Desk Occupied diff --git a/tests/component_tests/ld2450/test_polygon_pattern.py b/tests/component_tests/ld2450/test_polygon_pattern.py new file mode 100644 index 00000000000..4fb57ae6894 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_pattern.py @@ -0,0 +1,64 @@ +"""Tests for the polygon zone text pattern and maximum length. + +Home Assistant validates polygon input with the pattern before it reaches the device, so the pattern must accept +what Polygon::parse() accepts. Coordinate ranges are the one exception: only the device checks those. +""" + +from __future__ import annotations + +import re + +import pytest + +from esphome.components.ld2450.text import ( + MAX_POLYGON_POINTS, + POLYGON_MAX_LENGTH, + POLYGON_PATTERN, +) + +# The same inputs as the C++ parser tests (ld2450_polygon.cpp), without out-of-range values +ACCEPTED = [ + "", + " ", + "-1000,500;1000,500;0,3000", + " -1000 , 500 ; 1000,500; 0,3000; ", + "-4860,0;4860,0;4860,7560", +] +REJECTED = [ + "0,0", + "0,0;100,100", + "junk", + "1,2;3", + "1,2;3,4;5,", + "1;2;3", + "1,2 3,4 5,6", + "1,2;;3,4;5,6", + "--1,2;3,4;5,6", + "1,2;3,4;5,6x", + "1,2;\t3,4;5,6", +] + + +@pytest.mark.parametrize("value", ACCEPTED) +def test_pattern_accepts_valid_polygons(value: str) -> None: + assert re.fullmatch(POLYGON_PATTERN, value) + + +@pytest.mark.parametrize("value", REJECTED) +def test_pattern_rejects_invalid_polygons(value: str) -> None: + assert not re.fullmatch(POLYGON_PATTERN, value) + + +def test_point_count_limits() -> None: + assert re.fullmatch(POLYGON_PATTERN, ";".join(["0,0"] * MAX_POLYGON_POINTS)) + assert not re.fullmatch( + POLYGON_PATTERN, ";".join(["0,0"] * (MAX_POLYGON_POINTS + 1)) + ) + + +def test_longest_polygon_fits_max_length() -> None: + longest = ";".join(["-4860,7560"] * MAX_POLYGON_POINTS) + assert re.fullmatch(POLYGON_PATTERN, longest) + assert len(longest) <= POLYGON_MAX_LENGTH + # MAX_POLYGON_POINTS is the most that always fits + assert len(longest + ";-4860,7560") > POLYGON_MAX_LENGTH diff --git a/tests/component_tests/ld2450/test_polygon_zone_presence.py b/tests/component_tests/ld2450/test_polygon_zone_presence.py new file mode 100644 index 00000000000..8ea8e3a67a0 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_zone_presence.py @@ -0,0 +1,45 @@ +"""Tests for the ld2450 polygon zone presence binary sensor schema.""" + +from __future__ import annotations + +import pytest + +from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES +from esphome.components.ld2450.binary_sensor import ( + CONFIG_SCHEMA as BINARY_SENSOR_CONFIG_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import CONF_FILTERS, CONF_NAME, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _presence(**extra: object) -> ConfigType: + return { + CONF_POLYGON_ZONES: [ + {CONF_POLYGON_ZONE_ID: "couch_zone", CONF_NAME: "Couch Occupied", **extra} + ] + } + + +def test_presence_without_filters_passes( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)(_presence()) + + +def test_presence_with_filters_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Clearing a polygon bypasses filters, so they would get out of step with the zone.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone presence binary sensors can't have filters; .*presence_timeout", + ): + cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)( + _presence(**{CONF_FILTERS: [{"delayed_off": "1s"}]}) + ) diff --git a/tests/component_tests/ld2450/test_polygon_zones.py b/tests/component_tests/ld2450/test_polygon_zones.py new file mode 100644 index 00000000000..f63d3336782 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_zones.py @@ -0,0 +1,163 @@ +"""Tests for the ld2450 polygon zone validation. + +Polygon zones are declared in the text platform and their presence sensors in the +binary sensor platform, so the pairing between the two is checked at final validation. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + preference_words, +) +from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES +from esphome.components.ld2450.text import ( + CONFIG_SCHEMA as TEXT_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as TEXT_FINAL_VALIDATE_SCHEMA, + POLYGON_SAVED_BYTES, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_NAME, CONF_PLATFORM, PlatformFramework +from esphome.core import CORE, ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "ld2450_hub" +OTHER_HUB_ID = "ld2450_other" + + +def _text_config(*zone_ids: str) -> ConfigType: + """A text platform config declaring the given polygon zones.""" + return { + "ld2450_id": ID(HUB_ID, is_declaration=False, type="ld2450"), + CONF_POLYGON_ZONES: [ + {CONF_ID: ID(zone_id, is_declaration=True), CONF_NAME: zone_id} + for zone_id in zone_ids + ], + } + + +def _binary_sensors(*zone_ids: str, hub_id: str = HUB_ID) -> list[ConfigType]: + """A binary sensor platform list with one presence sensor per given zone reference.""" + return [ + { + CONF_PLATFORM: "ld2450", + "ld2450_id": ID(hub_id, is_declaration=False, type="ld2450"), + CONF_POLYGON_ZONES: [ + {CONF_POLYGON_ZONE_ID: ID(zone_id, is_declaration=False)} + for zone_id in zone_ids + ], + } + ] + + +def test_zones_paired_with_one_presence_pass( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch", "desk")}, + ) + + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk")) + + +def test_zone_without_presence_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch")}, + ) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone 'desk' has no presence binary sensor; .* @ data\['polygon_zones'\]\[1\]$", + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk")) + + +def test_zone_without_any_binary_sensor_platform_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={}) + + with pytest.raises(cv.Invalid, match="'couch' has no presence binary sensor"): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_zone_with_two_presences_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Two references to one zone are rejected, even across binary sensor platform entries.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={ + "binary_sensor": _binary_sensors("couch") + _binary_sensors("couch") + }, + ) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone 'couch' is used by more than one presence binary sensor; ", + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_other_platforms_are_ignored( + set_core_config: SetCoreConfigCallable, +) -> None: + """Binary sensors of other platforms never count as presence for a zone.""" + other = {CONF_PLATFORM: "template", CONF_POLYGON_ZONES: []} + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": [other, *_binary_sensors("couch")]}, + ) + + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_zone_without_id_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The presence sensor refers to the zone by id, so an automatic id is no use.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match=r"^'id' is required, so the zone's presence"): + TEXT_CONFIG_SCHEMA({CONF_POLYGON_ZONES: [{CONF_NAME: "Couch Zone Polygon"}]}) + + +def test_presence_on_another_ld2450_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The zone's own LD2450 drives its presence, so the sensor must name the same one.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch", hub_id=OTHER_HUB_ID)}, + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^Polygon zone 'couch' belongs to LD2450 'ld2450_hub', but its presence binary " + r"sensor is under LD2450 'ld2450_other'; .* @ data\['polygon_zones'\]\[0\]$" + ), + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_esp8266_zones_reserve_their_saved_space( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every zone reserves its polygon, so the ESP8266 can check that all saved values fit together.""" + generate_main(component_config_path("esp8266_zones.yaml")) + + words = preference_words(POLYGON_SAVED_BYTES) + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(words, False)] * 2 diff --git a/tests/components/ld2450/__init__.py b/tests/components/ld2450/__init__.py new file mode 100644 index 00000000000..a4f30dd1f7c --- /dev/null +++ b/tests/components/ld2450/__init__.py @@ -0,0 +1,9 @@ +from esphome.loader import FileResource +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # ld2450 is MULTI_CONF, so no codegen runs here to keep polygon.cpp; the tests need it + manifest.resources = manifest.resources + [ + FileResource("esphome.components.ld2450", "polygon.cpp") + ] diff --git a/tests/components/ld2450/common.yaml b/tests/components/ld2450/common.yaml index 617228ca34a..f0d5ecb04fd 100644 --- a/tests/components/ld2450/common.yaml +++ b/tests/components/ld2450/common.yaml @@ -96,6 +96,20 @@ binary_sensor: name: Moving Target has_still_target: name: Still Target + polygon_zones: + - polygon_zone_id: ld2450_couch_zone + name: Couch Occupied + - polygon_zone_id: ld2450_desk_zone + name: Desk Occupied + +text: + - platform: ld2450 + ld2450_id: ld2450_radar + polygon_zones: + - id: ld2450_couch_zone + name: Couch Zone Polygon + - id: ld2450_desk_zone + name: Desk Zone Polygon switch: - platform: ld2450 diff --git a/tests/components/ld2450/ld2450_polygon.cpp b/tests/components/ld2450/ld2450_polygon.cpp new file mode 100644 index 00000000000..b087e83d9c8 --- /dev/null +++ b/tests/components/ld2450/ld2450_polygon.cpp @@ -0,0 +1,162 @@ +#include +#include +#include "esphome/components/ld2450/polygon.h" + +namespace esphome::ld2450::testing { + +static bool parse(Polygon &polygon, const char *str) { return polygon.parse(str, strlen(str)); } + +static std::string format(const Polygon &polygon) { + char buf[POLYGON_STR_SIZE]; + polygon.format(buf); + return buf; +} + +// --- Parsing --- + +TEST(LD2450PolygonTest, ParseValid) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_EQ(polygon.count, 3); + EXPECT_EQ(polygon.points[0].x, -1000); + EXPECT_EQ(polygon.points[0].y, 500); + EXPECT_EQ(polygon.points[2].x, 0); + EXPECT_EQ(polygon.points[2].y, 3000); + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000"); +} + +TEST(LD2450PolygonTest, ParseAllowsSpacesAndTrailingSeparator) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, " -1000 , 500 ; 1000,500; 0,3000; ")); + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000"); +} + +TEST(LD2450PolygonTest, ParseEmptyDisablesZone) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_TRUE(parse(polygon, "")); + EXPECT_TRUE(polygon.empty()); + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_TRUE(parse(polygon, " ")); + EXPECT_TRUE(polygon.empty()); + EXPECT_EQ(format(polygon), ""); +} + +TEST(LD2450PolygonTest, ParseLimits) { + Polygon polygon; + EXPECT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4861,0;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,0;4861,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,-1;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,7561")); + EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,99999999999")); +} + +TEST(LD2450PolygonTest, ParsePointCount) { + Polygon polygon; + EXPECT_FALSE(parse(polygon, "0,0")); + EXPECT_FALSE(parse(polygon, "0,0;100,100")); + + std::string text; + for (int i = 0; i < MAX_POLYGON_POINTS; i++) { + text += "-4860,7560;"; + } + ASSERT_TRUE(parse(polygon, text.c_str())); + EXPECT_EQ(polygon.count, MAX_POLYGON_POINTS); + // The longest possible polygon fits in the format buffer + EXPECT_EQ(format(polygon) + ";", text); + + text += "0,0"; + EXPECT_FALSE(parse(polygon, text.c_str())); +} + +TEST(LD2450PolygonTest, ParseInvalidKeepsPreviousPolygon) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + const char *invalid[] = { + "junk", + "1,2;3", + "1,2;3,4;5,", + "1;2;3", + "1,2 3,4 5,6", + "1,2;;3,4;5,6", + "--1,2;3,4;5,6", + "1,2;3,4;5,6x", + // Only spaces are allowed around numbers, as in the text entity's pattern + "1,2;\t3,4;5,6", + }; + for (const char *str : invalid) { + EXPECT_FALSE(parse(polygon, str)) << str; + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000") << str; + } +} + +TEST(LD2450PolygonTest, IsValid) { + Polygon polygon; + EXPECT_TRUE(polygon.is_valid()); + polygon.count = 2; + EXPECT_FALSE(polygon.is_valid()); + polygon.count = MAX_POLYGON_POINTS + 1; + EXPECT_FALSE(polygon.is_valid()); + polygon.count = 3; + EXPECT_TRUE(polygon.is_valid()); + polygon.points[1].y = -5; + EXPECT_FALSE(polygon.is_valid()); +} + +// --- Point in polygon --- + +TEST(LD2450PolygonTest, ContainsEmpty) { + Polygon polygon; + EXPECT_FALSE(polygon.contains(0, 0)); + EXPECT_FALSE(polygon.contains(100, 1000)); +} + +TEST(LD2450PolygonTest, ContainsSquare) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,1000;1000,1000;1000,3000;-1000,3000")); + EXPECT_TRUE(polygon.contains(0, 2000)); + EXPECT_TRUE(polygon.contains(-999, 1001)); + EXPECT_TRUE(polygon.contains(999, 2999)); + EXPECT_FALSE(polygon.contains(0, 999)); + EXPECT_FALSE(polygon.contains(0, 3001)); + EXPECT_FALSE(polygon.contains(-1001, 2000)); + EXPECT_FALSE(polygon.contains(1001, 2000)); +} + +TEST(LD2450PolygonTest, ContainsWithReversedWinding) { + Polygon clockwise; + Polygon counter_clockwise; + ASSERT_TRUE(parse(clockwise, "0,1000;2000,3000;-2000,3000")); + ASSERT_TRUE(parse(counter_clockwise, "-2000,3000;2000,3000;0,1000")); + for (int16_t x = -2500; x <= 2500; x += 250) { + for (int16_t y = 500; y <= 3500; y += 250) { + EXPECT_EQ(clockwise.contains(x, y), counter_clockwise.contains(x, y)) << x << "," << y; + } + } + EXPECT_TRUE(clockwise.contains(0, 2000)); + EXPECT_FALSE(clockwise.contains(1500, 2000)); +} + +TEST(LD2450PolygonTest, ContainsConcave) { + // U shape: the notch between the two arms is outside + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-3000,1000;3000,1000;3000,4000;1000,4000;1000,2000;-1000,2000;-1000,4000;-3000,4000")); + EXPECT_TRUE(polygon.contains(-2000, 3000)); + EXPECT_TRUE(polygon.contains(2000, 3000)); + EXPECT_TRUE(polygon.contains(0, 1500)); + EXPECT_FALSE(polygon.contains(0, 3000)); + EXPECT_FALSE(polygon.contains(0, 4500)); +} + +TEST(LD2450PolygonTest, ContainsFarOutsideRadarArea) { + // The radar reports 15-bit coordinates; the cross products must not overflow + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560;-4860,7560")); + EXPECT_FALSE(polygon.contains(32767, 32767)); + EXPECT_FALSE(polygon.contains(-32767, -32767)); + EXPECT_FALSE(polygon.contains(-32767, 3000)); + EXPECT_TRUE(polygon.contains(0, 3000)); +} + +} // namespace esphome::ld2450::testing diff --git a/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml b/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml new file mode 100644 index 00000000000..df0b4ceb518 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml @@ -0,0 +1,248 @@ +esphome: + name: uart-mock-ld2450-has-target + +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 ld2450'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 + auto_start: false + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # One frame every 100 ms, as the radar sends them. Coordinates are sign-magnitude, with the high bit set for + # positive values. + injections: + # Target 1 at (-500, 1000) + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # No targets for 2 s: has_target must hold for the default 5 s presence_timeout + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +# Only has_target: no sensor platform and no presence_timeout number +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_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_ld2450_polygon_zone_restore.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml new file mode 100644 index 00000000000..ad7aec4e8c0 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml @@ -0,0 +1,55 @@ +esphome: + name: uart-mock-ld2450-zone-restore + +host: +api: + batch_delay: 0ms +logger: + level: DEBUG + +preferences: + flash_write_interval: 0s + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450'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: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: restore_zone + name: "Restore Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: restore_zone + name: "Restore Zone Presence" diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml new file mode 100644 index 00000000000..112e75f5ab7 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml @@ -0,0 +1,300 @@ +esphome: + name: uart-mock-ld2450-zone-timeout + +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 ld2450'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 + auto_start: false + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # One frame every 100 ms, as the radar sends them. Target 1 is either at (-500, 1000), inside the zone, or at + # (300, 400), outside it. Coordinates are sign-magnitude, with the high bit set for positive values. + injections: + # Inside the zone + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside for one frame: radar noise that presence_timeout must hide + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Inside again: the last inside frame + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside: presence clears once presence_timeout passes + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: hold_zone + name: "Hold Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: hold_zone + name: "Hold Zone Presence" + +number: + - platform: ld2450 + ld2450_id: ld2450_dev + presence_timeout: + name: "Presence Timeout" + +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_ld2450_polygon_zones.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml new file mode 100644 index 00000000000..5cc4784f814 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml @@ -0,0 +1,246 @@ +esphome: + name: uart-mock-ld2450-zones + +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 ld2450'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 + auto_start: false + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + injections: + # Frame 1: target 1 at (-500, 1000), target 2 at (200, 500) + # Coordinates are sign-magnitude, with the high bit set for positive values. + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0xC8, 0x80, 0xF4, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # Frame 2: target 1 at (300, 400), no other targets + - delay: 200ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # Frame 3: no targets + - delay: 200ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + + # No targets, frame after frame, until presence_timeout (1 s) has cleared every zone + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: left_zone + name: "Left Zone Polygon" + - id: near_zone + name: "Near Zone Polygon" + - id: unset_zone + name: "Unset Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: left_zone + name: "Left Zone Presence" + - polygon_zone_id: near_zone + name: "Near Zone Presence" + - polygon_zone_id: unset_zone + name: "Unset Zone Presence" + +number: + - platform: ld2450 + ld2450_id: ld2450_dev + presence_timeout: + name: "Presence Timeout" + +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_ld2450_has_target_only.py b/tests/integration/test_uart_mock_ld2450_has_target_only.py new file mode 100644 index 00000000000..56e66ad65fb --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_has_target_only.py @@ -0,0 +1,64 @@ +"""Integration test for LD2450 has_target without the sensor and number platforms. + +Tests: +1. has_target turns on when only binary sensors are configured (target coordinates are still decoded) +2. has_target holds for the default presence_timeout of 5 s, without the presence_timeout number +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import BinarySensorInfo, BinarySensorState, ButtonInfo, EntityState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The fixture sends one frame with a target, then 2 s of frames without one +SCENARIO_S = 2.1 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_has_target_only( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test has_target with no sensor platform and no presence_timeout number.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + has_target = require_entity(entities, "has_target", BinarySensorInfo) + history: list[bool] = [] + turned_on = asyncio.get_running_loop().create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key == has_target.key: + history.append(state.state) + if state.state and not turned_on.done(): + turned_on.set_result(None) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + await asyncio.wait_for(turned_on, timeout=5.0) + await asyncio.sleep(SCENARIO_S) + + assert history == [True], ( + "has_target must turn on and hold through 2 s without targets, " + f"got {history}" + ) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py new file mode 100644 index 00000000000..2b25e65e190 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py @@ -0,0 +1,117 @@ +"""Integration test for restoring LD2450 polygon zones from saved preferences. + +Tests: +1. A polygon set from the API is restored after a restart +2. A saved polygon that is no longer valid is reported and leaves the zone disabled +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import socket +import struct + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs, read_host_prefs, write_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "uart-mock-ld2450-zone-restore" +ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +# The saved Polygon starts with its points as little-endian int16 x, y pairs +SAVED_POLYGON_PREFIX = struct.pack(" None: + """Test that polygon zones are restored after a restart, and invalid saved polygons are rejected.""" + port, port_socket = reserved_tcp_port + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + clear_host_prefs(DEVICE_NAME) + + binary_path = await compile_esphome(await write_yaml_config(yaml_config)) + # Release the reserved port so the binary can bind to it + port_socket.close() + + async def initial_polygon(line_callback=None) -> str: + """Start the binary and return the zone's polygon state as first published.""" + async with ( + run_binary_and_wait_for_port( + binary_path, "127.0.0.1", port, line_callback=line_callback + ), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "restore_zone_polygon", TextInfo) + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(lambda state: None)) + await helper.wait_for_initial_states() + state = helper.initial_states[polygon.key] + assert isinstance(state, TextState) + return state.state + + # First run: set a polygon, which is saved + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "restore_zone_polygon", TextInfo) + loop = asyncio.get_running_loop() + published = loop.create_future() + + def on_state(state: object) -> None: + if ( + isinstance(state, TextState) + and state.key == polygon.key + and state.state == ZONE_POLYGON + and not published.done() + ): + published.set_result(None) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + assert helper.initial_states[polygon.key].state == "" + + client.text_command(polygon.key, ZONE_POLYGON) + await asyncio.wait_for(published, timeout=5.0) + + # Second run: the polygon is restored + assert await initial_polygon() == ZONE_POLYGON + + # Third run: a saved polygon with too few points is rejected + prefs = read_host_prefs(DEVICE_NAME) + keys = [key for key, data in prefs.items() if data.startswith(SAVED_POLYGON_PREFIX)] + assert len(keys) == 1, f"saved polygon not found in {prefs}" + data = bytearray(prefs[keys[0]]) + data[COUNT_OFFSET] = 2 + prefs[keys[0]] = bytes(data) + write_host_prefs(DEVICE_NAME, prefs) + + warnings: list[str] = [] + + def capture_warning(line: str) -> None: + if "saved polygon is invalid" in line: + warnings.append(line) + + assert await initial_polygon(capture_warning) == "" + assert warnings, "expected a warning about the invalid saved polygon" + + clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py new file mode 100644 index 00000000000..9aba119713f --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py @@ -0,0 +1,102 @@ +"""Integration test for LD2450 polygon zone presence with presence_timeout. + +Tests: +1. A target leaving the zone for a single radar frame does not clear presence +2. Presence clears once presence_timeout has passed since a target was last inside the zone +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ( + BinarySensorInfo, + BinarySensorState, + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + TextInfo, + TextState, +) +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "uart-mock-ld2450-zone-timeout" +ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +PRESENCE_TIMEOUT_S = 1 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_polygon_zone_timeout( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that polygon zone presence follows presence_timeout.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + # The polygon and presence_timeout are saved, so a previous run would otherwise restore them + clear_host_prefs(DEVICE_NAME) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "hold_zone_polygon", TextInfo) + presence = require_entity(entities, "hold_zone_presence", BinarySensorInfo) + timeout = require_entity(entities, "presence_timeout", NumberInfo) + + loop = asyncio.get_running_loop() + # (time received, state) for each presence change + presence_history: list[tuple[float, bool]] = [] + cleared = loop.create_future() + polygon_set = loop.create_future() + timeout_set = loop.create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key == presence.key: + presence_history.append((loop.time(), state.state)) + if state.state is False and not cleared.done(): + cleared.set_result(None) + elif ( + isinstance(state, TextState) + and state.key == polygon.key + and state.state == ZONE_POLYGON + and not polygon_set.done() + ): + polygon_set.set_result(None) + elif ( + isinstance(state, NumberState) + and state.key == timeout.key + and state.state == PRESENCE_TIMEOUT_S + and not timeout_set.done() + ): + timeout_set.set_result(None) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + client.text_command(polygon.key, ZONE_POLYGON) + client.number_command(timeout.key, PRESENCE_TIMEOUT_S) + await asyncio.wait_for(asyncio.gather(polygon_set, timeout_set), timeout=5.0) + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + await asyncio.wait_for(cleared, timeout=5.0) + + # The one-frame blip outside never reaches HA + assert [state for _, state in presence_history] == [True, False] + # The last inside frame comes 200 ms after the first, then presence_timeout must pass + (on_time, _), (off_time, _) = presence_history + assert off_time - on_time >= PRESENCE_TIMEOUT_S diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zones.py b/tests/integration/test_uart_mock_ld2450_polygon_zones.py new file mode 100644 index 00000000000..c77b7288e4c --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zones.py @@ -0,0 +1,160 @@ +"""Integration test for LD2450 polygon zones with mock UART. + +Tests: +1. Polygons start empty and their presence sensors start unknown +2. A polygon set from the API is published back in canonical form +3. Invalid polygon text is rejected and the previous polygon is published again +4. Presence turns on and off as targets move in and out of each polygon +5. A zone without a polygon keeps presence unknown, and clearing a polygon makes it unknown again +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +from aioesphomeapi import ( + BinarySensorInfo, + BinarySensorState, + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + TextInfo, + TextState, +) +import pytest + +from esphome.components.ld2450.text import POLYGON_MAX_LENGTH, POLYGON_PATTERN + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "uart-mock-ld2450-zones" +LEFT_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +NEAR_POLYGON = "0,0;1000,0;1000,600;0,600" + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_polygon_zones( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2450 polygon zone configuration and presence detection.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + # Polygons are saved, so a previous run would otherwise restore them + clear_host_prefs(DEVICE_NAME) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + polygons = { + name: require_entity(entities, f"{name}_zone_polygon", TextInfo) + for name in ("left", "near", "unset") + } + presences = { + name: require_entity(entities, f"{name}_zone_presence", BinarySensorInfo) + for name in ("left", "near", "unset") + } + presence_names = {info.key: name for name, info in presences.items()} + # None records presence going back to unknown + presence_history: dict[str, list[bool | None]] = { + name: [] for name in presences + } + waiter = StateWaiter() + expect = waiter.expect + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key in presence_names: + presence_history[presence_names[state.key]].append( + None if state.missing_state else state.state + ) + waiter.on_state(state) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + for name, polygon in polygons.items(): + # Home Assistant validates input and the zone card finds zones with these + assert polygon.pattern == POLYGON_PATTERN + assert polygon.max_length == POLYGON_MAX_LENGTH + assert initial_state_helper.initial_states[polygon.key].state == "" + assert initial_state_helper.initial_states[ + presences[name].key + ].missing_state + + async def set_polygon(name: str, value: str, expected: str) -> None: + key = polygons[name].key + published = expect(lambda s: isinstance(s, TextState) and s.key == key) + client.text_command(key, value) + state = await published + assert state.state == expected + assert re.fullmatch(POLYGON_PATTERN, state.state) + + # Spaces and a trailing separator are accepted and removed + await set_polygon( + "left", f" {LEFT_POLYGON.replace(';', ' ; ')} ;", LEFT_POLYGON + ) + await set_polygon("near", NEAR_POLYGON, NEAR_POLYGON) + # Invalid text keeps the polygon in use + await set_polygon("near", "0,0;1000,0", NEAR_POLYGON) + await set_polygon("near", "junk", NEAR_POLYGON) + + # Zone presence follows presence_timeout; keep the scenario short + timeout = require_entity(entities, "presence_timeout", NumberInfo) + timeout_set = expect( + lambda s: ( + isinstance(s, NumberState) and s.key == timeout.key and s.state == 1 + ) + ) + client.number_command(timeout.key, 1) + await timeout_set + + left_cleared = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["left"].key + and s.state is False + ) + ) + near_cleared = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["near"].key + and s.state is False + ) + ) + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + + await asyncio.gather(left_cleared, near_cleared) + + # Frame 1: (-500, 1000) is in the left zone and (200, 500) is in the near zone. + # Frame 2: (300, 400) is in the near zone only; the left zone clears 1 s after frame 1. + # Frame 3 onwards: no targets; the near zone clears 1 s after frame 2. + assert presence_history["left"] == [True, False] + assert presence_history["near"] == [True, False] + assert presence_history["unset"] == [] + + left_unknown = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["left"].key + and s.missing_state + ) + ) + await set_polygon("left", "", "") + await left_unknown + assert presence_history["left"] == [True, False, None] From e869d1f7ab374ca862eba02964a042c4e87938ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 09:56:09 -1000 Subject: [PATCH 378/651] [ci] Give the BK72xx clang-tidy environment the static allocation flag the real build has (#20764) --- platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platformio.ini b/platformio.ini index 9e05356665a..fe61470a358 100644 --- a/platformio.ini +++ b/platformio.ini @@ -599,6 +599,8 @@ build_flags = ${flags:clangtidy.build_flags} -DUSE_BK72XX -DUSE_LIBRETINY_VARIANT_BK7231N + ; The libretiny component adds this at code generation so FreeRTOSQueue can use xQueueCreateStatic + -DconfigSUPPORT_STATIC_ALLOCATION=1 build_unflags = ${common.build_unflags} From 3c4cf50cb6b844e098e47d3d6b64fd55939a7fac Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:02:51 +0100 Subject: [PATCH 379/651] [core] Point compile errors after a lambda at the right line (#20740) Co-authored-by: J. Nick Koston --- esphome/core/__init__.py | 13 ++++++-- esphome/cpp_generator.py | 12 ++++--- esphome/writer.py | 23 +++++++++++-- esphome/yaml_util.py | 17 +++++++--- tests/unit_tests/test_cpp_generator.py | 45 ++++++++++++++++++++++++-- tests/unit_tests/test_writer.py | 30 ++++++++++++++++- tests/unit_tests/test_yaml_util.py | 18 +++++++++++ 7 files changed, 142 insertions(+), 16 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 61092ec4272..9b8f9f98642 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -442,6 +442,16 @@ class ID: ) +# Replaced by write_cpp with a #line back into main.cpp +LINE_DIRECTIVE_RESET = "// ESPHOME_LINE_DIRECTIVE_RESET" + + +def line_directive(document: str, line: int) -> str: + """A #line directive naming the 1-based ``line`` of ``document``.""" + escaped = document.replace("\\", "\\\\") + return f'#line {line} "{escaped}"' + + class DocumentLocation: def __init__(self, document: str, line: int, column: int): self.document: str = document @@ -457,8 +467,7 @@ class DocumentLocation: @property def as_line_directive(self): - document_path = str(self.document).replace("\\", "\\\\") - return f'#line {self.line + 1} "{document_path}"' + return line_directive(str(self.document), self.line + 1) class DocumentRange: diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 1196669662c..d0fc126957f 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -8,6 +8,7 @@ from typing import Any from esphome.core import ( CORE, ID, + LINE_DIRECTIVE_RESET, Define, EnumValue, EsphomeError, @@ -214,9 +215,13 @@ class LambdaExpression(Expression): if self.return_type is not None: cpp += f" -> {self.return_type}" cpp += " {\n" + body = f"{self.content}\n" if self.source is not None: - cpp += f"{self.source.as_line_directive}\n" - cpp += f"{self.content}\n}}" + body = f"{self.source.as_line_directive}\n{body}" + # A #line in the body, the source's or lvgl's own marks, needs the reset after it + if "#line " in body: + body += f"{LINE_DIRECTIVE_RESET}\n" + cpp += f"{body}}}" return indent_all_but_first_and_last(cpp) @property @@ -946,8 +951,7 @@ async def process_lambda( parts[i * 3 + 2] = "" if isinstance(value, ESPHomeDataBase) and value.esp_range is not None: - location = value.esp_range.start_mark - location.line += value.content_offset + location = value.content_start else: location = None return LambdaExpression(parts, parameters, capture, return_type, location) diff --git a/esphome/writer.py b/esphome/writer.py index 559d7d85828..934595ef7e8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -15,7 +15,7 @@ from esphome.const import ( SOURCE_FILE_EXTENSIONS, __version__, ) -from esphome.core import CORE, EsphomeError +from esphome.core import CORE, LINE_DIRECTIVE_RESET, EsphomeError, line_directive from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -514,6 +514,25 @@ const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; """ +def _reset_line_directives(text: str, path: Path) -> str: + """Point the code after each lambda back at its own line of *path*. + + A lambda's #line directive points the compiler at the YAML; without a reset + every later line of main.cpp would be reported against the YAML too. + """ + if LINE_DIRECTIVE_RESET not in text: + return text + document = str(path.absolute()) + lines = text.split("\n") + for index, line in enumerate(lines): + if line.endswith(LINE_DIRECTIVE_RESET): + # The directive numbers the line after it: index + 2, counted from 1 + lines[index] = line.replace( + LINE_DIRECTIVE_RESET, line_directive(document, index + 2) + ) + return "\n".join(lines) + + def write_cpp(code_s): path = CORE.relative_src_path("main.cpp") if path.is_file(): @@ -540,7 +559,7 @@ def write_cpp(code_s): f"{code_format[1] + CPP_AUTO_GENERATE_BEGIN}\n{code_s}{CPP_AUTO_GENERATE_END}" ) full_file += code_format[2] - write_file_if_changed(path, full_file) + write_file_if_changed(path, _reset_line_directives(full_file, path)) def clean_cmake_cache(): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 261d8b50e36..6b7e49d3802 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -29,6 +29,7 @@ from esphome.config_helpers import Extend, Remove from esphome.const import CONF_DEFAULTS from esphome.core import ( CORE, + DocumentLocation, DocumentRange, EsphomeError, Lambda, @@ -142,14 +143,20 @@ class ESPHomeDataBase: def content_offset(self): return getattr(self, "_content_offset", 0) + @property + def content_start(self) -> DocumentLocation: + """Where the value's own text starts: the line after the marker for a block scalar.""" + start = self._esp_range.start_mark + return DocumentLocation( + start.document, start.line + self.content_offset, start.column + ) + def from_node(self, node): # pylint: disable=attribute-defined-outside-init self._esp_range = DocumentRange.from_marks(node.start_mark, node.end_mark) - if ( - isinstance(node, yaml.ScalarNode) - and node.style is not None - and node.style in "|>" - ): + # Only block scalars (| and >) start their content on the next line. A plain + # scalar's style is "", which `in "|>"` would also match. + if isinstance(node, yaml.ScalarNode) and node.style in ("|", ">"): self._content_offset = 1 def from_database(self, database): diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 9529e448008..ec88c583baa 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -1,16 +1,19 @@ from collections.abc import Iterator +import io import math +from pathlib import Path import pytest -from esphome import cpp_generator as cg, cpp_types as ct +from esphome import config_validation as cv, 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 +from esphome.core import CORE, ID, LINE_DIRECTIVE_RESET, DocumentLocation, EsphomeError +from esphome.yaml_util import parse_yaml class TestExpressions: @@ -244,6 +247,22 @@ class TestLambdaExpression: "[captured_var](int32_t x) -> int32_t {\n return captured_var + x;\n}" ) + def test_str__with_source_marks_end_of_body(self) -> None: + """A lambda from the YAML marks the end of its body for the line reset.""" + target = cg.LambdaExpression( + ("return 42;",), (), "", int, source=DocumentLocation("test.yaml", 4, 0) + ) + + actual = str(target) + + assert actual == ( + "[]() -> int32_t {\n" + ' #line 5 "test.yaml"\n' + " return 42;\n" + f" {LINE_DIRECTIVE_RESET}\n" + "}" + ) + class TestCallLambda: """Tests for the call_lambda() function.""" @@ -735,6 +754,28 @@ class TestProcessLambda: with pytest.raises(AssertionError): await cg.process_lambda(lambda_obj, [(int,)]) + def test_str__with_line_mark_in_body_marks_end_of_body(self) -> None: + """A #line written into the body, as lvgl does, gets the reset too.""" + target = cg.LambdaExpression(('#line 3 "test.yaml"\nfoo();',), (), "", None) + + actual = str(target) + + assert actual == ( + f'[]() {{\n #line 3 "test.yaml"\n foo();\n {LINE_DIRECTIVE_RESET}\n}}' + ) + + async def test_process_lambda__twice_keeps_line(self) -> None: + """Processing one lambda twice, as a YAML alias does, keeps its line.""" + config = parse_yaml(Path("test.yaml"), io.StringIO("x: |-\n return 42;\n")) + lambda_obj = cv.lambda_(config["x"]) + + first = await cg.process_lambda(lambda_obj, []) + second = await cg.process_lambda(lambda_obj, []) + + # The body of the block scalar is the second line, 1 counted from 0 + assert first.source.line == 1 + assert second.source.line == 1 + @pytest.mark.asyncio async def test_templatable__string_with_std_string_returns_flash_literal() -> None: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index a1ae7f9ec77..67acfa6a189 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -22,7 +22,9 @@ from esphome.const import ( PLATFORM_RP2, PLATFORM_RTL87XX, ) -from esphome.core import EsphomeError +from esphome.core import DocumentLocation, EsphomeError, line_directive +from esphome.cpp_generator import LambdaExpression +from esphome.helpers import indent from esphome.storage_json import StorageJSON from esphome.writer import ( CPP_AUTO_GENERATE_BEGIN, @@ -965,6 +967,32 @@ def test_write_cpp_creates_new_file( assert "App.loop();" in written_content +@pytest.mark.usefixtures("mock_copy_src_tree") +@patch("esphome.writer.write_file_if_changed") +@patch("esphome.writer.CORE") +def test_write_cpp_resets_line_after_lambda( + mock_core: MagicMock, + mock_write_file: MagicMock, + tmp_path: Path, +) -> None: + """The code after a YAML lambda is reported against main.cpp again.""" + main_cpp = tmp_path / "main.cpp" + mock_core.relative_src_path.return_value = main_cpp + mock_core.cpp_global_section = "" + lambda_ = LambdaExpression( + ("foo();",), (), source=DocumentLocation("test.yaml", 4, 0) + ) + + write_cpp(indent(f"bar({lambda_});\nbaz();") + "\n") + + lines = mock_write_file.call_args[0][1].split("\n") + closing = lines.index(" });") + assert lines[closing - 3].strip() == '#line 5 "test.yaml"' + # The directive names the line after it, which is closing + 1 (1-based) + assert lines[closing - 1].strip() == line_directive(str(main_cpp), closing + 1) + assert lines[closing + 1] == " baz();" + + @pytest.mark.usefixtures("mock_copy_src_tree") @patch("esphome.writer.CORE") def test_write_cpp_with_missing_end_marker( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 655fac6bf26..2a4b5f8e5c5 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -2317,3 +2317,21 @@ 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"} + + +@pytest.mark.parametrize( + ("yaml_text", "expected_offset"), + [ + ("value: return 42;\n", 0), + ('value: "return 42;"\n', 0), + ("value: |-\n return 42;\n", 1), + ("value: >-\n return 42;\n", 1), + ], +) +def test_content_offset_only_for_block_scalars( + yaml_text: str, expected_offset: int +) -> None: + """A one-line lambda starts on its own line; only | and > move it to the next.""" + doc = yaml_util.parse_yaml(Path("test.yaml"), io.StringIO(yaml_text)) + assert doc["value"].content_offset == expected_offset + assert doc["value"].content_start.line == expected_offset From 1779b011c9bbb55b0d441f8296b740189487e4e4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:21:45 +0000 Subject: [PATCH 380/651] Bump aioesphomeapi from 46.10.0 to 46.11.0 (#20766) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5397959f63f..a247eb4ba15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.10.0 +aioesphomeapi==46.11.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 6d173c7371a00f76e39d1f46faaa5cdd2ec274d5 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:35:01 +0100 Subject: [PATCH 381/651] [bq25186] Set i2c_id in the component tests (#20742) --- tests/components/bq25186/common.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml index 295a68d262b..916fedec3d1 100644 --- a/tests/components/bq25186/common.yaml +++ b/tests/components/bq25186/common.yaml @@ -12,6 +12,7 @@ esphome: bq25186: - id: bq25186_charger + i2c_id: i2c_bus configure_on_boot: false fast_charge_current: 150mA battery_regulation_voltage: 4.2V @@ -20,6 +21,7 @@ bq25186: charge_enabled: true # Second charger with the boot-time write enabled - id: bq25186_boot + i2c_id: i2c_bus address: 0x6B configure_on_boot: true fast_charge_current: 20mA From a4278b8decaa087f593f4a50418e8b2167e2df31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:35:33 -1000 Subject: [PATCH 382/651] [ci] Run benchmarks when a component the Python import benchmark imports changes (#20763) --- script/determine-jobs.py | 68 ++++++++++++++++++++++++ tests/script/test_determine_jobs.py | 82 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 9d146c94a0b..e1483446d4d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -49,6 +49,7 @@ Options: from __future__ import annotations import argparse +import ast from collections import Counter from collections.abc import Callable from enum import StrEnum @@ -58,6 +59,7 @@ import math import os from pathlib import Path import statistics +import subprocess import sys from typing import Any @@ -69,6 +71,7 @@ from clang_tidy_hash import ( ) from helpers import ( CPP_FILE_EXTENSIONS, + ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH, INTEGRATION_TESTS_PATH, PYTHON_FILE_EXTENSIONS, @@ -805,6 +808,9 @@ def determine_cpp_unit_tests( # Paths within tests/benchmarks/ that contain component benchmark files BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components" +# The Python benchmark that imports components; its COMPONENTS tuple says which ones +PYTHON_IMPORT_BENCHMARK_FILE = "tests/benchmarks/python/test_component_import_bench.py" + # Files that, when changed, should trigger benchmark runs BENCHMARK_INFRASTRUCTURE_FILES = frozenset( { @@ -815,6 +821,53 @@ BENCHMARK_INFRASTRUCTURE_FILES = frozenset( ) +def _benchmark_components_tuple(path: Path) -> frozenset[str]: + """Return the benchmark file's ``COMPONENTS`` tuple. + + Raises when the file has none, so renaming it fails CI instead of quietly + turning the trigger off. + """ + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "COMPONENTS" for t in node.targets + ): + return frozenset(ast.literal_eval(node.value)) + raise RuntimeError(f"{PYTHON_IMPORT_BENCHMARK_FILE} has no COMPONENTS tuple") + + +# Imports the given components and prints every component whose module got loaded +_IMPORT_PROBE = ( + "import importlib, sys\n" + "for c in sys.argv[1:]: importlib.import_module('esphome.components.' + c)\n" + "print(*{n.split('.')[2] for n in sys.modules if n.startswith('esphome.components.')})" +) + + +def python_import_benchmarked_components() -> frozenset[str] | None: + """Return the components loaded by importing the benchmark's ``COMPONENTS``. + + Imported in a fresh interpreter from the checkout, so the set is exactly what + the benchmark times. None when the import fails. + """ + path = Path(root_path) / PYTHON_IMPORT_BENCHMARK_FILE + if not path.is_file(): + return frozenset() + result = subprocess.run( + [sys.executable, "-c", _IMPORT_PROBE, *_benchmark_components_tuple(path)], + cwd=root_path, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print( + f"Benchmark import probe failed, running benchmarks:\n{result.stderr}", + file=sys.stderr, + ) + return None + return frozenset(result.stdout.split()) + + def should_run_benchmarks(branch: str | None = None) -> bool: """Determine if benchmarks (C++ and Python) should run based on changed files. @@ -831,6 +884,8 @@ def should_run_benchmarks(branch: str | None = None) -> bool: 4. A directly changed component has benchmark files (no dependency expansion) 5. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) + 6. A Python file changed in a component that importing the Python import + benchmark's ``COMPONENTS`` loads, even if it has no C++ benchmark Unlike unit tests, benchmarks do NOT expand to dependent components. Changing ``sensor`` does not trigger ``api`` benchmarks just because @@ -862,6 +917,19 @@ def should_run_benchmarks(branch: str | None = None) -> bool: ): return True + # The Python import benchmark times importing its components and what they pull + # in, so their Python sources move its numbers even without a C++ benchmark. + # A failed import runs the benchmarks rather than risk skipping them. + changed_python = { + get_component_from_path(f) + for f in files + if f.startswith(ESPHOME_COMPONENTS_PATH) and f.endswith(PYTHON_FILE_EXTENSIONS) + } + if changed_python: + loaded = python_import_benchmarked_components() + if loaded is None or changed_python & loaded: + return True + # Check if any directly changed component has benchmarks benchmarks_dir = Path(root_path) / BENCHMARKS_COMPONENTS_PATH if not benchmarks_dir.is_dir(): diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index e63e0849deb..6b63712f2a4 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -4,6 +4,7 @@ from collections.abc import Generator import importlib.util import json from pathlib import Path +import subprocess import sys from unittest.mock import Mock, patch @@ -2784,6 +2785,87 @@ def test_should_run_benchmarks_core_benchmark_file_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_python_import_benchmarked_components_imports_real_components() -> None: + """The set is what importing the real benchmark's components loads.""" + loaded = determine_jobs.python_import_benchmarked_components() + # mqtt is not listed in COMPONENTS; sensor imports it at module scope + assert {"binary_sensor", "sensor", "logger", "mqtt"} <= loaded + + +def test_should_run_benchmarks_python_import_benchmarked_component() -> None: + """A Python change in a component the import benchmark loads runs benchmarks. + + logger has no C++ benchmark, so before this a change to its __init__.py + skipped the Python import benchmark that times it. + """ + for changed, expected in [ + ("esphome/components/logger/__init__.py", True), + ("esphome/components/logger/select/__init__.py", True), + ("esphome/components/mqtt/__init__.py", True), + ("esphome/components/logger/logger.cpp", False), + ("tests/components/logger/common.yaml", False), + ("esphome/components/wifi/__init__.py", False), + ]: + with ( + patch.object(determine_jobs, "changed_files", return_value=[changed]), + patch.object( + determine_jobs, + "python_import_benchmarked_components", + return_value=frozenset({"logger", "mqtt"}), + ), + ): + assert determine_jobs.should_run_benchmarks() is expected, changed + + +def test_should_run_benchmarks_when_import_probe_fails() -> None: + """A component import that fails runs the benchmarks instead of skipping them.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/wifi/__init__.py"], + ), + patch.object( + determine_jobs, "python_import_benchmarked_components", return_value=None + ), + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_python_import_benchmarked_components_logs_probe_failure( + capsys: pytest.CaptureFixture[str], +) -> None: + """A failed probe returns None and prints why on stderr, never stdout.""" + failed = subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr="ModuleNotFoundError: boom" + ) + with patch.object(determine_jobs.subprocess, "run", return_value=failed): + assert determine_jobs.python_import_benchmarked_components() is None + captured = capsys.readouterr() + assert "ModuleNotFoundError: boom" in captured.err + assert captured.out == "" + + +def test_python_import_benchmarked_components_missing_file(tmp_path: Path) -> None: + """Without the benchmark file no component is treated as benchmarked.""" + with patch.object(determine_jobs, "root_path", str(tmp_path)): + assert determine_jobs.python_import_benchmarked_components() == frozenset() + + +def test_python_import_benchmarked_components_requires_components( + tmp_path: Path, +) -> None: + """A benchmark file without COMPONENTS fails loudly instead of disabling the rule.""" + bench = tmp_path / determine_jobs.PYTHON_IMPORT_BENCHMARK_FILE + bench.parent.mkdir(parents=True) + bench.write_text('NAMES = ("logger",)\n') + with ( + patch.object(determine_jobs, "root_path", str(tmp_path)), + pytest.raises(RuntimeError, match="COMPONENTS"), + ): + determine_jobs.python_import_benchmarked_components() + + def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None: """Test benchmarks trigger when a benchmarked component changes.""" # Create a fake benchmarks directory with an 'api' component From c56bb140379962d69d6f02e8a683a64bdfef40a5 Mon Sep 17 00:00:00 2001 From: AndreKR Date: Wed, 14 Oct 2026 22:39:55 +0200 Subject: [PATCH 383/651] [mqtt] Print crash info over MQTT (#17935) 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/api/api_connection.cpp | 12 +++ esphome/components/api/api_connection.h | 25 +---- esphome/components/esp32/__init__.py | 1 + esphome/components/esp32/crash_handler.cpp | 5 +- esphome/components/esp32/crash_handler.h | 3 + esphome/components/esp8266/__init__.py | 1 + esphome/components/logger/logger.cpp | 2 + esphome/components/logger/logger.h | 10 ++ esphome/components/logger/logger_esp32.cpp | 11 ++ esphome/components/logger/logger_esp8266.cpp | 5 + esphome/components/logger/logger_rp2.cpp | 5 + esphome/components/mqtt/mqtt_client.cpp | 50 +++++++-- esphome/components/mqtt/mqtt_client.h | 11 ++ esphome/components/rp2/__init__.py | 1 + esphome/core/defines.h | 1 + esphome/mqtt.py | 38 +++++-- tests/unit_tests/test_mqtt.py | 107 ++++++++++++++++++- 17 files changed, 246 insertions(+), 42 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index fd51aae1be8..ca2397b5d78 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,4 +1,7 @@ #include "api_connection.h" +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) +#include "esphome/components/logger/logger.h" +#endif #ifdef USE_API #include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines #ifdef USE_API_NOISE @@ -451,6 +454,15 @@ bool APIConnection::send_disconnect_response_() { DisconnectResponse resp; return this->send_message(resp); } +void APIConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) { + this->flags_.log_subscription = msg.level; + if (msg.dump_config) + App.schedule_dump_config(); +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) + logger::log_crash_to_client(); +#endif +} + void APIConnection::on_disconnect_response() { // Don't close socket here, let APIServer::loop() do it // so getpeername() still works for the disconnect trigger diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 9f784185b83..863e378a5e5 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -15,15 +15,6 @@ #include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" -#ifdef USE_ESP32_CRASH_HANDLER -#include "esphome/components/esp32/crash_handler.h" -#endif -#ifdef USE_RP2_CRASH_HANDLER -#include "esphome/components/rp2/crash_handler.h" -#endif -#ifdef USE_ESP8266_CRASH_HANDLER -#include "esphome/components/esp8266/crash_handler.h" -#endif #include "esphome/core/entity_base.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" @@ -298,21 +289,7 @@ class APIConnection final : public APIServerConnectionBase { this->begin_iterator_(ActiveIterator::INITIAL_STATE); } } - void on_subscribe_logs_request(const SubscribeLogsRequest &msg) { - this->flags_.log_subscription = msg.level; - if (msg.dump_config) - App.schedule_dump_config(); -#ifdef USE_ESP32_CRASH_HANDLER - esp32::crash_handler_log(); - esp32::crash_handler_clear(); -#endif -#ifdef USE_RP2_CRASH_HANDLER - rp2::crash_handler_log(); -#endif -#ifdef USE_ESP8266_CRASH_HANDLER - esp8266::crash_handler_log(); -#endif - } + void on_subscribe_logs_request(const SubscribeLogsRequest &msg); #ifdef USE_API_HOMEASSISTANT_SERVICES void on_subscribe_homeassistant_services_request() { this->flags_.service_call_subscription = true; } #endif diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 422413a269f..25a00b522c0 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2920,6 +2920,7 @@ async def _finalize_arduino_aware_flags(): if not arduino_linked: cg.add_build_flag("-Wl,--wrap=esp_panic_handler") cg.add_define("USE_ESP32_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") elif not CORE.using_arduino: cg.add_build_flag("-DUSE_ARDUINO") diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 182bc5f2d6c..bc28dde8d63 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -161,7 +161,7 @@ static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-c namespace esphome::esp32 { -ESPHOME_LOG_TAG(TAG, "esp32.crash"); +ESPHOME_LOG_TAG(TAG, CRASH_LOG_TAG); // RAM copy of the build timestamp. The generated constant lives in flash, // which the panic handler must not read (cache may be disabled during @@ -175,7 +175,8 @@ static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME) // Validate the NOINIT record. Runs on every has_data() call; re-running is // harmless and the magic is left alone so the record survives an OTA -// rollback reboot, crash_handler_clear() drops it once an API client has it. +// rollback reboot, crash_handler_clear() drops it once a log client has it +// (logger::log_crash_to_client(), for the API and MQTT). static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index 314be80314c..e145a4a6032 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,6 +4,9 @@ namespace esphome::esp32 { +/// The tag the crash record is logged under, so a client can check the level in force for it +inline constexpr const char *CRASH_LOG_TAG = "esp32.crash"; + /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 27c6fb1958d..09ced18fd67 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -420,6 +420,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) cg.add_define("USE_ESP8266_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 24a6586ae2f..ac7bfef48af 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -142,6 +142,8 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas } #endif // USE_ESP8266 +uint8_t Logger::get_log_level(const char *tag) { return this->level_for(tag); } + inline uint8_t Logger::level_for(const char *tag) { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS // Skip the lookup when no per-tag levels are set diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index b82aaa7c219..475ec93e81f 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -178,6 +178,8 @@ class Logger final : public Component { void set_log_level(const char *tag, uint8_t log_level); #endif uint8_t get_log_level() { return this->current_level_; } + /// The level in force for a tag: its own when one is set, else the default + uint8_t get_log_level(const char *tag); // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -511,6 +513,14 @@ class LoggerMessageTrigger final : public Triggerget_log_level(esp32::CRASH_LOG_TAG) < ESPHOME_LOG_LEVEL_ERROR) + return; + esp32::crash_handler_log(); + esp32::crash_handler_clear(); +} +#endif + } // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 6822e8f590f..418ab3d95d9 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -48,6 +48,11 @@ const LogString *Logger::get_uart_selection_() { #endif } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return esp8266::crash_handler_has_data(); } +void log_crash_to_client() { esp8266::crash_handler_log(); } +#endif + } // namespace esphome::logger #if !defined(USE_ESP8266_LOGGER_SERIAL) && !defined(USE_ESP8266_LOGGER_SERIAL1) diff --git a/esphome/components/logger/logger_rp2.cpp b/esphome/components/logger/logger_rp2.cpp index 518a1d7b1d2..6d190df5cdb 100644 --- a/esphome/components/logger/logger_rp2.cpp +++ b/esphome/components/logger/logger_rp2.cpp @@ -49,5 +49,10 @@ const LogString *Logger::get_uart_selection_() { } } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return rp2::crash_handler_has_data(); } +void log_crash_to_client() { rp2::crash_handler_log(); } +#endif + } // namespace esphome::logger #endif // USE_RP2 diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 803f486333a..297a94d2a97 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -49,6 +49,13 @@ MQTTClientComponent::MQTTClientComponent() { } // Connection +// Room for "esphome//" with the longest kind, "discover" +static constexpr size_t ESPHOME_TOPIC_BUFFER_SIZE = sizeof("esphome/discover/") + ESPHOME_DEVICE_NAME_MAX_LEN; + +static void format_esphome_topic(char (&topic)[ESPHOME_TOPIC_BUFFER_SIZE], const char *kind) { + buf_append_printf(topic, sizeof(topic), 0, "esphome/%s/%s", kind, App.get_name().c_str()); +} + void MQTTClientComponent::setup() { this->mqtt_backend_.set_on_message( [this](const char *topic, const char *payload, size_t len, size_t index, size_t total) { @@ -78,6 +85,13 @@ void MQTTClientComponent::setup() { this, [](void *self, uint8_t level, const char *tag, const char *message, size_t message_len) { static_cast(self)->on_log(level, tag, message, message_len); }); +#ifdef USE_MQTT_CRASH_LOG + // `esphome logs` announces itself here, like an API client's log subscription, and the record stays + // loggable until the next boot, so the offer does too. A log topic that drops errors would swallow the + // replay, so it is not offered. The topic is rebuilt from the device name when needed, so nothing is + // stored for it. + this->crash_log_offered_ = logger::crash_log_pending() && this->log_level_ >= ESPHOME_LOG_LEVEL_ERROR; +#endif } #endif @@ -87,10 +101,8 @@ void MQTTClientComponent::setup() { 2); // Format topic on stack - subscribe() copies it - // "esphome/ping/" (13) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1) - constexpr size_t ping_topic_buffer_size = 13 + ESPHOME_DEVICE_NAME_MAX_LEN + 1; - char ping_topic[ping_topic_buffer_size]; - buf_append_printf(ping_topic, sizeof(ping_topic), 0, "esphome/ping/%s", App.get_name().c_str()); + char ping_topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(ping_topic, "ping"); this->subscribe( ping_topic, [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2); } @@ -105,10 +117,8 @@ void MQTTClientComponent::send_device_info_() { return; } // Format topic on stack to avoid heap allocation - // "esphome/discover/" (17) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1) - constexpr size_t topic_buffer_size = 17 + ESPHOME_DEVICE_NAME_MAX_LEN + 1; - char topic[topic_buffer_size]; - buf_append_printf(topic, sizeof(topic), 0, "esphome/discover/%s", App.get_name().c_str()); + char topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(topic, "discover"); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson this->publish_json( @@ -354,6 +364,14 @@ void MQTTClientComponent::check_connected() { delay(100); // NOLINT this->resubscribe_subscriptions_(); +#ifdef USE_MQTT_CRASH_LOG + // Best effort: a refused subscription is tried again at the next connection + if (this->crash_log_offered_) { + char topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(topic, "logs"); + this->subscribe_(topic, 0); + } +#endif this->send_device_info_(); for (MQTTComponent *component : this->children_) @@ -461,6 +479,19 @@ void MQTTClientComponent::resubscribe_subscriptions_() { } } +#ifdef USE_MQTT_CRASH_LOG +void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, const std::string &payload) { + // Only the CLI's empty request counts: a log_topic pointing at this topic would otherwise replay the crash + // log for every line it logs + if (!this->crash_log_offered_ || !payload.empty()) + return; + char logs_topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(logs_topic, "logs"); + if (topic == logs_topic) + logger::log_crash_to_client(); +} +#endif + void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos) { auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{ @@ -658,6 +689,9 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // DO NOT REMOVE THIS DEFER without understanding the above. It may appear to work // in simple tests but will cause crashes with complex automations. this->defer([this, topic, payload]() { +#endif +#ifdef USE_MQTT_CRASH_LOG + this->handle_crash_log_request_(topic, payload); #endif // Indexed with the count taken up front: a callback may subscribe, which can reallocate the // vector, and the new entry waits for the next message. diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 30edb812975..1de100019e3 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -24,6 +24,11 @@ #include +// The crash log replay needs the logger, which mqtt does not depend on +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) +#define USE_MQTT_CRASH_LOG +#endif + namespace esphome::mqtt { /** Callback for MQTT events. @@ -316,6 +321,9 @@ class MQTTClientComponent final : public Component { callback(topic, doc.as()); } void erase_removed_subscriptions_(); +#ifdef USE_MQTT_CRASH_LOG + void handle_crash_log_request_(const std::string &topic, const std::string &payload); +#endif void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos); void resubscribe_subscriptions_(); @@ -359,6 +367,9 @@ class MQTTClientComponent final : public Component { bool dns_resolved_{false}; bool dns_resolve_error_{false}; bool enable_on_boot_{true}; +#ifdef USE_MQTT_CRASH_LOG + bool crash_log_offered_{false}; +#endif std::vector children_; uint32_t reboot_timeout_{300000}; uint32_t connect_begin_; diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index d46858d3977..1c74c8d5ac3 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -392,6 +392,7 @@ async def to_code(config: ConfigType) -> None: "USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT] ) # back-compat alias cg.add_define("USE_RP2_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6075fc93797..3d14494df7a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -58,6 +58,7 @@ #define USE_CLIMATE_VISUAL_OVERRIDES #define USE_CONTROLLER_REGISTRY #define USE_COVER +#define USE_CRASH_HANDLER #define USE_DATETIME #define USE_DATETIME_DATE #define USE_DATETIME_DATETIME diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 62deafb09a2..6f0426e4f49 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING import paho.mqtt.client as mqtt from esphome.const import ( + CONF_BIRTH_MESSAGE, CONF_BROKER, CONF_CERTIFICATE_AUTHORITY, CONF_CLIENT_CERTIFICATE, @@ -21,7 +22,9 @@ from esphome.const import ( CONF_MQTT, CONF_NAME, CONF_PASSWORD, + CONF_PAYLOAD, CONF_PORT, + CONF_RETAIN, CONF_SKIP_CERT_CN_CHECK, CONF_TOPIC, CONF_TOPIC_PREFIX, @@ -319,13 +322,36 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): return 1 _LOGGER.info("Starting log output from %s", topic) - def on_message(client, userdata, msg): - time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") - payload = msg.payload.decode(errors="backslashreplace") - message = time_ + payload - safe_print(message) + logs_topic = f"esphome/logs/{config[CONF_ESPHOME][CONF_NAME]}" + # Ask when the retained birth message reports online: a request sent while the device is down is + # lost, and only a retained message tells a CLI that connects later that the device is already up. + # An empty payload deletes the retained message instead, so it counts as no birth message. + birth = config.get(CONF_MQTT, {}).get(CONF_BIRTH_MESSAGE) or {} + birth_payload = birth.get(CONF_PAYLOAD) + birth_topic = birth.get(CONF_TOPIC) if birth_payload else None + ask_on_connect = birth_topic is None or not birth[CONF_RETAIN] - return initialize(config, [topic], on_message, None, username, password, client_id) + def request_crash_log(client): + _LOGGER.debug("Requesting the crash log via topic: %s", logs_topic) + client.publish(logs_topic, None, retain=False) + + def on_message(client, userdata, msg): + payload = msg.payload.decode(errors="backslashreplace") + if msg.topic == birth_topic and payload == birth_payload: + request_crash_log(client) + # The birth topic may sit inside the log subscription, so print by what was asked for + if mqtt.topic_matches_sub(topic, msg.topic): + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") + safe_print(time_ + payload) + + def on_connect(client, userdata, flags, return_code): + if ask_on_connect: + request_crash_log(client) + + subscriptions = [topic] if birth_topic is None else [topic, birth_topic] + return initialize( + config, subscriptions, on_message, on_connect, username, password, client_id + ) def clear_topic(config, topic, username=None, password=None, client_id=None): diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 1ae10d0eb54..ad45db88ca2 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -9,9 +9,112 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME +from esphome.const import ( + CONF_BIRTH_MESSAGE, + CONF_BROKER, + CONF_ESPHOME, + CONF_MQTT, + CONF_NAME, + CONF_PAYLOAD, + CONF_RETAIN, + CONF_TOPIC, +) from esphome.core import EsphomeError -from esphome.mqtt import get_esphome_device_ip +from esphome.mqtt import get_esphome_device_ip, show_logs + + +@pytest.mark.parametrize( + "birth", + [ + None, + {CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "online", CONF_RETAIN: False}, + {CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "", CONF_RETAIN: True}, + ], + ids=["no birth message", "unretained birth message", "empty birth payload"], +) +@patch("esphome.mqtt.initialize") +def test_show_logs_asks_on_connect( + mock_initialize: MagicMock, birth: dict | None +) -> None: + """Without a retained birth message nothing tells a later CLI the device is up, so it asks on connect.""" + config = _discovery_config() + if birth is not None: + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = birth + + show_logs(config) + + on_connect = mock_initialize.call_args.args[3] + client = MagicMock() + on_connect(client, None, None, 0) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + if birth is None or not birth[CONF_PAYLOAD]: + assert mock_initialize.call_args.args[1] == ["test-device/debug"] + + +@patch("esphome.mqtt.safe_print") +@patch("esphome.mqtt.initialize") +def test_show_logs_requests_crash_log_when_device_reports_online( + mock_initialize: MagicMock, mock_print: MagicMock +) -> None: + """With a retained birth message the request follows the device coming online, not the CLI connecting.""" + config = _discovery_config() + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = { + CONF_TOPIC: "test-device/status", + CONF_PAYLOAD: "online", + CONF_RETAIN: True, + } + + show_logs(config) + + assert mock_initialize.call_args.args[1] == [ + "test-device/debug", + "test-device/status", + ] + on_message = mock_initialize.call_args.args[2] + on_connect = mock_initialize.call_args.args[3] + client = MagicMock() + on_connect(client, None, None, 0) + client.publish.assert_not_called() + + on_message(client, None, MagicMock(topic="test-device/status", payload=b"offline")) + client.publish.assert_not_called() + on_message(client, None, MagicMock(topic="test-device/status", payload=b"online")) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + mock_print.assert_not_called() + + on_message( + client, None, MagicMock(topic="test-device/debug", payload=b"[I][app]: hi") + ) + assert mock_print.call_args.args[0].endswith("[I][app]: hi") + client.publish.assert_called_once() + + +@patch("esphome.mqtt.safe_print") +@patch("esphome.mqtt.initialize") +def test_show_logs_prints_a_birth_message_inside_the_log_subscription( + mock_initialize: MagicMock, mock_print: MagicMock +) -> None: + """A birth topic covered by the log topic is still printed, and still requests the crash log.""" + config = _discovery_config() + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = { + CONF_TOPIC: "test-device/status", + CONF_PAYLOAD: "online", + CONF_RETAIN: True, + } + + show_logs(config, topic="test-device/#") + + on_message = mock_initialize.call_args.args[2] + client = MagicMock() + on_message(client, None, MagicMock(topic="test-device/status", payload=b"online")) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + assert mock_print.call_args.args[0].endswith("online") def test_get_esphome_device_ip_empty_broker() -> None: From ac74796263f8e5d4b1f6b657f2128c3599511440 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:52:13 -1000 Subject: [PATCH 384/651] [friedrich] Clear saved preferences in the test fixture (#20770) --- tests/components/friedrich/friedrich_test.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp index 610eca9c091..baf4e53652e 100644 --- a/tests/components/friedrich/friedrich_test.cpp +++ b/tests/components/friedrich/friedrich_test.cpp @@ -4,6 +4,7 @@ #include "esphome/components/friedrich/friedrich.h" #include "esphome/components/remote_base/aeha_protocol.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" namespace esphome::friedrich::testing { @@ -41,7 +42,12 @@ struct Fixture { TestFriedrich sut; CapturingTransmitter transmitter; - Fixture() { this->sut.set_transmitter(&this->transmitter); } + Fixture() { + // Climate tests share one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + 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{}); } From d3668a71866fbf52e85010aa6403a385d42c20f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:13:32 -1000 Subject: [PATCH 385/651] [mqtt] Match subscription topics in a loop instead of recursing per character (#20767) --- esphome/components/mqtt/mqtt_client.cpp | 58 +----------- esphome/components/mqtt/mqtt_topic_match.h | 28 ++++++ tests/components/mqtt/test_topic_match.cpp | 103 +++++++++++++++++++++ 3 files changed, 132 insertions(+), 57 deletions(-) create mode 100644 esphome/components/mqtt/mqtt_topic_match.h create mode 100644 tests/components/mqtt/test_topic_match.cpp diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 297a94d2a97..dd376251399 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -16,6 +16,7 @@ #include "lwip/dns.h" #include "lwip/err.h" #include "mqtt_component.h" +#include "mqtt_topic_match.h" #ifdef USE_API #include "esphome/components/api/api_server.h" @@ -611,63 +612,6 @@ void MQTTClientComponent::disable() { this->on_shutdown(); } -/** Check if the message topic matches the given subscription topic - * - * INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings. - * - * @param message The message topic that was received from the MQTT server. Note: this must not contain - * wildcard characters as mandated by the MQTT spec. - * @param subscription The subscription topic we are matching against. - * @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$". - * @param past_separator Are we past the first '/' topic separator. - * @return true if the subscription topic matches the message topic, false otherwise. - */ -static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { - // Reached end of both strings at the same time, this means we have a successful match - if (*message == '\0' && *subscription == '\0') - return true; - - // Either the message or the subscribe are at the end. This means they don't match. - if (*message == '\0' || *subscription == '\0') - return false; - - bool do_wildcards = is_normal || past_separator; - - if (*subscription == '+' && do_wildcards) { - // single level wildcard - // consume + from subscription - subscription++; - // consume everything from message until '/' found or end of string - while (*message != '\0' && *message != '/') { - message++; - } - // after this, both pointers will point to a '/' or to the end of the string - - return topic_match(message, subscription, is_normal, true); - } - - if (*subscription == '#' && do_wildcards) { - // multilevel wildcard - MQTT mandates that this must be at end of subscribe topic - return true; - } - - // this handles '/' and normal characters at the same time. - if (*message != *subscription) - return false; - - past_separator = past_separator || *subscription == '/'; - - // consume characters - subscription++; - message++; - - return topic_match(message, subscription, is_normal, past_separator); -} - -static bool topic_match(const char *message, const char *subscription) { - return topic_match(message, subscription, *message != '\0' && *message != '$', false); -} - void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) { #ifdef USE_ESP8266 // IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266. diff --git a/esphome/components/mqtt/mqtt_topic_match.h b/esphome/components/mqtt/mqtt_topic_match.h new file mode 100644 index 00000000000..84172bc805e --- /dev/null +++ b/esphome/components/mqtt/mqtt_topic_match.h @@ -0,0 +1,28 @@ +#pragma once + +namespace esphome::mqtt { + +/// True if the message topic matches the subscription, with + and # wildcards. +/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics. +inline bool topic_match(const char *message, const char *subscription) { + // First-level wildcards do not match topics that begin with '$' + bool wildcards = *message != '$'; + while (*message != '\0' && *subscription != '\0') { + if (wildcards && *subscription == '#') + return true; + if (wildcards && *subscription == '+') { + subscription++; + while (*message != '\0' && *message != '/') + message++; + continue; + } + if (*message != *subscription) + return false; + wildcards |= *subscription == '/'; + message++; + subscription++; + } + return *message == *subscription; // a match only if both ended +} + +} // namespace esphome::mqtt diff --git a/tests/components/mqtt/test_topic_match.cpp b/tests/components/mqtt/test_topic_match.cpp new file mode 100644 index 00000000000..b51f45ce478 --- /dev/null +++ b/tests/components/mqtt/test_topic_match.cpp @@ -0,0 +1,103 @@ +#include + +#include +#include + +#include "esphome/components/mqtt/mqtt_topic_match.h" + +namespace esphome::mqtt::testing { + +// The previous recursive implementation, kept to check the loop matches it +static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { + if (*message == '\0' && *subscription == '\0') + return true; + if (*message == '\0' || *subscription == '\0') + return false; + bool do_wildcards = is_normal || past_separator; + if (*subscription == '+' && do_wildcards) { + subscription++; + while (*message != '\0' && *message != '/') + message++; + return reference_match(message, subscription, is_normal, true); + } + if (*subscription == '#' && do_wildcards) + return true; + if (*message != *subscription) + return false; + past_separator = past_separator || *subscription == '/'; + return reference_match(message + 1, subscription + 1, is_normal, past_separator); +} + +static bool reference_match(const char *message, const char *subscription) { + return reference_match(message, subscription, *message != '\0' && *message != '$', false); +} + +TEST(MQTTTopicMatch, Exact) { + EXPECT_TRUE(topic_match("a/b/c", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b")); + EXPECT_FALSE(topic_match("a/b", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b/d")); +} + +TEST(MQTTTopicMatch, SingleLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/+/c")); + EXPECT_TRUE(topic_match("a/b/c", "+/+/+")); + EXPECT_TRUE(topic_match("a//c", "a/+/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/+")); + EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c")); +} + +TEST(MQTTTopicMatch, MultiLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/#")); + EXPECT_TRUE(topic_match("a/b/c", "#")); + EXPECT_TRUE(topic_match("a/b/c", "a/+/#")); + EXPECT_FALSE(topic_match("b/c", "a/#")); +} + +TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) { + EXPECT_FALSE(topic_match("$SYS/broker", "#")); + EXPECT_FALSE(topic_match("$SYS/broker", "+/broker")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+")); +} + +TEST(MQTTTopicMatch, EdgeCases) { + EXPECT_TRUE(topic_match("", "")); + EXPECT_FALSE(topic_match("a", "")); + // Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level + EXPECT_FALSE(topic_match("a/b", "a/b/#")); + EXPECT_FALSE(topic_match("a/", "a/+")); + EXPECT_TRUE(topic_match("a/$x", "a/+")); +} + +// Recursion overflowed the ESP32 loop stack at about 250 characters +TEST(MQTTTopicMatch, LongTopic) { + std::string topic(4096, 'x'); + topic[100] = '/'; + EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str())); + std::string other = topic; + other.back() = 'y'; + EXPECT_FALSE(topic_match(topic.c_str(), other.c_str())); +} + +TEST(MQTTTopicMatch, MatchesPreviousImplementation) { + // Small alphabet so wildcards, separators and '$' meet often + static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'}; + static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'}; + std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + auto random_string = [&rng](const char *chars, size_t count) { + std::string s(rng() % 8, ' '); + for (char &c : s) + c = chars[rng() % count]; + return s; + }; + for (int i = 0; i < 20000; i++) { + std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS)); + std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS)); + ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()), + reference_match(message.c_str(), subscription.c_str())) + << "message='" << message << "' subscription='" << subscription << "'"; + } +} + +} // namespace esphome::mqtt::testing From cd97c9a9cb23d2a961215bd7a49c325da1cc95b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:29:17 +0000 Subject: [PATCH 386/651] Bump platformdirs from 4.12.4 to 4.13.0 (#20772) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a247eb4ba15..9105f3f9c0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 -platformdirs==4.12.4 # native esp-idf toolchain global cache dir +platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 61f3de0bc0b17377e33ffc66052a0f8ef7e09d6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:30:26 +0000 Subject: [PATCH 387/651] Bump pylint from 4.1.2 to 4.1.3 (#20773) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 65e8a63bfe9..b36562727f8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.1.2 +pylint==4.1.3 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py From 3df936087676b5f9ffb7cf73ef4808f589159715 Mon Sep 17 00:00:00 2001 From: "nullable.eth" <2248325+nullable-eth@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:39:30 -0400 Subject: [PATCH 388/651] [i2s_audio] Add keep_alive option to speaker to prevent pop/click on audio start (#15565) Co-authored-by: J. Nick Koston --- .../components/i2s_audio/speaker/__init__.py | 24 +++++++ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 64 ++++++++++--------- .../i2s_audio/speaker/i2s_audio_speaker.h | 9 ++- .../speaker/i2s_audio_speaker_standard.cpp | 15 ++++- tests/component_tests/i2s_audio/__init__.py | 0 .../i2s_audio/config/keep_alive.yaml | 16 +++++ .../config/keep_alive_shared_bus.yaml | 19 ++++++ .../config/keep_alive_shared_mic.yaml | 22 +++++++ .../i2s_audio/config/keep_alive_spdif.yaml | 20 ++++++ .../component_tests/i2s_audio/test_config.py | 36 +++++++++++ 10 files changed, 191 insertions(+), 34 deletions(-) create mode 100644 tests/component_tests/i2s_audio/__init__.py create mode 100644 tests/component_tests/i2s_audio/config/keep_alive.yaml create mode 100644 tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml create mode 100644 tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml create mode 100644 tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml create mode 100644 tests/component_tests/i2s_audio/test_config.py diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 4dc15681bfa..e4ea186199d 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -1,6 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import audio, esp32, speaker +from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -14,9 +15,11 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TIMEOUT, ) +import esphome.final_validate as fv from esphome.types import ConfigType from .. import ( + CONF_I2S_AUDIO_ID, CONF_I2S_DOUT_PIN, CONF_I2S_MODE, CONF_LEFT, @@ -44,6 +47,7 @@ I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) CONF_DAC_TYPE = "dac_type" CONF_I2S_COMM_FMT = "i2s_comm_fmt" +CONF_KEEP_ALIVE = "keep_alive" CONF_SPDIF_MODE = "spdif_mode" I2SAudioSpeakerBase = i2s_audio_ns.class_( @@ -173,6 +177,7 @@ BASE_SCHEMA = ( cv.positive_time_period_milliseconds, cv.one_of(CONF_NEVER, lower=True), ), + cv.Optional(CONF_KEEP_ALIVE, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) @@ -235,6 +240,23 @@ def _final_validate(config: ConfigType) -> None: raise cv.Invalid("SPDIF mode requires 'i2s_comm_fmt: stand_i2s'") if config[CONF_MCLK_MULTIPLE] != 256: raise cv.Invalid("SPDIF mode requires 'mclk_multiple: 256'") + if config[CONF_KEEP_ALIVE]: + raise cv.Invalid("SPDIF mode does not support 'keep_alive'") + + if config[CONF_KEEP_ALIVE]: + # A kept channel holds the port between sessions, so nothing else may share the bus + bus = config[CONF_I2S_AUDIO_ID] + full_config = fv.full_config.get() + on_bus = sum( + 1 + for domain in (speaker.DOMAIN, MICROPHONE_DOMAIN) + for entry in full_config.get(domain, []) + if entry.get(CONF_I2S_AUDIO_ID) == bus + ) + if on_bus > 1: + raise cv.Invalid( + "'keep_alive' needs its own I2S bus; another speaker or microphone shares this one" + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -262,6 +284,8 @@ async def to_code(config: ConfigType) -> None: if config[CONF_TIMEOUT] != CONF_NEVER: cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) + if config[CONF_KEEP_ALIVE]: + cg.add(var.set_keep_alive(True)) # The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE, diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 8d586ea4e01..c644ece5428 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -56,8 +56,9 @@ void I2SAudioSpeakerBase::dump_config() { ESP_LOGCONFIG(TAG, "Speaker:\n" " Pin: %d\n" - " Buffer duration: %" PRIu32, - static_cast(this->dout_pin_), this->buffer_duration_ms_); + " Buffer duration: %" PRIu32 "\n" + " Keep alive: %s", + static_cast(this->dout_pin_), this->buffer_duration_ms_, YESNO(this->keep_alive_)); if (this->timeout_.has_value()) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value()); } @@ -105,7 +106,13 @@ void I2SAudioSpeakerBase::loop() { vTaskDelete(this->speaker_task_handle_); this->speaker_task_handle_ = nullptr; - this->stop_i2s_driver_(); + // The task raises this bit before it exits, possibly a loop pass before TASK_STOPPED is seen, so read it + // live here; a channel the task could not bring up is rebuilt next time rather than kept + const bool task_failed = xEventGroupGetBits(this->event_group_) & SpeakerEventGroupBits::ERR_ESP_NO_MEM; + if (task_failed) { + ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); + } + this->stop_i2s_driver_(this->keep_alive_ && !task_failed); // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at // the top of loop(): the audio source's task can raise a start at any point above, including // during stop_i2s_driver_(), and nothing would ever re-issue it. @@ -121,11 +128,6 @@ void I2SAudioSpeakerBase::loop() { this->state_ = speaker::STATE_STOPPED; } - if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { - ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } - // Handle the speaker's state switch (this->state_) { case speaker::STATE_STARTING: @@ -133,8 +135,8 @@ void I2SAudioSpeakerBase::loop() { break; } - // Still starting up or winding down from a previous run - if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + // Still winding down from a previous run + if (this->speaker_task_handle_ != nullptr) { break; } @@ -154,7 +156,7 @@ void I2SAudioSpeakerBase::loop() { if (this->speaker_task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); this->status_momentary_error(1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt + this->stop_i2s_driver_(false); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough @@ -270,31 +272,25 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to initialize I2S channel"); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; + this->delete_channel_(); this->parent_->unlock(); return err; } + // Both queues are drained by the speaker task before it preloads, whether the channel is new or kept if (this->i2s_event_queue_ == nullptr) { this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); - } else { - // Reset queue to clear any stale events from previous task - xQueueReset(this->i2s_event_queue_); } // Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue // so a fully-saturated DMA pipeline cannot overflow either side before drain. if (this->write_records_queue_ == nullptr) { this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t)); - } else { - xQueueReset(this->write_records_queue_); } if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) { ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)"); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; + this->delete_channel_(); this->parent_->unlock(); return ESP_ERR_NO_MEM; } @@ -302,22 +298,30 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c return ESP_OK; } -void I2SAudioSpeakerBase::stop_i2s_driver_() { +void I2SAudioSpeakerBase::stop_i2s_driver_(bool keep_channel) { if (this->tx_handle_ != nullptr) { i2s_channel_disable(this->tx_handle_); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; - - // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only - // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that - // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our - // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a - // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. - this->park_dout_pin_(); + // A kept channel lets the next start skip i2s_new_channel and i2s_channel_init_std_mode, whose GPIO and + // clock reconfiguration is the audible pop + if (!keep_channel) { + this->delete_channel_(); + } } this->parent_->unlock(); } +void I2SAudioSpeakerBase::delete_channel_() { + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + this->park_dout_pin_(); +} + bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { int64_t now = esp_timer_get_time(); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 965eede0201..d73d2da8794 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -54,6 +54,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } void set_timeout(uint32_t ms) { this->timeout_ = ms; } + void set_keep_alive(bool keep_alive) { this->keep_alive_ = keep_alive; } void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; } /// @brief Get the I2S TX channel handle @@ -126,12 +127,15 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, size_t event_queue_size); - /// @brief Stops the I2S driver and unlocks the I2S port - void stop_i2s_driver_(); + /// @brief Stops the I2S driver and unlocks the I2S port; keep_channel leaves the disabled channel allocated + void stop_i2s_driver_(bool keep_channel); /// @brief Detaches dout from the I2S signal and drives it low void park_dout_pin_(); + /// @brief Deletes the I2S channel and parks dout; the caller holds the bus lock + void delete_channel_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} @@ -177,6 +181,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public optional timeout_; bool pause_state_{false}; + bool keep_alive_{false}; // Smooths software gain changes. The main loop posts targets, the speaker task processes; // GainRamp's mailbox makes that safe. The main loop is the only poster. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index fbc1edbde93..1a6b021fffa 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -75,6 +75,9 @@ void I2SAudioSpeaker::dump_config() { void I2SAudioSpeaker::run_speaker_task() { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + // Stale records from the previous run would pair with the wrong DMA events + xQueueReset(this->i2s_event_queue_); + xQueueReset(this->write_records_queue_); const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; // Ensure ring buffer duration is at least the duration of all DMA buffers @@ -389,8 +392,6 @@ void I2SAudioSpeaker::run_speaker_task() { } esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { - this->current_stream_info_ = audio_stream_info; - if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT // Can't reconfigure I2S bus, so the sample rate must match the configured value ESP_LOGE(TAG, "Incompatible stream settings"); @@ -428,6 +429,16 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_INVALID_STATE; } + // Reuse a kept channel while the stream format matches + if (this->tx_handle_ != nullptr) { + if (this->current_stream_info_ == audio_stream_info) { + return ESP_OK; + } + this->delete_channel_(); + } + + this->current_stream_info_ = audio_stream_info; + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); diff --git a/tests/component_tests/i2s_audio/__init__.py b/tests/component_tests/i2s_audio/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/i2s_audio/config/keep_alive.yaml b/tests/component_tests/i2s_audio/config/keep_alive.yaml new file mode 100644 index 00000000000..fd418bd4af5 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true diff --git a/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml b/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml new file mode 100644 index 00000000000..2eecf2088fa --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO13 diff --git a/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml b/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml new file mode 100644 index 00000000000..c95ff1abbc6 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml @@ -0,0 +1,22 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true + +microphone: + - platform: i2s_audio + i2s_din_pin: GPIO34 + adc_type: external + pdm: false diff --git a/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml b/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml new file mode 100644 index 00000000000..0e194bebef5 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml @@ -0,0 +1,20 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + spdif_mode: true + use_apll: true + sample_rate: 48000 + channel: stereo + keep_alive: true diff --git a/tests/component_tests/i2s_audio/test_config.py b/tests/component_tests/i2s_audio/test_config.py new file mode 100644 index 00000000000..bd5c3a598fa --- /dev/null +++ b/tests/component_tests/i2s_audio/test_config.py @@ -0,0 +1,36 @@ +"""Exercise the I2S speaker keep_alive validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_keep_alive_generates_setter( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("keep_alive.yaml")) + assert "set_keep_alive(true)" in source + + +@pytest.mark.parametrize( + ("config_file", "message"), + [ + ("keep_alive_shared_bus.yaml", "needs its own I2S bus"), + ("keep_alive_shared_mic.yaml", "needs its own I2S bus"), + ("keep_alive_spdif.yaml", "SPDIF mode does not support 'keep_alive'"), + ], +) +def test_keep_alive_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], + config_file: str, + message: str, +) -> None: + CORE.config_path = component_config_path(config_file) + assert read_config({}) is None + assert message in capsys.readouterr().out From 04715fb32bf711b89b04bb606fad55f977f0fcd1 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:41:12 -0400 Subject: [PATCH 389/651] [usb_uart] Expose `interface_class` and `port_number` (#20659) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 7 ++- esphome/components/api/api_pb2.cpp | 4 ++ esphome/components/api/api_pb2.h | 2 + esphome/components/api/api_pb2_dump.cpp | 2 + .../components/serial_proxy/serial_proxy.cpp | 2 + esphome/components/usb_uart/ch34x.cpp | 8 --- esphome/components/usb_uart/cp210x.cpp | 10 ++-- esphome/components/usb_uart/ft23xx.cpp | 1 + esphome/components/usb_uart/pl2303.cpp | 5 +- esphome/components/usb_uart/usb_uart.cpp | 6 ++- esphome/components/usb_uart/usb_uart.h | 21 +++++++- tests/components/serial_proxy/__init__.py | 16 ++++++ .../test_usb_device_descriptor.cpp | 51 +++++++++++++++++++ 13 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 tests/components/serial_proxy/__init__.py create mode 100644 tests/components/serial_proxy/test_usb_device_descriptor.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f8f31d81df0..d55cf10c47d 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -3043,12 +3043,15 @@ enum SerialProxyIdentityFlag { // IDs below are empty } -// The descriptor fields of a USB device as seen by the host stack +// The descriptor fields of a USB device as seen by the host stack, and the port's index +// among those a vendor driver exposes on the interface message UsbDeviceDescriptor { uint32 vendor_id = 1; uint32 product_id = 2; uint32 bcd_device = 3; - uint32 interface_number = 4; // bInterfaceNumber the host driver binds to + uint32 interface_number = 4; // bInterfaceNumber the port is bound through + uint32 interface_class = 5; // bInterfaceClass of that interface + uint32 port_number = 6; } // The identity of the device behind a port. Identifies one physical device among others of diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8847249b173..bcfa86763a1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4175,6 +4175,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number); return pos; } uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { @@ -4184,6 +4186,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.product_id); size += ProtoSize::calc_uint32(1, msg.bcd_device); size += ProtoSize::calc_uint32(1, msg.interface_number); + size += ProtoSize::calc_uint32(1, msg.interface_class); + size += ProtoSize::calc_uint32(1, msg.port_number); return size; } uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 14204e97aaa..7c8082a8c7c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -4083,6 +4083,8 @@ class UsbDeviceDescriptor final : public ProtoMessage { uint32_t product_id{0}; uint32_t bcd_device{0}; uint32_t interface_number{0}; + uint32_t interface_class{0}; + uint32_t port_number{0}; 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/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3c6f5302b55..ec224c46e29 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2942,6 +2942,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class); + dump_field(out, ESPHOME_PSTR("port_number"), this->port_number); return out.c_str(); } const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 9623eb21b3c..03d289225f2 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -418,6 +418,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: msg.usb.product_id = scratch.product_id; msg.usb.bcd_device = scratch.bcd_device; msg.usb.interface_number = this->usb_channel_->get_interface_number(); + msg.usb.interface_class = this->usb_channel_->get_interface_class(); + msg.usb.port_number = this->usb_channel_->get_port_number(); msg.manufacturer = StringRef(scratch.manufacturer); msg.product = StringRef(scratch.product); msg.serial_number = StringRef(scratch.serial_number); diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index ed5a034b82f..dce07a64a53 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -152,14 +152,6 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo } } -std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { - auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl); - // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare - for (auto &cdc_dev : result) { - cdc_dev.interrupt_interface_number = 0xFF; - } - return result; -} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index e920da6c267..34b76f88541 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -88,11 +88,11 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } - if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) { - cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}}); - } else { - cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}}); - } + CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN + ? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber} + : CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}; + eps.bulk_interface_class = data_desc->bInterfaceClass; + cdc_devs.push_back(eps); } return cdc_devs; } diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index e10da78aaf0..805a4adfce3 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -217,6 +217,7 @@ static optional get_uart(const usb_config_desc_t *config_desc, uint8_t i } eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; return eps; } diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 36107c093c4..0430b1eeacb 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -189,7 +189,10 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev } if (in_ep && out_ep) { - cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}; + eps.bulk_interface_class = intf->bInterfaceClass; + eps.interrupt_interface_class = intf->bInterfaceClass; + cdc_devs.push_back(eps); break; // PL2303 is single-port } } diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index f61642566cb..744b79f4e43 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -41,14 +41,17 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { eps.notify_ep = ep; eps.interrupt_interface_number = intf_desc->bInterfaceNumber; + eps.interrupt_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.in_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.out_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else { ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes); continue; @@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() { if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { if (!this->claim_comm_interface_) { - ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", - channel->cdc_dev_.interrupt_interface_number); + ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number); } else { auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number, 0); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index b54641884e6..ba073404e2f 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -40,6 +40,9 @@ struct CdcEps { // must remain valid even when the interface itself is not claimed. uint8_t interrupt_interface_number{0xFF}; bool interrupt_interface_claimed{false}; + // bInterfaceClass of each interface above, which decides how a host binds the port + uint8_t bulk_interface_class{0}; + uint8_t interrupt_interface_class{0}; }; enum CH34xChipType : uint8_t { @@ -187,6 +190,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedcdc_dev_.bulk_interface_number; } + /// bInterfaceClass of the interface `get_interface_number()` names + uint8_t get_interface_class() const { + return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class + : this->cdc_dev_.bulk_interface_class; + } + + /// Index of this port among the ports on its interface. Every driver here exposes one + /// port per interface; a multiplexed driver would index its channels. + uint8_t get_port_number() const { return 0; } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -318,13 +331,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: - USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} + USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) { + // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The + // comm interface is left in place unclaimed: on the CDC-style chips it is still the + // interface a host binds, and so the one the port is identified by. + this->claim_comm_interface_ = false; + } void dump_config() override; protected: bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; - std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: CH34xChipType chiptype_{CHIP_UNKNOWN}; diff --git a/tests/components/serial_proxy/__init__.py b/tests/components/serial_proxy/__init__.py new file mode 100644 index 00000000000..913e8d8b37e --- /dev/null +++ b/tests/components/serial_proxy/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness gives a multi-instance component an empty list and never calls its to_code, + # so take a single empty config instead and emit what the proxy messages and handlers + # need. No proxy is configured, so the count is 0, which keeps the capability messages as + # the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only. + manifest.multi_conf = False + + async def to_code_testing(config): + cg.add_define("USE_SERIAL_PROXY") + cg.add_define("SERIAL_PROXY_COUNT", 0) + + manifest.to_code = to_code_testing diff --git a/tests/components/serial_proxy/test_usb_device_descriptor.cpp b/tests/components/serial_proxy/test_usb_device_descriptor.cpp new file mode 100644 index 00000000000..c76757bbee6 --- /dev/null +++ b/tests/components/serial_proxy/test_usb_device_descriptor.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check. +static std::vector encode(const UsbDeviceDescriptor &msg) { + const uint32_t size = msg.calculate_size(); + APIBuffer buf; + EXPECT_TRUE(buf.resize(size == 0 ? 1 : size)); + uint8_t *pos = buf.data(); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = buf.data() + buf.size(); +#endif + uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG); + EXPECT_EQ(static_cast(end - buf.data()), size); + return {buf.data(), end}; +} + +TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) { + UsbDeviceDescriptor msg; + msg.vendor_id = 0x10C4; + msg.product_id = 0xEA60; + msg.bcd_device = 0x0100; + msg.interface_number = 1; + msg.interface_class = 0xFF; + msg.port_number = 2; + + const std::vector expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80, + 0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02}; + EXPECT_EQ(encode(msg), expected); +} + +TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) { + // The new fields are additive with zero defaults, so an old client sees the message it always did + UsbDeviceDescriptor msg; + msg.vendor_id = 0x303A; + msg.product_id = 0x1001; + msg.interface_number = 1; + + const std::vector expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01}; + EXPECT_EQ(encode(msg), expected); +} + +} // namespace esphome::api::testing From 2c6f59738cf868b38cd76675f6cb948025f75fc6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:41:28 +0000 Subject: [PATCH 390/651] Bump filelock from 4.1.0 to 4.1.1 (#20778) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9105f3f9c0a..ca5c5af39ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.1.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From dcb943ded39dc1a7ec470dbfa7f461fc5041ab65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:45:54 -1000 Subject: [PATCH 391/651] [noise] Initialize the protocol id's reserved field for ESP-IDF 6.1 (#20779) --- esphome/components/noise/noise_handshake.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index 181dd63bb21..46a1314d23c 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -38,6 +38,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog .cipher_id = NOISE_CIPHER_CHACHAPOLY, .hash_id = NOISE_HASH_SHA256, .hybrid_id = NOISE_DH_NONE, + .reserved = {}, }; int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER); From ea76073d869b83a3055ffc56f0feb9fab7f452aa Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:42:24 +0200 Subject: [PATCH 392/651] [micro_wake_word] Allow selecting the inference task core (#20126) Co-authored-by: n-IA-hane Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 34 ++++++++ .../micro_wake_word/micro_wake_word.cpp | 13 ++- esphome/core/defines.h | 1 + esphome/core/static_task.cpp | 6 +- esphome/core/static_task.h | 3 +- .../micro_wake_word/test.esp32-idf.yaml | 3 +- .../micro_wake_word/test.esp32-s3-idf.yaml | 1 + .../micro_wake_word/test_task_core.py | 86 +++++++++++++++++++ 8 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 tests/unit_tests/components/micro_wake_word/test_task_core.py diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 9477ebc850e..ca65fa1a5e6 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -13,6 +13,7 @@ from esphome.components.http_request import validate_url import esphome.config_validation as cv from esphome.const import ( CONF_FILE, + CONF_FRAMEWORK, CONF_ID, CONF_INTERNAL, CONF_MICROPHONE, @@ -26,10 +27,12 @@ from esphome.const import ( CONF_TYPE, CONF_URL, CONF_USERNAME, + PLATFORM_ESP32, TYPE_GIT, TYPE_LOCAL, ) from esphome.core import CORE, HexInt +import esphome.final_validate as fv from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -41,6 +44,8 @@ DEPENDENCIES = ["microphone"] DOMAIN = "micro_wake_word" +CONF_TASK_CORE = "task_core" + CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" @@ -407,6 +412,27 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config +def _validate_task_core(config: ConfigType) -> ConfigType: + if config[CONF_TASK_CORE] != 1: + return config + esp32.only_on_variant( + supported=[ + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S3, + esp32.VARIANT_ESP32P4, + esp32.VARIANT_ESP32H4, + esp32.VARIANT_ESP32S31, + ], + msg_prefix="micro_wake_word task_core: 1", + )(config) + sdkconfig = fv.full_config.get()[PLATFORM_ESP32][CONF_FRAMEWORK][ + esp32.CONF_SDKCONFIG_OPTIONS + ] + if sdkconfig.get("CONFIG_FREERTOS_UNICORE") == "y": + raise cv.Invalid("task_core: 1 requires FreeRTOS dual-core mode") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -427,6 +453,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_TASK_CORE, default=-1): cv.int_range(min=-1, max=1), 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( @@ -509,6 +536,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( }, extra=cv.ALLOW_EXTRA, ), + _validate_task_core, _feature_step_size_validate, ) @@ -521,6 +549,12 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + cg.add_define( + "MICRO_WAKE_WORD_TASK_CORE", + cg.RawExpression("tskNO_AFFINITY") + if config[CONF_TASK_CORE] == -1 + else config[CONF_TASK_CORE], + ) if config[CONF_PREFER_INTERNAL_RAM]: cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 4ae66d69bf0..6b33a3a1b56 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -17,6 +17,10 @@ namespace esphome::micro_wake_word { +// Config validation checks the YAML, this checks the build: a unicore sdkconfig reached any other way +static_assert(MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY || MICRO_WAKE_WORD_TASK_CORE < portNUM_PROCESSORS, + "micro_wake_word task_core is not a core of this build"); + ESPHOME_LOG_TAG(TAG, "micro_wake_word"); static const ssize_t DETECTION_QUEUE_LENGTH = 5; @@ -84,6 +88,12 @@ static const LogString *micro_wake_word_state_to_string(State state) { void MicroWakeWord::dump_config() { ESP_LOGCONFIG(TAG, "microWakeWord:"); + // tskNO_AFFINITY is a cast expression, so this cannot be a preprocessor test; the compiler folds it + if (MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY) { + ESP_LOGCONFIG(TAG, " Inference task core: any"); + } else { + ESP_LOGCONFIG(TAG, " Inference task core: %d", MICRO_WAKE_WORD_TASK_CORE); + } ESP_LOGCONFIG(TAG, " models:"); for (auto &model : this->wake_word_models_) { model->log_model_config(); @@ -477,7 +487,8 @@ void MicroWakeWord::loop() { } if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, - (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { + (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_, + MICRO_WAKE_WORD_TASK_CORE)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state this->status_momentary_error(1000); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3d14494df7a..0547ba6a82d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -562,6 +562,7 @@ #define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 #ifdef USE_ESP32 +#define MICRO_WAKE_WORD_TASK_CORE 0 #define USE_MICRO_WAKE_WORD #define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 87629c2507d..dd7309f8f09 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -7,7 +7,7 @@ namespace esphome { bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram) { + bool use_psram, BaseType_t core_id) { if (this->handle_ != nullptr) { // Task is already created; must call destroy() first return false; @@ -38,8 +38,8 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return false; } - this->handle_ = - xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), this->tcb_.get()); + this->handle_ = xTaskCreateStaticPinnedToCore(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), + this->tcb_.get(), core_id); if (this->handle_ == nullptr) { this->deallocate(); return false; diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 10c55f70c62..999e92a4b41 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -37,9 +37,10 @@ class StaticTask { /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM + /// @param core_id CPU core, or tskNO_AFFINITY to leave scheduling unrestricted /// @return true on success; false if either allocation fails or the task cannot be created bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram); + bool use_psram, BaseType_t core_id = tskNO_AFFINITY); /// @brief Delete the task, keeping the TCB and stack buffer allocated for reuse by a subsequent create() call. /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is diff --git a/tests/components/micro_wake_word/test.esp32-idf.yaml b/tests/components/micro_wake_word/test.esp32-idf.yaml index fa3984d57e0..726013a0602 100644 --- a/tests/components/micro_wake_word/test.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-idf.yaml @@ -3,5 +3,4 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml - -<<: !include common.yaml + micro_wake_word: !include common.yaml 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 8806d8046d9..3ed671a764d 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -6,4 +6,5 @@ packages: micro_wake_word: !include common.yaml micro_wake_word: + task_core: 1 prefer_internal_ram: true diff --git a/tests/unit_tests/components/micro_wake_word/test_task_core.py b/tests/unit_tests/components/micro_wake_word/test_task_core.py new file mode 100644 index 00000000000..563022a2400 --- /dev/null +++ b/tests/unit_tests/components/micro_wake_word/test_task_core.py @@ -0,0 +1,86 @@ +"""Validate inference task affinity before generating firmware.""" + +import pytest + +from esphome.components import audio, micro_wake_word as mww +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_ESP32, KEY_TARGET_PLATFORM, KEY_VARIANT +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + "variant", + ["ESP32", "ESP32S3", "ESP32P4", "ESP32H4", "ESP32S31", "ESP32S2", "ESP32C3"], +) +@pytest.mark.parametrize("core", [-1, 0, 1]) +def test_task_core_matches_target(variant: str, core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": core}) + if core == 1 and variant in ("ESP32S2", "ESP32C3"): + with pytest.raises(cv.Invalid, match="only available"): + mww._validate_task_core(config) + else: + assert mww._validate_task_core(config)["task_core"] == core + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("core", [-2, 2]) +def test_invalid_task_core(core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + with pytest.raises(cv.Invalid): + mww.CONFIG_SCHEMA({"task_core": core}) + + +def test_default_task_core_is_unpinned() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + assert mww.CONFIG_SCHEMA({})["task_core"] == -1 + + +@pytest.mark.parametrize("unicore", ["y", "n", "true", "1"]) +def test_core_one_matches_freertos_unicore_setting(unicore: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + token = fv.full_config.set( + { + "esp32": { + "framework": {"sdkconfig_options": {"CONFIG_FREERTOS_UNICORE": unicore}} + } + } + ) + try: + if unicore == "y": + with pytest.raises(cv.Invalid, match="dual-core"): + mww._validate_task_core({"task_core": 1}) + else: + assert mww._validate_task_core({"task_core": 1})["task_core"] == 1 + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32S3", "ESP32C3"]) +def test_task_core_final_validation( + variant: str, monkeypatch: pytest.MonkeyPatch +) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + # Isolate microphone capabilities; exercise the real final-validation chain. + monkeypatch.setattr( + audio, + "final_validate_audio_schema", + lambda *args, **kwargs: lambda config: config, + ) + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": 1}) + if variant == "ESP32C3": + with pytest.raises(cv.Invalid, match="only available"): + mww.FINAL_VALIDATE_SCHEMA(config) + else: + mww.FINAL_VALIDATE_SCHEMA(config) + finally: + fv.full_config.reset(token) From 0e2c4cf5c2e85c9fb7b6e571af6184f7a0872716 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:07 -1000 Subject: [PATCH 393/651] Bump aioesphomeapi from 46.11.0 to 46.12.0 (#20781) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca5c5af39ac..0c1a1fb6235 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.11.0 +aioesphomeapi==46.12.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 48a0a41cce3b705c85cea1bc96f3b17c2822969a Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 14 Oct 2026 19:49:37 -0300 Subject: [PATCH 394/651] [wifi] Implement access point WISP mode (#13633) Co-authored-by: J. Nick Koston Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/network/__init__.py | 33 +++- esphome/components/wifi/__init__.py | 93 ++++++++- esphome/components/wifi/wifi_component.cpp | 49 +++-- esphome/components/wifi/wifi_component.h | 19 +- .../wifi/wifi_component_esp_idf.cpp | 99 ++++++++++ esphome/core/defines.h | 4 + .../network/config/high_perf_wifi_only.yaml | 11 ++ .../network/test_high_performance.py | 17 +- .../components/wifi/test-wisp.esp32-idf.yaml | 8 + .../wifi/validate-wisp-idf6.esp32-idf.yaml | 16 ++ tests/unit_tests/components/test_wifi.py | 180 +++++++++++++++++- 11 files changed, 501 insertions(+), 28 deletions(-) create mode 100644 tests/component_tests/network/config/high_perf_wifi_only.yaml create mode 100644 tests/components/wifi/test-wisp.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 89835128fe2..0227e0a6bd4 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -27,7 +27,8 @@ _LOGGER = logging.getLogger(__name__) # High performance networking tracking infrastructure # Components can request high performance networking and this configures lwip and WiFi settings -KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" +KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" # lwip tier +KEY_HIGH_PERFORMANCE_WIFI = "high_performance_wifi" # WiFi driver buffers CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" CONF_TCP_SEND_BUFFER = "tcp_send_buffer" @@ -225,9 +226,26 @@ def require_high_performance_networking() -> None: _request_high_performance_networking, ) """ - # Only set up once (idempotent - multiple components can call this) - if not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False): - CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True + CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True + require_high_performance_wifi() + + +def require_high_performance_wifi() -> None: + """Request only the WiFi driver part of high performance networking. + + For traffic that passes through the device below its own sockets, such as the + NAT of a WISP access point: more RX/TX buffers, AMPDU aggregation and a deeper + lwip input mailbox help every forwarded packet, while the larger TCP windows + would only serve the device's own connections and cost internal RAM for nothing. + ``network: enable_high_performance: false`` turns the mailbox part off like the + lwip tier; the WiFi driver settings follow the component request alone. + """ + CORE.data[KEY_HIGH_PERFORMANCE_WIFI] = True + + +def has_high_performance_wifi() -> bool: + """True when a component asked for the high performance WiFi driver settings.""" + return CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI, False) def has_high_performance_networking() -> bool: @@ -519,6 +537,13 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", tcp_window) add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", tcpip_mailbox) + elif ( + CORE.is_esp32 and enable_high_perf is not False and has_high_performance_wifi() + ): + # Forwarded frames still queue through tcpip_input, so the WiFi-only tier deepens that mailbox too + add_idf_sdkconfig_option( + "CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", TCPIP_RECVMBOX_OPTIMIZED + ) # After the high performance block so an explicit size wins over the # bundle's 65534 (last write wins in the sdkconfig store). diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 667e288fd72..583a9ed9992 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -18,8 +18,9 @@ from esphome.components.esp32 import ( from esphome.components.network import ( add_use_address, get_network_priority, - has_high_performance_networking, + has_high_performance_wifi, ip_address_literal, + require_high_performance_wifi, ) from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.config_helpers import filter_source_files_from_platform @@ -160,6 +161,8 @@ CONF_BAND_MODE = "band_mode" CONF_MIN_AUTH_MODE = "min_auth_mode" CONF_PHY_MODE = "phy_mode" CONF_POST_CONNECT_ROAMING = "post_connect_roaming" +CONF_AP_MODE = "ap_mode" +CONF_DNS_SERVERS = "dns_servers" # Maximum number of WiFi networks that can be configured # Limited to 127 because selected_sta_index_ is int8_t in C++ @@ -170,6 +173,7 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_AP_TIMEOUT_PERIOD = cv.time_period(DEFAULT_AP_TIMEOUT) DEFAULT_REBOOT_TIMEOUT = "15min" # Both defaults also match the C++ initializers in wifi_component.h; codegen skips # the setter when the config equals them. @@ -209,6 +213,32 @@ WIFI_8266_PHY_MODES = { "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, } + +WIFI_AP_MODE_FALLBACK = "FALLBACK" +WIFI_AP_MODE_WISP = "WISP" + + +def _validate_ap_mode(value: str) -> str: + value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value) + if value == WIFI_AP_MODE_WISP: + # esp_netif_napt_enable() exists from ESP-IDF 5.1 + cv.require_framework_version( + esp_idf=cv.Version(5, 1, 0), extra_message="WISP mode routes through NAT" + )(value) + return value + + +def _validate_dns_servers(value: list) -> list: + if any(ip.is_unspecified for ip in value): + raise cv.Invalid("0.0.0.0 is not a DNS server the access point can offer") + if len(value) > 1: + cv.require_framework_version( + esp_idf=cv.Version(6, 0, 0), + extra_message="The access point offers a second DNS server from ESP-IDF 6.0", + )(value) + return value + + WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) @@ -304,6 +334,12 @@ WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend( cv.Optional( CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AP_MODE, default=WIFI_AP_MODE_FALLBACK): _validate_ap_mode, + cv.Optional(CONF_DNS_SERVERS): cv.All( + cv.ensure_list(cv.ipv4address), + cv.Length(min=1, max=2), + _validate_dns_servers, + ), } ) @@ -312,6 +348,19 @@ def wifi_network_ap(value): if value is None: value = {} config = WIFI_NETWORK_AP(value) + if config[CONF_AP_MODE] == WIFI_AP_MODE_WISP: + if not config.get(CONF_PASSWORD): + raise cv.Invalid( + "WISP mode requires a password for the access point", + path=[CONF_PASSWORD], + ) + if config[CONF_AP_TIMEOUT].total_milliseconds == 0: + raise cv.Invalid( + "ap_timeout is how long the bridge waits before a failed start is tried again; it cannot be 0", + path=[CONF_AP_TIMEOUT], + ) + elif CONF_DNS_SERVERS in config: + raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS]) if CONF_MANUAL_IP in config and CORE.is_rp2: raise cv.Invalid( "Manual AP IP configuration is not supported on RP2040. " @@ -370,7 +419,30 @@ def final_validate(config): raise cv.Invalid( "Please specify at least an SSID or an Access Point to create." ) - if has_ap and not has_captive_portal and not has_web_server: + + ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP + if ap_is_wisp: + if not has_sta: + raise cv.Invalid( + "WISP mode requires at least one STA network (wifi.ssid or wifi.networks)" + ) + if has_captive_portal: + raise cv.Invalid( + "captive_portal is not supported together with ap_mode: WISP" + ) + # Every client packet passes through the WiFi driver, none through the device's own sockets + require_high_performance_wifi() + if CONF_DNS_SERVERS not in config[CONF_AP]: + # The access point passes the station's resolvers on, which a static address has to name + for network in [config, *config.get(CONF_NETWORKS, [])]: + static = network.get(CONF_MANUAL_IP) + if static and static[CONF_DNS1].is_unspecified: + raise cv.Invalid( + "manual_ip needs dns1 in WISP mode unless ap.dns_servers is set", + path=[CONF_MANUAL_IP, CONF_DNS1], + ) + + if has_ap and not has_captive_portal and not has_web_server and not ap_is_wisp: _LOGGER.warning( "WiFi AP is configured but neither captive_portal nor web_server is enabled. " "The AP will not be usable for configuration or monitoring. " @@ -708,8 +780,19 @@ async def to_code(config): lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) # Skip the setter when the config matches the C++ initializer. - if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): + if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD: cg.add(var.set_ap_timeout(ap_timeout)) + if conf[CONF_AP_MODE] == WIFI_AP_MODE_WISP: + cg.add_define("USE_WIFI_AP_WISP") + add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True) + add_idf_sdkconfig_option("CONFIG_LWIP_IPV4_NAPT", True) + if dns_servers := conf.get(CONF_DNS_SERVERS): + cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS") + # As lwIP stores an IPv4 address: the four bytes read as one little-endian word + for i, ip in enumerate(dns_servers): + cg.add_define( + f"WIFI_AP_WISP_DNS_{i}", int.from_bytes(ip.packed, "little") + ) cg.add_define("USE_WIFI_AP") # The LN882H radio cannot run the AP and STA together; the fallback AP # takes turns with the networks instead. @@ -823,8 +906,8 @@ async def to_code(config): if config.get(CONF_USE_PSRAM): add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) - # Apply high performance WiFi settings if high performance networking is enabled - if CORE.is_esp32 and has_high_performance_networking(): + # Apply high performance WiFi settings if a component asked for them + if CORE.is_esp32 and has_high_performance_wifi(): # Check if PSRAM is guaranteed (set by psram component during final validation) psram_guaranteed = psram_is_guaranteed() diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 62732e09153..70b208769b7 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -655,10 +655,10 @@ void WiFiComponent::setup() { this->configured_power_save_ = this->power_save_; #endif -#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) +#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // 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. + // loop() is gated so none of them is started again afterwards. A bridge AP carries no portal. 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_ @@ -911,8 +911,8 @@ void WiFiComponent::loop() { // The networks are not tried while the AP is up, so it pauses for // them once nobody has used it for a while. Without networks the AP // is all there is and stays. - if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && - (this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) + if (this->has_sta() && now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && + (this->ap_clients_ == 0 || now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) this->pause_exclusive_ap_(); break; #endif @@ -922,12 +922,20 @@ void WiFiComponent::loop() { return; } -#ifdef USE_WIFI_AP - bool provisioning_closed = false; -#ifdef USE_PROVISIONING +#ifdef USE_WIFI_AP_WISP + // The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout + if (!this->ap_setup_ && this->ap_dns_known_() && + (this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) { + ESP_LOGI(TAG, "Starting AP"); + this->ap_changed_ = now; + this->setup_ap_config_(); + } +#elif defined(USE_WIFI_AP) // Once the provisioning window has closed, don't bring up the fallback AP (or // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. + bool provisioning_closed = false; +#ifdef USE_PROVISIONING provisioning_closed = provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); #endif @@ -937,12 +945,12 @@ void WiFiComponent::loop() { #ifdef USE_WIFI_AP_EXCLUSIVE // After a pause, or a start that failed, the networks get a full // ap_timeout before the AP is tried again. - && now - this->ap_exclusive_changed_ > this->ap_timeout_ + && now - this->ap_changed_ > this->ap_timeout_ #endif ) { ESP_LOGI(TAG, "Starting fallback AP"); #ifdef USE_WIFI_AP_EXCLUSIVE - this->ap_exclusive_changed_ = now; + this->ap_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. @@ -1063,9 +1071,9 @@ void WiFiComponent::setup_ap_config_() { } } this->ap_setup_ = this->wifi_start_ap_(this->ap_); -#ifdef USE_WIFI_AP_EXCLUSIVE - // A failed start must not leave the radio in AP mode, which would refuse - // the STA attempts until the next try. +#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) + // A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse + // the STA attempts until the next try, and a bridge would take clients it cannot serve. if (!this->ap_setup_) this->wifi_mode_({}, false); #endif @@ -1108,7 +1116,7 @@ void WiFiComponent::pause_exclusive_ap_() { this->ap_setup_ = false; // Clients drop with the AP without a disconnect event for each. this->ap_clients_ = 0; - this->ap_exclusive_changed_ = App.get_loop_component_start_time(); + this->ap_changed_ = App.get_loop_component_start_time(); // A fresh connection cycle, as at boot, rather than resuming the one the // AP cut short. this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; @@ -1117,6 +1125,13 @@ void WiFiComponent::pause_exclusive_ap_() { } #endif +#ifdef USE_WIFI_AP_WISP +void WiFiComponent::fail_bridge_() { + this->ap_setup_ = false; + this->wifi_mode_({}, false); +} +#endif + void WiFiComponent::disable_ap_() { this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); @@ -1452,6 +1467,12 @@ void WiFiComponent::disable() { #endif this->wifi_disconnect_(); this->wifi_mode_(false, false); +#ifdef USE_WIFI_AP + this->ap_setup_ = false; // the AP went down with the radio; enable() may bring it up again +#endif +#ifdef USE_WIFI_AP_WISP + this->ap_changed_ = 0; // at once, not after ap_timeout +#endif } void WiFiComponent::start_scanning() { @@ -1742,7 +1763,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; -#ifdef USE_WIFI_AP +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients if (this->has_ap()) { this->disable_ap_(); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c1f4061a534..964de6996a3 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -721,6 +721,19 @@ class WiFiComponent final : public Component { #endif #endif // USE_WIFI_AP +#ifdef USE_WIFI_AP_WISP + /// Whether there is an IPv4 resolver to offer AP clients: a configured one, or the station's + bool ap_dns_known_(); + /// Hand the AP DHCP server its resolvers; false when one could not be set + bool write_ap_dns_(); + /// Take the bridge down so loop() runs its setup again after ap_timeout + void fail_bridge_(); +#ifndef USE_WIFI_AP_WISP_STATIC_DNS + /// Pass the station's resolvers on to AP clients when they changed + void apply_ap_dns_(); +#endif +#endif + void print_connect_params_(); WiFiAP build_params_for_current_phase_(); @@ -971,8 +984,8 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; -#ifdef USE_WIFI_AP_EXCLUSIVE - uint32_t ap_exclusive_changed_{0}; // When the AP was last started or paused +#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) + uint32_t ap_changed_{0}; // When the AP was last started or paused #endif uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; @@ -982,7 +995,7 @@ class WiFiComponent final : public Component { // WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the // 1-byte members so they pack into its trailing bytes instead of padding after it WiFiAP ap_; -#endif +#endif // USE_WIFI_AP // 1-byte enums and integers WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index bdca350ddeb..10dd67dba92 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1054,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; +#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS) + this->apply_ap_dns_(); +#endif #ifdef USE_WIFI_LEASE_CACHE this->save_lease_settings_(); // events are drained on the main loop #endif @@ -1172,6 +1175,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { ESP_LOGV(TAG, "AP start"); this->ap_started_ = true; +#ifdef USE_WIFI_AP_WISP + // NAPT needs the interface up, so it cannot be part of the setup. Without it the clients have no way out, so + // the AP goes down and its setup runs again, which brings it back up through this event. + if (esp_err_t err = esp_netif_napt_enable(s_ap_netif); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable NAT: %s", esp_err_to_name(err)); + this->fail_bridge_(); + } +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); @@ -1349,6 +1360,19 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { } #endif +#ifdef USE_WIFI_AP_WISP + // Offer resolvers through DHCP: the configured ones, otherwise the station's. Without them the server would + // offer the AP's own address, so a failure fails the setup and it is tried again. + uint8_t dns_offer = 1; + err = + esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &dns_offer, sizeof(dns_offer)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable DHCP DNS option: %s", esp_err_to_name(err)); + } + if (err != ESP_OK || !this->write_ap_dns_()) + return false; +#endif + err = esp_netif_dhcps_start(s_ap_netif); if (err != ESP_OK) { @@ -1489,6 +1513,81 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_ip); } +#ifdef USE_WIFI_AP_WISP +// A DHCP server interface takes a backup resolver from ESP-IDF 6.0 +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +static constexpr size_t AP_DNS_COUNT = 2; +#else +static constexpr size_t AP_DNS_COUNT = 1; +#endif +#ifdef USE_WIFI_AP_WISP_STATIC_DNS +// The configured resolvers as lwIP stores them, from codegen +static constexpr uint32_t AP_DNS[] = { + WIFI_AP_WISP_DNS_0, +#ifdef WIFI_AP_WISP_DNS_1 + WIFI_AP_WISP_DNS_1, +#endif +}; +#endif + +static uint32_t ap_dns_to_offer(size_t i) { +#ifdef USE_WIFI_AP_WISP_STATIC_DNS + return i < std::size(AP_DNS) ? AP_DNS[i] : 0; +#else + return ip_addr_get_ip4_u32(dns_getserver(i)); +#endif +} + +bool WiFiComponent::ap_dns_known_() { return ap_dns_to_offer(0) != 0; } + +bool WiFiComponent::write_ap_dns_() { + bool ok = true; + for (size_t i = 0; i < AP_DNS_COUNT; i++) { + const uint32_t addr = ap_dns_to_offer(i); + if (addr == 0) + continue; + esp_netif_dns_info_t info{}; + info.ip.type = ESP_IPADDR_TYPE_V4; + info.ip.u_addr.ip4.addr = addr; + esp_err_t err = esp_netif_set_dns_info(s_ap_netif, static_cast(i), &info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to set DHCP DNS server %u: %s", (unsigned) i, esp_err_to_name(err)); + ok = false; + } else { + ESP_LOGV(TAG, "DHCP DNS server: " IPSTR, IP2STR(&info.ip.u_addr.ip4)); + } + } + return ok; +} + +#ifndef USE_WIFI_AP_WISP_STATIC_DNS +void WiFiComponent::apply_ap_dns_() { + if (!this->ap_setup_) + return; // loop() brings the bridge up once the station has a resolver, and its setup writes them + // The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the + // next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address. + bool changed = false; + for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) { + const uint32_t addr = ap_dns_to_offer(i); + esp_netif_dns_info_t current{}; + changed = + addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast(i), ¤t) != ESP_OK || + current.ip.u_addr.ip4.addr != addr); + } + if (!changed) + return; + // The IP setup programs the DHCP server from scratch without touching the soft AP, so clients stay joined +#ifdef USE_WIFI_MANUAL_IP + const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip()); +#else + const bool ok = this->wifi_ap_ip_config_({}); +#endif + if (!ok) + this->fail_bridge_(); +} +#endif // USE_WIFI_AP_WISP_STATIC_DNS +#endif // USE_WIFI_AP_WISP + } // namespace esphome::wifi #endif // USE_ESP32 #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0547ba6a82d..7763812054a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -500,6 +500,10 @@ #define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_LEASE_CACHE #define USE_WIFI_LEASE_CACHE_IN_FLASH +#define USE_WIFI_AP_WISP +#define USE_WIFI_AP_WISP_STATIC_DNS +#define WIFI_AP_WISP_DNS_0 0x08080808 +#define WIFI_AP_WISP_DNS_1 0x04040808 #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS diff --git a/tests/component_tests/network/config/high_perf_wifi_only.yaml b/tests/component_tests/network/config/high_perf_wifi_only.yaml new file mode 100644 index 00000000000..61dfde3e039 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/network/test_high_performance.py b/tests/component_tests/network/test_high_performance.py index c291806ac4b..8452a56a057 100644 --- a/tests/component_tests/network/test_high_performance.py +++ b/tests/component_tests/network/test_high_performance.py @@ -9,7 +9,10 @@ from pathlib import Path import pytest -from esphome.components.network import require_high_performance_networking +from esphome.components.network import ( + require_high_performance_networking, + require_high_performance_wifi, +) from tests.component_tests.network import sdkconfig_option @@ -52,3 +55,15 @@ def test_lwip_tier( assert sdkconfig_option("CONFIG_LWIP_WND_SCALE") is window_scale # Wifi RX buffers really go to PSRAM, so the wifi tier is never downgraded assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == wifi_rx_buffers + + +def test_wifi_only_request_leaves_lwip_alone( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A WiFi-only request sizes the driver buffers but not the TCP windows.""" + require_high_performance_wifi() + generate_main(component_config_path("high_perf_wifi_only.yaml")) + assert sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT") is None + assert sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE") == 64 + assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == 64 diff --git a/tests/components/wifi/test-wisp.esp32-idf.yaml b/tests/components/wifi/test-wisp.esp32-idf.yaml new file mode 100644 index 00000000000..757e825358c --- /dev/null +++ b/tests/components/wifi/test-wisp.esp32-idf.yaml @@ -0,0 +1,8 @@ +wifi: + ap: + ssid: WISP AP + password: wisp12345678 + ap_mode: wisp + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml b/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml new file mode 100644 index 00000000000..cd54b3b144b --- /dev/null +++ b/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml @@ -0,0 +1,16 @@ +# A second DNS server needs ESP-IDF 6.0; config-only, a build would re-use the 5.x build dir +esp32: + framework: + version: 6.0.1 + +wifi: + ap: + ssid: WISP AP + password: wisp12345678 + ap_mode: wisp + dns_servers: + - 8.8.8.8 + - 8.8.4.4 + +packages: + wifi: !include common.yaml diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 3899b3d8540..b09e37aacf1 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -1,22 +1,42 @@ """Tests for WiFi component public helpers.""" +from ipaddress import IPv4Address + import pytest from esphome.components.esp32 import const +from esphome.components.network import ( + KEY_HIGH_PERFORMANCE_NETWORKING, + KEY_HIGH_PERFORMANCE_WIFI, +) from esphome.components.wifi import ( + CONF_AP_MODE, + CONF_AP_TIMEOUT, + CONF_DNS_SERVERS, check_placeholder_credentials, + final_validate as wifi_final_validate, has_native_wifi, variant_has_wifi, + wifi_network_ap, ) +import esphome.config_validation as cv from esphome.const import ( CONF_AP, + CONF_DNS1, + CONF_MANUAL_IP, CONF_NETWORKS, + CONF_PASSWORD, CONF_SSID, CONF_WIFI, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, PLACEHOLDER_WIFI_SSID, Platform, ) -from esphome.core import EsphomeError, Lambda +from esphome.core import CORE, EsphomeError, Lambda +import esphome.final_validate as fv @pytest.mark.parametrize( @@ -197,3 +217,161 @@ def test_check_placeholder_credentials_skips_template_ssid() -> None: """A templated (Lambda) SSID is not a string and is skipped.""" config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) assert check_placeholder_credentials(config) is None + + +@pytest.fixture +def wisp_core(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """An ESP32 build; the parameter overrides the framework or the ESP-IDF version.""" + params = getattr(request, "param", {}) + monkeypatch.setitem( + CORE.data, + KEY_CORE, + { + KEY_TARGET_PLATFORM: Platform.ESP32, + KEY_TARGET_FRAMEWORK: params.get("framework", "esp-idf"), + KEY_FRAMEWORK_VERSION: params.get("idf", cv.Version(5, 4, 0)), + }, + ) + + +def _wisp_ap(**overrides: object) -> dict: + return {CONF_AP_MODE: "WISP", CONF_PASSWORD: "wisp12345678", **overrides} + + +def _final_validate(config: dict, full_extra: dict | None = None) -> None: + token = fv.full_config.set({CONF_WIFI: config, **(full_extra or {})}) + try: + wifi_final_validate(config) + finally: + fv.full_config.reset(token) + + +@pytest.mark.usefixtures("wisp_core") +@pytest.mark.parametrize( + "ap", [{CONF_AP_MODE: "WISP"}, _wisp_ap(**{CONF_PASSWORD: ""})] +) +def test_wisp_requires_ap_password(ap: dict) -> None: + """WISP must not route upstream access through an open access point.""" + with pytest.raises(cv.Invalid, match="password"): + wifi_network_ap(ap) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_accepts_ap_password() -> None: + assert wifi_network_ap(_wisp_ap())[CONF_AP_MODE] == "WISP" + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_rejects_zero_ap_timeout() -> None: + """Zero disables the fallback AP; the bridge would retry a failed start on every loop pass.""" + with pytest.raises(cv.Invalid, match="ap_timeout"): + wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "0s"})) + wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "30s"})) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_reject_unspecified_address() -> None: + """0.0.0.0 would leave the clients with the access point as their resolver.""" + with pytest.raises(cv.Invalid, match="0.0.0.0"): + wifi_network_ap(_wisp_ap(dns_servers=["0.0.0.0"])) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_require_wisp() -> None: + with pytest.raises(cv.Invalid, match="dns_servers requires ap_mode: WISP"): + wifi_network_ap({CONF_DNS_SERVERS: ["1.1.1.1"]}) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_limited_to_two() -> None: + """The access point DHCP server has a main and a backup resolver.""" + with pytest.raises(cv.Invalid): + wifi_network_ap(_wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8", "9.9.9.9"])) + + +@pytest.mark.parametrize( + ("wisp_core", "valid"), + [({"idf": cv.Version(5, 5, 5)}, False), ({"idf": cv.Version(6, 0, 1)}, True)], + indirect=["wisp_core"], +) +def test_second_dns_server_requires_idf_6(wisp_core: None, valid: bool) -> None: + ap = _wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8"]) + if valid: + wifi_network_ap(ap) + else: + with pytest.raises(cv.Invalid, match="6.0.0"): + wifi_network_ap(ap) + + +@pytest.mark.parametrize("wisp_core", [{"framework": "arduino"}], indirect=True) +def test_wisp_requires_esp_idf(wisp_core: None) -> None: + """NAT is only available with the ESP-IDF framework.""" + with pytest.raises(cv.Invalid, match="incompatible"): + wifi_network_ap(_wisp_ap()) + + +@pytest.mark.parametrize("wisp_core", [{"idf": cv.Version(5, 0, 9)}], indirect=True) +def test_wisp_requires_idf_5_1(wisp_core: None) -> None: + """esp_netif_napt_enable() is missing before ESP-IDF 5.1.""" + with pytest.raises(cv.Invalid, match="5.1.0"): + wifi_network_ap(_wisp_ap()) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_requires_sta_network() -> None: + """WISP has nothing to share without an upstream network.""" + with pytest.raises(cv.Invalid, match="at least one STA network"): + _final_validate({CONF_NETWORKS: [], CONF_AP: _wisp_ap()}) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_rejects_captive_portal() -> None: + with pytest.raises(cv.Invalid, match="captive_portal"): + _final_validate( + {CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}, {"captive_portal": {}} + ) + + +def _manual_ip(dns1: str) -> dict: + return {CONF_DNS1: IPv4Address(dns1)} + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_manual_ip_needs_dns1() -> None: + """The bridge passes the station's resolvers on, which a static address must name.""" + with pytest.raises(cv.Invalid, match="dns1"): + _final_validate( + { + CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}], + CONF_AP: _wisp_ap(), + } + ) + with pytest.raises(cv.Invalid, match="dns1"): + _final_validate( + { + CONF_MANUAL_IP: _manual_ip("0.0.0.0"), + CONF_NETWORKS: [{}], + CONF_AP: _wisp_ap(), + } + ) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_manual_ip_with_dns1_or_dns_servers() -> None: + _final_validate( + {CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("1.1.1.1")}], CONF_AP: _wisp_ap()} + ) + _final_validate( + { + CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}], + CONF_AP: _wisp_ap(**{CONF_DNS_SERVERS: [IPv4Address("1.1.1.1")]}), + } + ) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_final_validate_accepts_sta_and_ap() -> None: + _final_validate({CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}) + # NAT needs the WiFi driver buffers, not the lwip TCP windows + assert CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI) is True + assert not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING) From d8ca4a9460129bcd9c6d528a222df283fe008951 Mon Sep 17 00:00:00 2001 From: Giovanni <105289353+GioMez@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:50:41 +0200 Subject: [PATCH 395/651] [audio_dac] Add ES9038Q2M Katana platform (#15017) 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 + .../components/es9038q2m_katana/__init__.py | 3 + .../components/es9038q2m_katana/audio_dac.py | 92 +++++++++ .../es9038q2m_katana/es9038q2m_katana.cpp | 176 ++++++++++++++++++ .../es9038q2m_katana/es9038q2m_katana.h | 62 ++++++ tests/components/es9038q2m_katana/common.yaml | 16 ++ .../es9038q2m_katana/test.esp32-idf.yaml | 3 + .../es9038q2m_katana/test.esp8266-ard.yaml | 3 + 8 files changed, 356 insertions(+) create mode 100644 esphome/components/es9038q2m_katana/__init__.py create mode 100644 esphome/components/es9038q2m_katana/audio_dac.py create mode 100644 esphome/components/es9038q2m_katana/es9038q2m_katana.cpp create mode 100644 esphome/components/es9038q2m_katana/es9038q2m_katana.h create mode 100644 tests/components/es9038q2m_katana/common.yaml create mode 100644 tests/components/es9038q2m_katana/test.esp32-idf.yaml create mode 100644 tests/components/es9038q2m_katana/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 56cb685ec53..6adf2a93594 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -183,6 +183,7 @@ esphome/components/es7243e/* @kbx81 esphome/components/es8156/* @kbx81 esphome/components/es8311/* @kahrendt @kroimon esphome/components/es8388/* @P4uLT +esphome/components/es9038q2m_katana/* @GioMez esphome/components/esp32/* @esphome/core esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito esphome/components/esp32_ble_client/* @bdraco @jesserockz diff --git a/esphome/components/es9038q2m_katana/__init__.py b/esphome/components/es9038q2m_katana/__init__.py new file mode 100644 index 00000000000..5950268ad04 --- /dev/null +++ b/esphome/components/es9038q2m_katana/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@GioMez"] +DEPENDENCIES = ["i2c"] +DOMAIN = "es9038q2m_katana" diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py new file mode 100644 index 00000000000..f5c14d234c6 --- /dev/null +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -0,0 +1,92 @@ +import esphome.codegen as cg +from esphome.components import audio_dac, i2c +import esphome.config_validation as cv +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +CONF_DEEMPHASIS = "deemphasis" +CONF_DOP = "dop" +CONF_FILTER_SHAPE = "filter_shape" + +# Fields of the Katana format register: channel layout, word length and rate code. +FORMAT_STEREO = 0x80 +BITS_PER_SAMPLE_CODES = {16: 0x10, 24: 0x20, 32: 0x30} +SAMPLE_RATE_CODES = { + 44100: 0x04, + 48000: 0x05, + 88200: 0x06, + 96000: 0x07, + 176400: 0x08, + 192000: 0x09, + 352800: 0x0A, + 384000: 0x0B, +} + +es9038q2m_katana_ns = cg.esphome_ns.namespace("es9038q2m_katana") +ES9038Q2MKatana = es9038q2m_katana_ns.class_( + "ES9038Q2MKatana", + audio_dac.AudioDac, + cg.Component, + i2c.I2CDevice, +) + +FilterShape = es9038q2m_katana_ns.enum("FilterShape") +FILTER_SHAPES = { + "linear_phase_fast": FilterShape.FILTER_SHAPE_LINEAR_PHASE_FAST, + "linear_phase_slow": FilterShape.FILTER_SHAPE_LINEAR_PHASE_SLOW, + "minimum_phase_fast": FilterShape.FILTER_SHAPE_MIN_PHASE_FAST, + "minimum_phase_slow": FilterShape.FILTER_SHAPE_MIN_PHASE_SLOW, + "apodizing": FilterShape.FILTER_SHAPE_APODIZING, + "hybrid": FilterShape.FILTER_SHAPE_HYBRID, + "brick_wall": FilterShape.FILTER_SHAPE_BRICK_WALL, +} + +DeemphasisMode = es9038q2m_katana_ns.enum("DeemphasisMode") +DEEMPHASIS_MODES = { + "bypass": DeemphasisMode.DEEMPHASIS_BYPASS, + "32khz": DeemphasisMode.DEEMPHASIS_32KHZ, + "44_1khz": DeemphasisMode.DEEMPHASIS_44_1KHZ, + "48khz": DeemphasisMode.DEEMPHASIS_48KHZ, +} + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ES9038Q2MKatana), + cv.Optional(CONF_FILTER_SHAPE, default="apodizing"): cv.enum( + FILTER_SHAPES, lower=True + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + cv.float_with_unit("bits", "bit"), + cv.one_of(*BITS_PER_SAMPLE_CODES, int=True), + ), + cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.one_of( + *SAMPLE_RATE_CODES, int=True + ), + cv.Optional(CONF_DEEMPHASIS, default="bypass"): cv.enum( + DEEMPHASIS_MODES, lower=True + ), + # Only switches the control logic; the source must already send DoP frames. + cv.Optional(CONF_DOP, default=False): cv.boolean, + } + ) + .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) + + cg.add(var.set_filter_shape(config[CONF_FILTER_SHAPE])) + cg.add( + var.set_format( + FORMAT_STEREO + | BITS_PER_SAMPLE_CODES[config[CONF_BITS_PER_SAMPLE]] + | SAMPLE_RATE_CODES[config[CONF_SAMPLE_RATE]] + ) + ) + cg.add(var.set_deemphasis_mode(config[CONF_DEEMPHASIS])) + cg.add(var.set_dop_enabled(config[CONF_DOP])) diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp new file mode 100644 index 00000000000..464f971acef --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -0,0 +1,176 @@ +#include "es9038q2m_katana.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::es9038q2m_katana { + +ESPHOME_LOG_TAG(TAG, "es9038q2m_katana"); + +namespace { + +constexpr uint8_t KATANA_CHIP_ID = 0x30; + +constexpr uint8_t KATANA_REG_CHIP_ID = 0x00; +constexpr uint8_t KATANA_REG_RESET = 0x01; +constexpr uint8_t KATANA_REG_VOLUME_1 = 0x02; +constexpr uint8_t KATANA_REG_VOLUME_2 = 0x03; +constexpr uint8_t KATANA_REG_MUTE = 0x04; +constexpr uint8_t KATANA_REG_DSP_PROGRAM = 0x05; +constexpr uint8_t KATANA_REG_DEEMPHASIS = 0x06; +constexpr uint8_t KATANA_REG_DOP = 0x07; +constexpr uint8_t KATANA_REG_FORMAT = 0x08; +constexpr uint8_t KATANA_REG_COMMAND = 0x09; +constexpr uint8_t KATANA_REG_MUTE_STREAM = 0x0A; + +constexpr float KATANA_VOLUME_MIDPOINT = 0.5f; +constexpr float KATANA_VOLUME_MIDPOINT_STEPS = 50.0f; // -25 dB at 50% volume. +// Cubic amplitude gain: -20 * log10(volume^3) / 0.5 attenuation steps. +constexpr float KATANA_VOLUME_CUBIC_SCALE = 120.0f; +constexpr uint8_t KATANA_VOLUME_FULL_ATTENUATION = 255; + +// The control logic ignores writes for this long after a reset. +constexpr uint32_t RESET_SETTLE_DELAY_MS = 10; + +const LogString *filter_shape_to_str(FilterShape shape) { + switch (shape) { + case FILTER_SHAPE_LINEAR_PHASE_FAST: + return LOG_STR("linear_phase_fast"); + case FILTER_SHAPE_LINEAR_PHASE_SLOW: + return LOG_STR("linear_phase_slow"); + case FILTER_SHAPE_MIN_PHASE_FAST: + return LOG_STR("minimum_phase_fast"); + case FILTER_SHAPE_MIN_PHASE_SLOW: + return LOG_STR("minimum_phase_slow"); + case FILTER_SHAPE_APODIZING: + return LOG_STR("apodizing"); + case FILTER_SHAPE_HYBRID: + return LOG_STR("hybrid"); + default: + return LOG_STR("brick_wall"); + } +} + +const LogString *deemphasis_to_str(DeemphasisMode mode) { + switch (mode) { + case DEEMPHASIS_32KHZ: + return LOG_STR("32khz"); + case DEEMPHASIS_44_1KHZ: + return LOG_STR("44_1khz"); + case DEEMPHASIS_48KHZ: + return LOG_STR("48khz"); + default: + return LOG_STR("bypass"); + } +} + +} // namespace + +void ES9038Q2MKatana::setup() { + uint8_t chip_id = 0; + if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (chip_id != KATANA_CHIP_ID) { + ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id); + this->mark_failed(LOG_STR("Unexpected chip ID")); + return; + } + if (!this->write_byte(KATANA_REG_RESET, 0x01)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + delay(RESET_SETTLE_DELAY_MS); + + // MUTE_STREAM is the gate the Linux driver closes between streams; ESPHome has no such + // notion, so it stays open and mute is only ever driven through MUTE. + if (!this->write_byte(KATANA_REG_FORMAT, this->format_) || !this->write_byte(KATANA_REG_COMMAND, 0x00) || + !this->write_byte(KATANA_REG_DSP_PROGRAM, this->filter_shape_) || + !this->write_byte(KATANA_REG_DEEMPHASIS, this->deemphasis_mode_) || + !this->write_byte(KATANA_REG_DOP, this->dop_enabled_ ? 1 : 0) || !this->write_volume_() || + !this->write_byte(KATANA_REG_MUTE, this->is_muted_ ? 1 : 0) || !this->write_byte(KATANA_REG_MUTE_STREAM, 0x00)) { + this->mark_failed(LOG_STR("Failed to configure the control logic")); + } +} + +void ES9038Q2MKatana::dump_config() { + ESP_LOGCONFIG(TAG, + "ES9038Q2M Katana:\n" + " Filter shape: %s\n" + " De-emphasis: %s\n" + " Format register: 0x%02X\n" + " DoP: %s", + LOG_STR_ARG(filter_shape_to_str(this->filter_shape_)), + LOG_STR_ARG(deemphasis_to_str(this->deemphasis_mode_)), this->format_, ONOFF(this->dop_enabled_)); + LOG_I2C_DEVICE(this); + if (this->is_failed()) + return; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + for (uint8_t reg = KATANA_REG_CHIP_ID; reg <= KATANA_REG_MUTE_STREAM; reg++) { + uint8_t value = 0; + if (this->read_byte(reg, &value)) { + ESP_LOGV(TAG, " Register 0x%02X: 0x%02X", reg, value); + } else { + ESP_LOGV(TAG, " Register 0x%02X: read failed", reg); + } + } +#endif +} + +// Both channel registers follow ESPHome's single master volume. +bool ES9038Q2MKatana::write_volume_() { + return this->write_byte(KATANA_REG_VOLUME_1, this->volume_reg_) && + this->write_byte(KATANA_REG_VOLUME_2, this->volume_reg_); +} + +// Records the outcome of a runtime write; the cached state only changes on success. +bool ES9038Q2MKatana::check_write_(bool ok) { + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } + return ok; +} + +bool ES9038Q2MKatana::set_volume(float volume) { + if (this->is_failed()) + return false; + volume = clamp(volume, 0.0f, 1.0f); + const uint8_t previous = this->volume_reg_; + float attenuation = KATANA_VOLUME_FULL_ATTENUATION; + if (volume >= KATANA_VOLUME_MIDPOINT) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS * (1.0f - volume) / (1.0f - KATANA_VOLUME_MIDPOINT); + } else if (volume > 0.0f) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS - KATANA_VOLUME_CUBIC_SCALE * log10f(volume / KATANA_VOLUME_MIDPOINT); + } + this->volume_reg_ = + static_cast(lroundf(clamp(attenuation, 0.0f, static_cast(KATANA_VOLUME_FULL_ATTENUATION)))); + if (this->check_write_(this->write_volume_())) + return true; + this->volume_reg_ = previous; + return false; +} + +float ES9038Q2MKatana::volume() { + if (this->volume_reg_ == KATANA_VOLUME_FULL_ATTENUATION) + return 0.0f; + if (this->volume_reg_ <= KATANA_VOLUME_MIDPOINT_STEPS) + return 1.0f - this->volume_reg_ * (1.0f - KATANA_VOLUME_MIDPOINT) / KATANA_VOLUME_MIDPOINT_STEPS; + return KATANA_VOLUME_MIDPOINT * + powf(10.0f, (KATANA_VOLUME_MIDPOINT_STEPS - this->volume_reg_) / KATANA_VOLUME_CUBIC_SCALE); +} + +bool ES9038Q2MKatana::set_mute_state_(bool mute_state) { + if (this->is_failed()) + return false; + if (!this->check_write_(this->write_byte(KATANA_REG_MUTE, mute_state ? 1 : 0))) + return false; + this->is_muted_ = mute_state; + return true; +} + +} // namespace esphome::es9038q2m_katana diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.h b/esphome/components/es9038q2m_katana/es9038q2m_katana.h new file mode 100644 index 00000000000..1d900f9bab4 --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::es9038q2m_katana { + +// DSP program numbers of the Katana control interface; there is no program 5. +enum FilterShape : uint8_t { + FILTER_SHAPE_LINEAR_PHASE_FAST = 0, + FILTER_SHAPE_LINEAR_PHASE_SLOW = 1, + FILTER_SHAPE_MIN_PHASE_FAST = 2, + FILTER_SHAPE_MIN_PHASE_SLOW = 3, + FILTER_SHAPE_APODIZING = 4, + FILTER_SHAPE_HYBRID = 6, + FILTER_SHAPE_BRICK_WALL = 7, +}; + +enum DeemphasisMode : uint8_t { + DEEMPHASIS_BYPASS = 0, + DEEMPHASIS_32KHZ = 1, + DEEMPHASIS_44_1KHZ = 2, + DEEMPHASIS_48KHZ = 3, +}; + +// Boards that expose an ES9038Q2M through the Allo Katana control logic at I2C address 0x30. +// Ported from the Linux driver sound/soc/bcm/allo-katana-codec.c; the DAC's own registers +// are never written, the control logic applies the configuration itself. +class ES9038Q2MKatana : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + // Cubic below 50%, then 0.5 dB per 1% above, meeting at -25 dB. + // Zero selects full attenuation (-127.5 dB). + bool set_volume(float volume) override; + float volume() override; + bool set_mute_off() override { return this->set_mute_state_(false); } + bool set_mute_on() override { return this->set_mute_state_(true); } + bool is_muted() override { return this->is_muted_; } + + void set_filter_shape(FilterShape shape) { this->filter_shape_ = shape; } + void set_format(uint8_t format) { this->format_ = format; } + void set_deemphasis_mode(DeemphasisMode mode) { this->deemphasis_mode_ = mode; } + void set_dop_enabled(bool enabled) { this->dop_enabled_ = enabled; } + + protected: + bool set_mute_state_(bool mute_state); + bool write_volume_(); + bool check_write_(bool ok); + + FilterShape filter_shape_{FILTER_SHAPE_APODIZING}; + DeemphasisMode deemphasis_mode_{DEEMPHASIS_BYPASS}; + uint8_t format_{0}; + // Attenuation in 0.5 dB steps: 0 is loudest, 255 quietest. Starts 20 dB down. + uint8_t volume_reg_{0x28}; + bool dop_enabled_{false}; +}; + +} // namespace esphome::es9038q2m_katana diff --git a/tests/components/es9038q2m_katana/common.yaml b/tests/components/es9038q2m_katana/common.yaml new file mode 100644 index 00000000000..074eb47eeaa --- /dev/null +++ b/tests/components/es9038q2m_katana/common.yaml @@ -0,0 +1,16 @@ +esphome: + on_boot: + then: + - audio_dac.mute_off: + - audio_dac.mute_on: + - audio_dac.set_volume: + volume: 50% + +audio_dac: + - platform: es9038q2m_katana + i2c_id: i2c_bus + filter_shape: hybrid + bits_per_sample: 24bit + sample_rate: 96000 + deemphasis: 48khz + dop: true diff --git a/tests/components/es9038q2m_katana/test.esp32-idf.yaml b/tests/components/es9038q2m_katana/test.esp32-idf.yaml new file mode 100644 index 00000000000..c5822be8f86 --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + es9038q2m_katana: !include common.yaml diff --git a/tests/components/es9038q2m_katana/test.esp8266-ard.yaml b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml new file mode 100644 index 00000000000..b2ff0d1aceb --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + es9038q2m_katana: !include common.yaml From 4df6291febc11f21b3be58f6761b62af2f4e5af7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 18:56:51 -0400 Subject: [PATCH 396/651] [api][sendspin] Add the Sendspin client id to the capabilities message (#20775) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 3 +++ esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 1 + esphome/components/api/api_pb2_dump.cpp | 1 + esphome/components/sendspin/sendspin_hub.cpp | 8 ++++++++ esphome/components/sendspin/sendspin_hub.h | 6 ++++++ tests/unit_tests/components/api/test_api_proto.py | 2 +- 8 files changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d55cf10c47d..41ece9275fd 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -403,6 +403,9 @@ message WizardCapabilities { message SendspinCapabilities { // Bitmask of the Sendspin features this device supports uint32 feature_flags = 1; + // The Sendspin client_id: base64url of the client's static public key, 43 characters. + // Empty until the Sendspin client has started once since boot. + string client_id = 2 [(max_data_length) = 43]; } message DeviceCapabilitiesResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ca2397b5d78..bcb6486a84d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2131,6 +2131,7 @@ bool APIConnection::send_device_capabilities_response_() { #endif #if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); + resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id(); #endif return this->send_message(resp); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index bcfa86763a1..227af11bc08 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id); return pos; } uint32_t SendspinCapabilities::calc_size_msg(const void *self) { const auto &msg = *static_cast(self); uint32_t size = 0; size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0; return size; } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7c8082a8c7c..7ec8955610c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage { class SendspinCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; + StringRef client_id{nullptr, 0}; // null until set, encode only 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/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ec224c46e29..23ce6319ed1 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1119,6 +1119,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const { const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("client_id"), this->client_id); return out.c_str(); } #endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 8b5eaeeed23..701676503f4 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -243,6 +243,14 @@ std::optional SendspinHub::get_pairing_token() const { return this->client_->pairing_token(); } +// THREAD CONTEXT: Main loop +StringRef SendspinHub::get_client_id() const { + if (this->client_ == nullptr) { + return StringRef(); + } + return StringRef(this->client_->client_id()); +} + // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_unpaired_access_enabled(bool enabled) { this->unpaired_access_ = enabled; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index cd8d0d0afaf..23c60d215b6 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -198,6 +198,12 @@ class SendspinHub final : public Component, /// started once; it stays available after the client stops. Main loop only. std::optional get_pairing_token() const; + /// @brief Returns the Sendspin client_id: base64url of the client's static public key. + /// + /// Empty until the client has started once; it stays set after the client stops. The returned StringRef points into + /// the client's string, which start() may rewrite, so do not keep it past the current call. Main loop only. + StringRef get_client_id() const; + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index ca3c0ce7b58..68e3cab2a21 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -71,7 +71,7 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "sendspin": 6, }, "WizardCapabilities": {"configured": 1}, - "SendspinCapabilities": {"feature_flags": 1}, + "SendspinCapabilities": {"feature_flags": 1, "client_id": 2}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, From 471774cdbfb4962bb171cfd11f4a0af11604ed4f Mon Sep 17 00:00:00 2001 From: gledian Date: Thu, 15 Oct 2026 01:14:04 +0200 Subject: [PATCH 397/651] [ecocomfort2] Fix the clock sync and read boost from the unit state (#20782) Co-authored-by: Claude Opus 5.5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../ecocomfort2/ecocomfort2_fan.cpp | 7 +--- .../ecocomfort2/ecocomfort2_hub.cpp | 36 ++++++++++--------- .../components/ecocomfort2/ecocomfort2_hub.h | 4 +-- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp index c9d64892c48..d41bd311b7d 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -118,13 +118,8 @@ void Ecocomfort2Fan::on_status() { 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) { + 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; diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index 00ce0f20804..223648e3276 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -205,6 +205,10 @@ void Ecocomfort2Hub::publish_unknown_() { sensor->publish_state(NAN); } #endif +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->invalidate_state(); +#endif } // C_OPER: mode byte, speed/flags byte @@ -216,10 +220,6 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { 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(); @@ -227,7 +227,9 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { #endif } -// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction +// C_STATE: bytes 0-1 the mode and speed the unit runs; byte 1 also has the boost flag (0x40) and the advanced +// control step (0x20). Byte 2 the air flow direction, bytes 3-4 temperature and 5-6 humidity in hundredths, +// 7-8 VOC in ppb 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); @@ -236,8 +238,12 @@ void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { 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]); + ESP_LOGD(TAG, "[%s] C_STATE: mode=0x%02x, speed=0x%02x, %.2f °C, %.2f %%, %u ppb, direction=%u", + this->parent_->address_str(), data[0], data[1], temperature, humidity, voc, data[2]); +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->publish_state((data[1] & FLAG_BOOST) != 0); +#endif #ifdef USE_SENSOR if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); @@ -267,7 +273,7 @@ void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) { } #ifdef USE_TIME -// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes +// C_CLOCK: two-digit year, month, day, weekday with Monday as 0, hour, minute, second, automatic DST flag void Ecocomfort2Hub::sync_clock_() { if (!this->is_ready() || this->char_handle_clock_ == 0) return; @@ -276,15 +282,11 @@ void Ecocomfort2Hub::sync_clock_() { 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); + // The time source already applies DST and every sync rewrites the clock, so the unit must not shift it again + uint8_t data[8]{ + static_cast(now.year % 100), now.month, now.day_of_month, weekday, now.hour, now.minute, now.second, 0}; + ESP_LOGD(TAG, "[%s] Syncing clock to %04u-%02u-%02u %02u:%02u:%02u", this->parent_->address_str(), now.year, + now.month, now.day_of_month, 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); diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h index ed046fe0495..2f65914ddec 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.h +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -32,7 +32,8 @@ 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 +// Speed byte: speed in the low nibble, flags in the high nibble. +// C_OPER carries FLAG_AUTO; the unit reports FLAG_BOOST in byte 1 of C_STATE. static constexpr uint8_t SPEED_OFF = 0x00; static constexpr uint8_t SPEED_SLEEP = 0x01; static constexpr uint8_t SPEED_VEL1 = 0x02; @@ -41,7 +42,6 @@ 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 { From eafeef7cb8177a53c418177579ef10e7176be6a5 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 01:18:20 +0200 Subject: [PATCH 398/651] [fusb302b] Add FUSB302B driver (#20760) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/fusb302b/__init__.py | 38 ++ esphome/components/fusb302b/fusb302b.cpp | 415 ++++++++++++++++++ esphome/components/fusb302b/fusb302b.h | 74 ++++ .../components/fusb302b/fusb302b_registers.h | 94 ++++ esphome/components/fusb302b/pd.h | 1 + tests/components/fusb302b/common.yaml | 4 + tests/components/fusb302b/test.esp32-idf.yaml | 6 + 7 files changed, 632 insertions(+) create mode 100644 esphome/components/fusb302b/fusb302b.cpp create mode 100644 esphome/components/fusb302b/fusb302b.h create mode 100644 esphome/components/fusb302b/fusb302b_registers.h create mode 100644 tests/components/fusb302b/common.yaml create mode 100644 tests/components/fusb302b/test.esp32-idf.yaml diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index 3cf9043f418..212ef057e94 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -1,2 +1,40 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN +from esphome.types import ConfigType + CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] DOMAIN = "fusb302b" + +CONF_REQUEST_VOLTAGE = "request_voltage" + +fusb302b_ns = cg.esphome_ns.namespace("fusb302b") +FUSB302B = fusb302b_ns.class_("FUSB302B", cg.Component, i2c.I2CDevice) + +# USB PD fixed supply voltages are 5, 9, 12, 15 and 20 V; the highest one that does not exceed the +# requested voltage is chosen. +validate_voltage = cv.int_range(min=5, max=20) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(FUSB302B), + cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema, + cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x22)), + cv.only_on_esp32, +) + + +async def to_code(config: ConfigType) -> None: + interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) + var = cg.new_Pvariable(config[CONF_ID], interrupt_pin) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp new file mode 100644 index 00000000000..6cffbf114f7 --- /dev/null +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -0,0 +1,415 @@ +#include "fusb302b.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include "fusb302b_registers.h" + +namespace esphome::fusb302b { + +ESPHOME_LOG_TAG(TAG, "fusb302b"); + +// Task notification bits +static constexpr uint32_t NOTIFY_IRQ = 1 << 0; +static constexpr uint32_t NOTIFY_STOP = 1 << 1; + +static constexpr uint32_t TASK_STACK_SIZE = 4096; +static constexpr UBaseType_t TASK_PRIORITY = 18; + +static constexpr uint32_t STARTUP_DELAY_MS = 2000; +static constexpr uint32_t CC_SETTLE_MS = 5; +static constexpr uint32_t CC_POLL_INTERVAL_MS = 500; +static constexpr uint32_t PD_POLL_INTERVAL_MS = 100; +// Safety net in case an interrupt edge is missed while attached and idle +static constexpr uint32_t IDLE_POLL_INTERVAL_MS = 1000; +// Time to wait for the source to send its capabilities on its own after attach +static constexpr uint32_t SOURCE_CAP_WAIT_MS = 5000; +// Time to wait after each GET_SOURCE_CAP / soft reset before the next recovery step +static constexpr uint32_t SOURCE_CAP_RETRY_MS = 2500; +// Upper bound on back-to-back interrupt handling while INT_N stays asserted +static constexpr uint8_t MAX_IRQ_ROUNDS = 8; +// BC_LVL is read this many extra times to confirm a stable measurement +static constexpr uint8_t CC_STABLE_READS = 5; +static constexpr uint8_t CC_LEVEL_UNSTABLE = 0xFF; + +void IRAM_ATTR FUSB302B::gpio_intr(FUSB302B *arg) { + TaskHandle_t handle = arg->task_.get_handle(); + if (handle == nullptr) + return; + BaseType_t woken = pdFALSE; + xTaskNotifyFromISR(handle, NOTIFY_IRQ, eSetBits, &woken); + portYIELD_FROM_ISR(woken); +} + +void FUSB302B::task_func(void *arg) { static_cast(arg)->run_task_(); } + +void FUSB302B::setup() { + uint8_t device_id = 0; + if (!this->read_byte(FUSB_DEVICE_ID, &device_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + const uint8_t version = device_id & FUSB_DEVICE_ID_VERSION_MASK; + if (version != FUSB_DEVICE_ID_VERSION_A && version != FUSB_DEVICE_ID_VERSION_B) { + ESP_LOGE(TAG, "Unknown device ID 0x%02X", device_id); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Device ID 0x%02X (product %u, revision %u)", device_id, (device_id >> 2) & 0x3, device_id & 0x3); + if (!this->init_chip_()) { + this->mark_failed(LOG_STR("Initialization failed")); + return; + } + + this->interrupt_pin_->setup(); + // The task must exist before the interrupt is attached, since the ISR notifies it + if (!this->task_.create(FUSB302B::task_func, "fusb302b", TASK_STACK_SIZE, this, TASK_PRIORITY, false)) { + this->mark_failed(LOG_STR("Failed to create task")); + return; + } + this->interrupt_pin_->attach_interrupt(&FUSB302B::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); +} + +void FUSB302B::dump_config() { + ESP_LOGCONFIG(TAG, + "FUSB302B:\n" + " Request Voltage: %u V", + this->get_request_voltage()); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +} + +void FUSB302B::on_shutdown() { + this->interrupt_pin_->detach_interrupt(); + // The task parks itself instead of being deleted, so it can never be stopped in the middle of an I2C transfer + if (this->task_.is_created()) + xTaskNotify(this->task_.get_handle(), NOTIFY_STOP, eSetBits); +} + +void FUSB302B::run_task_() { + // Give the source time to settle after power-up before the first CC measurement + vTaskDelay(pdMS_TO_TICKS(STARTUP_DELAY_MS)); + + while (true) { + uint32_t wait_ms = CC_POLL_INTERVAL_MS; + if (this->attached_) { + const bool busy = this->waiting_for_source_caps_ || this->retry_request_pending_ || this->check_ams_(); + wait_ms = busy ? PD_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; + } + uint32_t bits = 0; + xTaskNotifyWait(0, UINT32_MAX, &bits, pdMS_TO_TICKS(wait_ms)); + + if (bits & NOTIFY_STOP) { + while (true) + vTaskSuspend(nullptr); + } + + // INT_N is held low until the interrupt registers are read, so a new event that arrives while it is + // already low has no falling edge: keep servicing while the pin stays asserted. + if (bits & NOTIFY_IRQ) + this->handle_interrupt_(); + for (uint8_t i = 0; i < MAX_IRQ_ROUNDS && !this->interrupt_pin_->digital_read(); i++) + this->handle_interrupt_(); + + if (!this->attached_) { + this->try_attach_(); + continue; + } + this->check_transition_timeout_(); + this->check_request_retry_(); + this->check_source_caps_(); + } +} + +bool FUSB302B::init_chip_() { + // Software reset restores all registers to their defaults + if (!this->write_byte(FUSB_RESET, FUSB_RESET_SW_RES)) + return false; + if (!this->reset_pd_()) + return false; + + // Unmask the interrupts used for PD messaging and VBUS changes + if (!this->write_byte(FUSB_MASK1, FUSB_MASK1_DEFAULT) || !this->write_byte(FUSB_MASKA, 0) || + !this->write_byte(FUSB_MASKB, 0)) + return false; + + // Let the chip retransmit unacknowledged messages up to 3 times + uint8_t control3 = 0; + if (!this->read_byte(FUSB_CONTROL3, &control3)) + return false; + control3 = + (control3 & ~FUSB_CONTROL3_N_RETRIES_MASK) | (3 << FUSB_CONTROL3_N_RETRIES_SHIFT) | FUSB_CONTROL3_AUTO_RETRY; + if (!this->write_byte(FUSB_CONTROL3, control3)) + return false; + + if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL)) + return false; + return this->reset_pd_(); +} + +bool FUSB302B::reset_pd_() { + this->reset_protocol_(); + // Writing CONTROL0 as a whole byte also clears INT_MASK, which is what enables the INT_N pin + return this->write_byte(FUSB_CONTROL0, FUSB_CONTROL0_TX_FLUSH) && + this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH) && this->write_byte(FUSB_RESET, FUSB_RESET_PD_RESET); +} + +bool FUSB302B::enable_auto_crc_() { + uint8_t switches1 = 0; + return this->read_byte(FUSB_SWITCHES1, &switches1) && + this->write_byte(FUSB_SWITCHES1, switches1 | FUSB_SWITCHES1_AUTO_CRC); +} + +void FUSB302B::handle_interrupt_() { + // STATUS0A, STATUS1A, INTERRUPTA, INTERRUPTB, STATUS0, STATUS1, INTERRUPT; reading clears the interrupts + uint8_t regs[7]; + if (this->read_register(FUSB_STATUS0A, regs, sizeof(regs)) != i2c::ERROR_OK) { + this->enter_error_(); + return; + } + const uint8_t interrupta = regs[2]; + const uint8_t status0 = regs[4]; + uint8_t status1 = regs[5]; + const uint8_t interrupt = regs[6]; + + if ((interrupt & FUSB_INTERRUPT_I_VBUSOK) && !(status0 & FUSB_STATUS0_VBUSOK)) { + if (this->attached_) + this->detach_(); + return; + } + if (interrupta & FUSB_INTERRUPTA_I_HARDRST) { + if (this->attached_) + this->handle_hard_reset_(); + return; + } + if (interrupta & FUSB_INTERRUPTA_I_SOFTRST) { + ESP_LOGV(TAG, "Soft reset received"); + } + if ((interrupta & FUSB_INTERRUPTA_I_RETRYFAIL) && this->attached_) + this->handle_transmit_failed_(); + + if (!this->attached_) + return; + PdMsg msg; + while (!(status1 & FUSB_STATUS1_RX_EMPTY)) { + bool is_sop = false; + if (!this->read_message_(msg, is_sop)) { + ESP_LOGW(TAG, "Reading message failed"); + // A partial read leaves the FIFO misaligned + this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH); + return; + } + // Only SOP packets are addressed to a sink; SOP'/SOP'' are for cable plugs + if (is_sop) + this->handle_message_(msg); + if (!this->read_byte(FUSB_STATUS1, &status1)) + return; + } +} + +bool FUSB302B::read_message_(PdMsg &msg, bool &is_sop) { + uint8_t token = 0; + uint8_t header[2]; + if (!this->read_byte(FUSB_FIFOS, &token) || this->read_register(FUSB_FIFOS, header, 2) != i2c::ERROR_OK) + return false; + msg.set_header(encode_uint16(header[1], header[0])); + + // Data objects, followed by the CRC32 that the chip already checked + uint8_t buf[PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; + if (this->read_register(FUSB_FIFOS, buf, msg.num_of_obj * 4 + 4) != i2c::ERROR_OK) + return false; + for (uint8_t i = 0; i < msg.num_of_obj; i++) { + const uint8_t *obj = &buf[i * 4]; + msg.data_objects[i] = encode_uint32(obj[3], obj[2], obj[1], obj[0]); + } + is_sop = (token & FUSB_FIFO_RX_TOKEN_BITS) == FUSB_FIFO_RX_SOP; + return true; +} + +bool FUSB302B::send_message(const PdMsg &msg) { + // Register address, then the TX tokens; written in one transfer without the copy write_register() makes + uint8_t buf[1 + 4 + 1 + 2 + PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; + size_t len = 0; + const uint16_t header = msg.get_coded_header(); + + buf[len++] = FUSB_FIFOS; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC2; + buf[len++] = FUSB_TX_TOKEN_PACKSYM | (msg.num_of_obj * 4 + 2); + buf[len++] = header & 0xFF; + buf[len++] = header >> 8; + for (uint8_t i = 0; i < msg.num_of_obj; i++) { + const uint32_t obj = msg.data_objects[i]; + buf[len++] = obj & 0xFF; + buf[len++] = (obj >> 8) & 0xFF; + buf[len++] = (obj >> 16) & 0xFF; + buf[len++] = obj >> 24; + } + buf[len++] = FUSB_TX_TOKEN_JAM_CRC; + buf[len++] = FUSB_TX_TOKEN_EOP; + buf[len++] = FUSB_TX_TOKEN_TXOFF; + buf[len++] = FUSB_TX_TOKEN_TXON; + + i2c::ErrorCode err = this->write(buf, len); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Sending message type %u failed (error %d)", msg.type, static_cast(err)); + return false; + } + return true; +} + +bool FUSB302B::measure_cc_(uint8_t meas_switch, uint8_t &level) { + if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch)) + return false; + vTaskDelay(pdMS_TO_TICKS(CC_SETTLE_MS)); + uint8_t status0 = 0; + if (!this->read_byte(FUSB_STATUS0, &status0)) + return false; + level = status0 & FUSB_STATUS0_BC_LVL; + for (uint8_t i = 0; i < CC_STABLE_READS; i++) { + if (!this->read_byte(FUSB_STATUS0, &status0)) + return false; + if ((status0 & FUSB_STATUS0_BC_LVL) != level) { + level = CC_LEVEL_UNSTABLE; + return true; + } + } + return true; +} + +void FUSB302B::try_attach_() { + if (this->state_ == PdState::PD_STATE_ERROR) { + if (!this->init_chip_()) + return; + ESP_LOGI(TAG, "Communication restored"); + this->set_state_(PdState::PD_STATE_DISCONNECTED); + } + + uint8_t status0 = 0; + if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL) || !this->read_byte(FUSB_STATUS0, &status0)) { + this->enter_error_(); + return; + } + // A source may present its CC pull-up before VBUS is on; detach is only detected when VBUS drops + if (!(status0 & FUSB_STATUS0_VBUSOK)) { + this->cc_ambiguous_logged_ = false; + return; + } + + uint8_t cc1 = 0; + uint8_t cc2 = 0; + if (!this->write_byte(FUSB_SWITCHES1, FUSB_SWITCHES1_SPECREV_REV2) || + !this->write_byte(FUSB_MEASURE, FUSB_MEASURE_MDAC_CC) || !this->measure_cc_(FUSB_SWITCHES0_MEAS_CC1, cc1) || + !this->measure_cc_(FUSB_SWITCHES0_MEAS_CC2, cc2)) { + this->enter_error_(); + return; + } + // Exactly one CC line carries the source pull-up; the other one is open + uint8_t meas_switch; + uint8_t tx_switch; + if (cc1 != CC_LEVEL_UNSTABLE && cc1 > 0 && cc2 == 0) { + meas_switch = FUSB_SWITCHES0_MEAS_CC1; + tx_switch = FUSB_SWITCHES1_TXCC1; + } else if (cc2 != CC_LEVEL_UNSTABLE && cc2 > 0 && cc1 == 0) { + meas_switch = FUSB_SWITCHES0_MEAS_CC2; + tx_switch = FUSB_SWITCHES1_TXCC2; + } else { + if (!this->cc_ambiguous_logged_) { + ESP_LOGD(TAG, "VBUS present but no single CC line with a source pull-up (CC1 %u, CC2 %u)", cc1, cc2); + this->cc_ambiguous_logged_ = true; + } + return; + } + + if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch) || + !this->write_byte(FUSB_SWITCHES1, tx_switch | FUSB_SWITCHES1_SPECREV_REV2) || !this->enable_auto_crc_() || + !this->reset_pd_()) { + this->enter_error_(); + return; + } + ESP_LOGD(TAG, "Attached on CC%u", meas_switch == FUSB_SWITCHES0_MEAS_CC1 ? 1 : 2); + this->attached_ = true; + this->cc_ambiguous_logged_ = false; + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); +} + +void FUSB302B::detach_() { + ESP_LOGD(TAG, "Detached (VBUS lost)"); + this->attached_ = false; + this->waiting_for_source_caps_ = false; + this->reset_pd_(); + this->clear_contract_(); + this->set_state_(PdState::PD_STATE_DISCONNECTED); +} + +void FUSB302B::handle_hard_reset_() { + ESP_LOGD(TAG, "Hard reset received"); + // The source returns to Type-C default power and sends its capabilities again + // Also flushes the RX FIFO, so the caller has nothing left to read + if (!this->reset_pd_()) { + this->enter_error_(); + return; + } + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); +} + +void FUSB302B::enter_error_() { + if (this->state_ == PdState::PD_STATE_ERROR) + return; + ESP_LOGW(TAG, "Communication with the chip failed"); + this->attached_ = false; + this->waiting_for_source_caps_ = false; + this->clear_contract_(); + this->set_state_(PdState::PD_STATE_ERROR); +} + +void FUSB302B::send_soft_reset_() { + this->reset_pd_(); + this->send_message(this->make_control_msg_(PD_CNTRL_SOFT_RESET)); + // The source answers a soft reset with new source capabilities + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT); +} + +void FUSB302B::start_source_cap_wait_(SourceCapStep step) { + this->waiting_for_source_caps_ = true; + this->source_cap_step_ = step; + this->source_cap_step_start_ = millis(); +} + +void FUSB302B::check_source_caps_() { + if (!this->waiting_for_source_caps_ || this->check_ams_()) + return; + const uint32_t elapsed = millis() - this->source_cap_step_start_; + switch (this->source_cap_step_) { + case SourceCapStep::SOURCE_CAP_STEP_WAIT: + if (elapsed < SOURCE_CAP_WAIT_MS) + return; + ESP_LOGD(TAG, "No source capabilities received, requesting them"); + this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP)); + this->source_cap_step_ = SourceCapStep::SOURCE_CAP_STEP_GET_SENT; + break; + case SourceCapStep::SOURCE_CAP_STEP_GET_SENT: + if (elapsed < SOURCE_CAP_RETRY_MS) + return; + ESP_LOGD(TAG, "No source capabilities received, sending soft reset"); + this->send_soft_reset_(); + return; + case SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT: + if (elapsed < SOURCE_CAP_RETRY_MS) + return; + ESP_LOGW(TAG, "PD negotiation failed, staying at 5 V"); + this->waiting_for_source_caps_ = false; + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + return; + } + this->source_cap_step_start_ = millis(); +} + +} // namespace esphome::fusb302b + +#endif // USE_ESP32 diff --git a/esphome/components/fusb302b/fusb302b.h b/esphome/components/fusb302b/fusb302b.h new file mode 100644 index 00000000000..341d64558df --- /dev/null +++ b/esphome/components/fusb302b/fusb302b.h @@ -0,0 +1,74 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/static_task.h" + +#include "pd.h" + +namespace esphome::fusb302b { + +/// FUSB302B USB Type-C / Power Delivery sink controller. +/// +/// A dedicated task owns all I2C traffic to the chip so that PD replies meet the protocol timing +/// even while the main loop is busy. +class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { + public: + explicit FUSB302B(InternalGPIOPin *interrupt_pin) : interrupt_pin_(interrupt_pin) {} + + void setup() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + enum class SourceCapStep : uint8_t { + SOURCE_CAP_STEP_WAIT, + SOURCE_CAP_STEP_GET_SENT, + SOURCE_CAP_STEP_SOFT_RESET_SENT, + }; + + static void gpio_intr(FUSB302B *arg); + static void task_func(void *arg); + + bool send_message(const PdMsg &msg) override; + void on_state_changed() override {} + void on_soft_reset_received() override { this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); } + void on_source_caps_received() override { this->waiting_for_source_caps_ = false; } + void on_transition_timeout() override { this->send_soft_reset_(); } + + // PHY task only + [[noreturn]] void run_task_(); + bool init_chip_(); + bool reset_pd_(); + bool enable_auto_crc_(); + void handle_interrupt_(); + bool read_message_(PdMsg &msg, bool &is_sop); + void try_attach_(); + bool measure_cc_(uint8_t meas_switch, uint8_t &level); + void detach_(); + void handle_hard_reset_(); + void start_source_cap_wait_(SourceCapStep step); + void send_soft_reset_(); + void check_source_caps_(); + void enter_error_(); + + InternalGPIOPin *interrupt_pin_; + StaticTask task_; + + // PHY task only + uint32_t source_cap_step_start_{0}; + SourceCapStep source_cap_step_{SourceCapStep::SOURCE_CAP_STEP_WAIT}; + bool attached_{false}; + bool waiting_for_source_caps_{false}; + bool cc_ambiguous_logged_{false}; +}; + +} // namespace esphome::fusb302b + +#endif // USE_ESP32 diff --git a/esphome/components/fusb302b/fusb302b_registers.h b/esphome/components/fusb302b/fusb302b_registers.h new file mode 100644 index 00000000000..3efaf57215f --- /dev/null +++ b/esphome/components/fusb302b/fusb302b_registers.h @@ -0,0 +1,94 @@ +#pragma once + +#include + +namespace esphome::fusb302b { + +// Register addresses +static constexpr uint8_t FUSB_DEVICE_ID = 0x01; +static constexpr uint8_t FUSB_SWITCHES0 = 0x02; +static constexpr uint8_t FUSB_SWITCHES1 = 0x03; +static constexpr uint8_t FUSB_MEASURE = 0x04; +static constexpr uint8_t FUSB_CONTROL0 = 0x06; +static constexpr uint8_t FUSB_CONTROL1 = 0x07; +static constexpr uint8_t FUSB_CONTROL3 = 0x09; +static constexpr uint8_t FUSB_MASK1 = 0x0A; +static constexpr uint8_t FUSB_POWER = 0x0B; +static constexpr uint8_t FUSB_RESET = 0x0C; +static constexpr uint8_t FUSB_MASKA = 0x0E; +static constexpr uint8_t FUSB_MASKB = 0x0F; +static constexpr uint8_t FUSB_STATUS0A = 0x3C; +static constexpr uint8_t FUSB_STATUS0 = 0x40; +static constexpr uint8_t FUSB_STATUS1 = 0x41; +static constexpr uint8_t FUSB_FIFOS = 0x43; + +// DEVICE_ID: version in bits 7:4, product variant (I2C address 0x22-0x25) in bits 3:2, silicon revision in bits 1:0 +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_MASK = 0xF0; +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_A = 0x80; +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_B = 0x90; + +// SWITCHES0 bits +static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC2 = 1 << 3; +static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC1 = 1 << 2; +static constexpr uint8_t FUSB_SWITCHES0_PDWN_2 = 1 << 1; +static constexpr uint8_t FUSB_SWITCHES0_PDWN_1 = 1 << 0; + +// SWITCHES1 bits +static constexpr uint8_t FUSB_SWITCHES1_SPECREV_REV2 = 0x01 << 5; +static constexpr uint8_t FUSB_SWITCHES1_AUTO_CRC = 1 << 2; +static constexpr uint8_t FUSB_SWITCHES1_TXCC2 = 1 << 1; +static constexpr uint8_t FUSB_SWITCHES1_TXCC1 = 1 << 0; + +// MEASURE value used for CC detection +static constexpr uint8_t FUSB_MEASURE_MDAC_CC = 49; + +// CONTROL0 bits +static constexpr uint8_t FUSB_CONTROL0_TX_FLUSH = 1 << 6; + +// CONTROL1 bits +static constexpr uint8_t FUSB_CONTROL1_RX_FLUSH = 1 << 2; + +// CONTROL3 bits +static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_SHIFT = 1; +static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_MASK = 0x3 << FUSB_CONTROL3_N_RETRIES_SHIFT; +static constexpr uint8_t FUSB_CONTROL3_AUTO_RETRY = 1 << 0; + +// MASK1: mask the ACTIVITY, CRC_CHK and BC_LVL interrupts +static constexpr uint8_t FUSB_MASK1_DEFAULT = 0x51; + +// POWER bits +static constexpr uint8_t FUSB_POWER_ALL = 0x0F; + +// RESET bits +static constexpr uint8_t FUSB_RESET_PD_RESET = 1 << 1; +static constexpr uint8_t FUSB_RESET_SW_RES = 1 << 0; + +// INTERRUPTA bits +static constexpr uint8_t FUSB_INTERRUPTA_I_RETRYFAIL = 1 << 4; +static constexpr uint8_t FUSB_INTERRUPTA_I_SOFTRST = 1 << 1; +static constexpr uint8_t FUSB_INTERRUPTA_I_HARDRST = 1 << 0; + +// INTERRUPT bits +static constexpr uint8_t FUSB_INTERRUPT_I_VBUSOK = 1 << 7; + +// STATUS0 bits +static constexpr uint8_t FUSB_STATUS0_VBUSOK = 1 << 7; +static constexpr uint8_t FUSB_STATUS0_BC_LVL = 0x03; + +// STATUS1 bits +static constexpr uint8_t FUSB_STATUS1_RX_EMPTY = 1 << 5; + +// RX FIFO token +static constexpr uint8_t FUSB_FIFO_RX_TOKEN_BITS = 0xE0; +static constexpr uint8_t FUSB_FIFO_RX_SOP = 0xE0; + +// TX FIFO tokens +static constexpr uint8_t FUSB_TX_TOKEN_TXON = 0xA1; +static constexpr uint8_t FUSB_TX_TOKEN_SYNC1 = 0x12; +static constexpr uint8_t FUSB_TX_TOKEN_SYNC2 = 0x13; +static constexpr uint8_t FUSB_TX_TOKEN_PACKSYM = 0x80; +static constexpr uint8_t FUSB_TX_TOKEN_JAM_CRC = 0xFF; +static constexpr uint8_t FUSB_TX_TOKEN_EOP = 0x14; +static constexpr uint8_t FUSB_TX_TOKEN_TXOFF = 0xFE; + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.h b/esphome/components/fusb302b/pd.h index 26c75239152..66918ce98da 100644 --- a/esphome/components/fusb302b/pd.h +++ b/esphome/components/fusb302b/pd.h @@ -30,6 +30,7 @@ enum PdControlMsgType : uint8_t { PD_CNTRL_REJECT = 0x04, PD_CNTRL_PING = 0x05, PD_CNTRL_PS_RDY = 0x06, + PD_CNTRL_GET_SOURCE_CAP = 0x07, PD_CNTRL_GET_SINK_CAP = 0x08, PD_CNTRL_WAIT = 0x0C, PD_CNTRL_SOFT_RESET = 0x0D, diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml new file mode 100644 index 00000000000..e684462ba7c --- /dev/null +++ b/tests/components/fusb302b/common.yaml @@ -0,0 +1,4 @@ +fusb302b: + id: pd + interrupt_pin: ${interrupt_pin} + request_voltage: 20 diff --git a/tests/components/fusb302b/test.esp32-idf.yaml b/tests/components/fusb302b/test.esp32-idf.yaml new file mode 100644 index 00000000000..b9ca02073b4 --- /dev/null +++ b/tests/components/fusb302b/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + fusb302b: !include common.yaml From d0b9f195b0c92f8e6611b6d23d4e539221984ba1 Mon Sep 17 00:00:00 2001 From: Gilles Devillers Date: Thu, 15 Oct 2026 01:23:37 +0200 Subject: [PATCH 399/651] [wifi] Apply reboot_timeout with a fallback AP unless a station is connected to it (#9958) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 3 + esphome/components/wifi/wifi_component.cpp | 60 ++++++++++++------- esphome/components/wifi/wifi_component.h | 20 ++++++- .../wifi/wifi_component_esp8266.cpp | 7 +++ .../wifi/wifi_component_esp_idf.cpp | 20 +++++++ .../wifi/wifi_component_libretiny.cpp | 5 +- .../components/wifi/wifi_component_pico_w.cpp | 42 ++++++++++++- tests/components/wifi/test.esp8266-ard.yaml | 3 + tests/components/wifi/test.rp2040-ard.yaml | 8 ++- tests/components/wifi/test.rtl87xx-ard.yaml | 8 ++- 10 files changed, 147 insertions(+), 29 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 583a9ed9992..bc730fe66fa 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -798,6 +798,9 @@ async def to_code(config): # takes turns with the networks instead. if CORE.is_ln882x: cg.add_define("USE_WIFI_AP_EXCLUSIVE") + # The Pico W driver drops the AP station events; wifi_component_pico_w.cpp counts them + if CORE.is_rp2: + cg.add_build_flag("-Wl,--wrap=cyw43_cb_process_async_event") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which # drops SoftAP support / the LWIP DHCP server when AP mode is unused. diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 70b208769b7..221efef07d9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -672,7 +672,7 @@ void WiFiComponent::setup() { return; } #endif - this->wifi_mode_({}, false); + this->disable_ap_(); } }); } @@ -953,8 +953,9 @@ void WiFiComponent::loop() { this->ap_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_) { + // A portal is only started behind an AP that came up: the portal keeps a reference to the web server + // on every start, and a failed start is tried again later + if (this->ap_setup_) { #ifdef USE_CAPTIVE_PORTAL // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) @@ -977,22 +978,28 @@ void WiFiComponent::loop() { #endif - if (!this->has_ap() && this->reboot_timeout_ != 0) { - if (now - this->last_connected_ > this->reboot_timeout_) { - bool suppress = false; -#ifdef USE_PROVISIONING - // Don't reboot while a provisioning window is pending (device unprovisioned). - // The device is legitimately waiting to be onboarded (Wi-Fi must come up - // before the controller can set credentials), and an auto-reboot would reopen - // the window without the deliberate power cycle / reset that is meant to be - // required. Resumes normal reboot behavior once provisioned. - suppress = provisioning::global_provisioning_manager != nullptr && - provisioning::global_provisioning_manager->window_pending(); + if (this->reboot_timeout_ != 0 && now - this->last_connected_ > this->reboot_timeout_) { + bool suppress = false; +#if defined(USE_WIFI_AP) && (defined(USE_RTL87XX) || defined(USE_LN882X)) + // The Realtek driver reports no stations and the Lightning one is unverified, so a running fallback AP + // holds the reboot there as it always did + suppress = this->ap_setup_; +#elif defined(USE_WIFI_AP) + // A station on the fallback AP is using the device + suppress = this->ap_setup_ && this->ap_clients_ != 0; #endif - if (!suppress) { - ESP_LOGE(TAG, "Can't connect; rebooting"); - App.reboot(); - } +#ifdef USE_PROVISIONING + // Don't reboot while a provisioning window is pending (device unprovisioned). + // The device is legitimately waiting to be onboarded (Wi-Fi must come up + // before the controller can set credentials), and an auto-reboot would reopen + // the window without the deliberate power cycle / reset that is meant to be + // required. Resumes normal reboot behavior once provisioned. + suppress |= provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); +#endif + if (!suppress) { + ESP_LOGE(TAG, "Can't connect; rebooting"); + App.reboot(); } } } @@ -1070,6 +1077,8 @@ void WiFiComponent::setup_ap_config_() { this->ap_.set_ssid(ssid_buf); } } + // Reset before the AP exists: afterwards only the platform's event path writes the counter + this->ap_clients_ = 0; this->ap_setup_ = this->wifi_start_ap_(this->ap_); #if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) // A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse @@ -1113,9 +1122,6 @@ void WiFiComponent::pause_exclusive_ap_() { ESP_LOGI(TAG, "Pausing AP to try the networks"); // Scanning and connecting turn STA back on. this->disable_ap_(); - this->ap_setup_ = false; - // Clients drop with the AP without a disconnect event for each. - this->ap_clients_ = 0; this->ap_changed_ = App.get_loop_component_start_time(); // A fresh connection cycle, as at boot, rather than resuming the one the // AP cut short. @@ -1128,6 +1134,7 @@ void WiFiComponent::pause_exclusive_ap_() { #ifdef USE_WIFI_AP_WISP void WiFiComponent::fail_bridge_() { this->ap_setup_ = false; + this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each this->wifi_mode_({}, false); } #endif @@ -1136,6 +1143,10 @@ void WiFiComponent::disable_ap_() { this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); + // The fallback block may bring the AP back after a later disconnect; its clients dropped with it without + // a disconnect event for each + this->ap_setup_ = false; + this->ap_clients_ = 0; } void WiFiComponent::set_ap(const WiFiAP &ap) { @@ -1468,7 +1479,10 @@ void WiFiComponent::disable() { this->wifi_disconnect_(); this->wifi_mode_(false, false); #ifdef USE_WIFI_AP - this->ap_setup_ = false; // the AP went down with the radio; enable() may bring it up again + // The AP went down with the radio; enable() has to configure it, and its portal, again + this->end_ap_portal_(); + this->ap_setup_ = false; + this->ap_clients_ = 0; #endif #ifdef USE_WIFI_AP_WISP this->ap_changed_ = 0; // at once, not after ap_timeout @@ -1774,6 +1788,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { } #endif + // The fallback and reboot checks later in this pass must see the connection, not the old timestamp + this->last_connected_ = now; this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; // Refresh is_connected() cache; loop()'s refresh ran before this transition. this->update_connected_state_(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 964de6996a3..4c346cb40a7 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -50,6 +50,13 @@ extern "C" { } #include + +#ifdef USE_WIFI_AP +// The linker routes the driver's firmware events here; wifi/__init__.py adds the wrap flag +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif #endif #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) @@ -919,6 +926,11 @@ class WiFiComponent final : public Component { #ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result); +#ifdef USE_WIFI_AP + // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + friend void ::__wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); + // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif #endif #ifdef USE_LIBRETINY @@ -1079,8 +1091,12 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; -#ifdef USE_WIFI_AP_EXCLUSIVE - uint8_t ap_clients_{0}; // Devices joined to the AP, which keep it from pausing +#ifdef USE_WIFI_AP + // Stations joined to the AP; they hold the reboot timeout and keep an exclusive AP from pausing. + // Counted on the main loop on ESP32 and LibreTiny (queued events), in the SDK callback on ESP8266 and + // in the driver's interrupt context on the Pico W. The main loop only reads the byte while the AP is up + // and only writes it (a reset) while the AP is down, so no lock is needed. + uint8_t ap_clients_{0}; #endif bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 41b51460006..61c66079b63 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -572,6 +572,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { break; } case EVENT_SOFTAPMODE_STACONNECTED: { +#ifdef USE_WIFI_AP + global_wifi_component->ap_clients_++; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto it = event->event_info.sta_connected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -581,6 +584,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { break; } case EVENT_SOFTAPMODE_STADISCONNECTED: { +#ifdef USE_WIFI_AP + if (global_wifi_component->ap_clients_ > 0) + global_wifi_component->ap_clients_--; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto it = event->event_info.sta_disconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 10dd67dba92..c31fe336484 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -928,6 +928,15 @@ const char *get_disconnect_reason_str(uint8_t reason) { } } +#ifdef USE_WIFI_AP +// The driver's own station list cannot drift, so the count is read from it rather than kept by arithmetic +static void read_ap_clients(uint8_t &count) { + wifi_sta_list_t sta_list; + if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK) + count = sta_list.num; +} +#endif + bool WiFiComponent::wifi_loop_() { // Use pop() directly instead of empty() — pop() costs 1 memw (acquire on tail_), // while empty() costs 2 memw (acquire on both head_ and tail_) on Xtensa. @@ -945,6 +954,11 @@ bool WiFiComponent::wifi_loop_() { uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u WiFi events due to buffer overflow", dropped); +#ifdef USE_WIFI_AP + // A dropped join or leave is made up for by reading the list again + if (this->ap_setup_) + read_ap_clients(this->ap_clients_); +#endif } return true; } @@ -1197,6 +1211,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STACONNECTED) { +#ifdef USE_WIFI_AP + read_ap_clients(this->ap_clients_); +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE const auto &it = data->data.ap_staconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -1205,6 +1222,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STADISCONNECTED) { +#ifdef USE_WIFI_AP + read_ap_clients(this->ap_clients_); +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE const auto &it = data->data.ap_stadisconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index ca32f3d3ffa..099eefd61da 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -629,7 +629,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { -#ifdef USE_WIFI_AP_EXCLUSIVE +#ifdef USE_WIFI_AP this->ap_clients_++; #endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -641,7 +641,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { -#ifdef USE_WIFI_AP_EXCLUSIVE +#ifdef USE_WIFI_AP if (this->ap_clients_ > 0) this->ap_clients_--; #endif @@ -881,6 +881,7 @@ bool WiFiComponent::wifi_loop_() { // so if pop() returned nullptr above we can skip this check. uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { + // A dropped station event cannot be made up for: the BK72xx core has no station query ESP_LOGW(TAG, "Dropped %" PRIu16 " WiFi events due to buffer overflow", dropped); } return true; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 5b05132f4f7..c1620154e6c 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -59,6 +59,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (ap.value()) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, true, CYW43_COUNTRY_WORLDWIDE); } else { +#ifdef USE_WIFI_AP + // Takes down the Arduino AP interface and its DHCP server; a later beginAP() is refused + // while they are still up + WiFi.disconnectAP(); +#endif cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, false, CYW43_COUNTRY_WORLDWIDE); } this->ap_started_ = ap.value(); @@ -238,12 +243,16 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { // Pass nullptr for empty password — CYW43 uses the password pointer (not length) // to choose between OPEN and WPA2 auth mode. const char *ap_password = ap.password_.empty() ? nullptr : ap.password_.c_str(); - WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1); + if (WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1) != WL_CONNECTED) { + ESP_LOGW(TAG, "beginAP failed"); + return false; + } return true; } network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *) WiFi.softAPIP()}; } + #endif // USE_WIFI_AP bool WiFiComponent::wifi_disconnect_() { @@ -372,5 +381,36 @@ bool WiFiComponent::wifi_loop_() { void WiFiComponent::wifi_pre_setup_() {} } // namespace esphome::wifi + +#ifdef USE_WIFI_AP +// Firmware event ids the driver header leaves out +static constexpr uint32_t AP_STATION_ASSOC_IND = 8; + +// The driver hands every firmware event to cyw43_cb_process_async_event and ignores the AP station +// ones, so they are counted here before it sees them. Runs in the driver's context. Verified with +// arduino-pico 6.1.1 on a Pico 2W: a join gives AUTH_IND then ASSOC_IND, a leave gives DISASSOC_IND. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism. +extern "C" void __real_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); +extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev) { + if (ev->interface == CYW43_ITF_AP) { + switch (ev->event_type) { + case AP_STATION_ASSOC_IND: + // A refused association carries its status here and has no leave event to balance it. A + // reassociation comes from a station that is already counted, whether or not its leave was seen. + if (ev->status == CYW43_STATUS_SUCCESS) + esphome::wifi::global_wifi_component->ap_clients_++; + break; + case CYW43_EV_DISASSOC_IND: + case CYW43_EV_DEAUTH_IND: + if (esphome::wifi::global_wifi_component->ap_clients_ > 0) + esphome::wifi::global_wifi_component->ap_clients_--; + break; + } + } + __real_cyw43_cb_process_async_event(cb_data, ev); +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif // USE_WIFI_AP #endif #endif diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index ffeec136d3a..6b552c33da8 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -2,6 +2,9 @@ wifi: min_auth_mode: WPA2 post_connect_roaming: true phy_mode: 11G + ap: + ssid: Fallback AP + password: fallback_password packages: - !include common.yaml diff --git a/tests/components/wifi/test.rp2040-ard.yaml b/tests/components/wifi/test.rp2040-ard.yaml index dade44d145b..ec545f7350e 100644 --- a/tests/components/wifi/test.rp2040-ard.yaml +++ b/tests/components/wifi/test.rp2040-ard.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +wifi: + ap: + ssid: Fallback AP + password: fallback_password + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/test.rtl87xx-ard.yaml b/tests/components/wifi/test.rtl87xx-ard.yaml index dade44d145b..ec545f7350e 100644 --- a/tests/components/wifi/test.rtl87xx-ard.yaml +++ b/tests/components/wifi/test.rtl87xx-ard.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +wifi: + ap: + ssid: Fallback AP + password: fallback_password + +packages: + wifi: !include common.yaml From fe57eef3f66a56815faa9fc025c2f3ea5e325127 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:28:01 +0100 Subject: [PATCH 400/651] [core] Test that includes inside a package resolve next to the package (#20739) --- tests/unit_tests/core/test_config.py | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 883f9d8b3d5..bb565564c6e 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -17,6 +17,8 @@ from esphome.const import ( CONF_BUILD_PATH, CONF_DEVICES, CONF_ESPHOME, + CONF_INCLUDES, + CONF_INCLUDES_C, CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, @@ -36,7 +38,7 @@ from esphome.core.config import ( validate_hostname, ) -from .common import load_config_from_fixture +from .common import load_config_from_fixture, load_config_from_yaml FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" @@ -689,6 +691,32 @@ def test_valid_include_invalid_extension(tmp_path: Path) -> None: valid_include(str(invalid_file)) +def test_includes_in_a_package_resolve_next_to_the_package( + tmp_path: Path, yaml_file: Callable[[str], Path] +) -> None: + """A package's includes resolve next to the package file (issue #14583).""" + package_dir = tmp_path / "packages" / "helper" + package_dir.mkdir(parents=True) + (package_dir / "my_helper.h").write_text("#pragma once\n") + (package_dir / "my_c_helper.h").write_text("#pragma once\n") + (package_dir / "helper.yaml").write_text( + "esphome:\n includes:\n - my_helper.h\n includes_c:\n - my_c_helper.h\n" + ) + + result = load_config_from_yaml( + yaml_file, + "esphome:\n" + " name: test\n" + "host:\n" + "packages:\n" + " - !include packages/helper/helper.yaml\n", + ) + + assert result is not None + assert result[CONF_ESPHOME][CONF_INCLUDES] == [str(package_dir / "my_helper.h")] + assert result[CONF_ESPHOME][CONF_INCLUDES_C] == [str(package_dir / "my_c_helper.h")] + + def test_valid_project_name_valid() -> None: """Test valid_project_name accepts valid project names.""" assert valid_project_name("esphome.my_project") == "esphome.my_project" From af709ac9a5c145beb88ac74abe60850487cffbba Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 14 Oct 2026 18:30:36 -0500 Subject: [PATCH 401/651] [openthread] Fail instead of aborting when NVS is unavailable (#20717) Co-authored-by: Claude Opus 5.5 --- esphome/components/openthread/openthread.cpp | 15 +++++++++++++++ .../components/openthread/openthread_esp.cpp | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 4986850cd4f..c4ab27689e7 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -58,6 +58,9 @@ void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) // Gets the off-mesh routable address std::optional OpenThreadComponent::get_omr_address() { + // A failed setup never creates the lock, and acquire() would abort waiting for it + if (this->is_failed()) + return {}; InstanceLock lock = InstanceLock::acquire(); return this->get_omr_address_(lock); } @@ -122,6 +125,10 @@ void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrp } void OpenThreadSrpComponent::setup() { + if (global_openthread_component->is_failed()) { + this->mark_failed(); + return; + } otError error; InstanceLock lock = InstanceLock::acquire(); otInstance *instance = lock.get_instance(); @@ -228,6 +235,9 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { } bool OpenThreadComponent::teardown() { + // Nothing was started + if (this->is_failed()) + return true; switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { { @@ -272,6 +282,11 @@ bool OpenThreadComponent::teardown() { } void OpenThreadComponent::on_factory_reset(std::function callback) { + if (this->is_failed()) { + // No SRP registration to remove; reset right away + callback(); + return; + } this->factory_reset_external_callback_ = std::move(callback); ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index f3860b2cc81..de1295fa26a 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -10,12 +10,12 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #include "esp_err.h" #include "esp_netif.h" #include "esp_openthread_netif_glue.h" #include "esp_vfs_eventfd.h" -#include "nvs_flash.h" ESPHOME_LOG_TAG(TAG, "openthread"); @@ -29,8 +29,14 @@ void OpenThreadComponent::setup() { esp_vfs_eventfd_config_t eventfd_config = { .max_fds = 3, }; - // Network interface setup handled by network component - ESP_ERROR_CHECK(nvs_flash_init()); + // Network interface setup handled by network component. + // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_start() + // aborts without it, so fail this component instead. + if (global_preferences->nvs_handle == 0) { + ESP_LOGE(TAG, "NVS unavailable"); + this->mark_failed(); + return; + } ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); esp_openthread_config_t config = {.netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(), @@ -153,6 +159,11 @@ InstanceLock InstanceLock::acquire() { ESP_LOGE(TAG, "OpenThread component torn down, cannot acquire instance lock"); abort(); } + // A failed setup() never creates the lock; say so now instead of after the wait below + if (global_openthread_component->is_failed()) { + ESP_LOGE(TAG, "Setup failed, cannot acquire instance lock"); + abort(); + } // Wait for the lock to be created before attempting to acquire it. // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_start(). constexpr uint32_t lock_init_timeout_ms = 10000; From 908c2f67783933104cbdd0939674ffa3237e38e1 Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Thu, 15 Oct 2026 03:29:47 +0300 Subject: [PATCH 402/651] [wifi] Add Wi-Fi Easy Connect (DPP) provisioning on the ESP32 (#17934) 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/wifi/__init__.py | 64 ++++++++- esphome/components/wifi/wifi_component.cpp | 126 ++++++++++++++---- esphome/components/wifi/wifi_component.h | 47 +++++++ .../wifi/wifi_component_esp_idf.cpp | 103 ++++++++++++++ esphome/core/defines.h | 2 + tests/components/wifi/test-dpp.esp32-idf.yaml | 12 ++ .../wifi/validate-dpp-bare.esp32-idf.yaml | 6 + tests/unit_tests/components/test_wifi.py | 9 ++ 8 files changed, 341 insertions(+), 28 deletions(-) create mode 100644 tests/components/wifi/test-dpp.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-dpp-bare.esp32-idf.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bc730fe66fa..5879729a22b 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -31,6 +31,7 @@ from esphome.const import ( CONF_CERTIFICATE, CONF_CERTIFICATE_AUTHORITY, CONF_CHANNEL, + CONF_CHANNELS, CONF_DNS1, CONF_DNS2, CONF_DOMAIN, @@ -158,9 +159,13 @@ def has_native_wifi( CONF_SAVE = "save" CONF_BAND_MODE = "band_mode" +CONF_DEVICE_INFO = "device_info" +CONF_DPP = "dpp" CONF_MIN_AUTH_MODE = "min_auth_mode" +CONF_ON_DPP_URI = "on_dpp_uri" CONF_PHY_MODE = "phy_mode" CONF_POST_CONNECT_ROAMING = "post_connect_roaming" +CONF_PRIVATE_KEY = "private_key" CONF_AP_MODE = "ap_mode" CONF_DNS_SERVERS = "dns_servers" @@ -178,6 +183,10 @@ DEFAULT_REBOOT_TIMEOUT = "15min" # Both defaults also match the C++ initializers in wifi_component.h; codegen skips # the setter when the config equals them. +# Similar to AP timeout, but reduced to allow repetitive enrollment attempts +# in the case of failure. Does not affect AP or scanning. +DEFAULT_DPP_TIMEOUT = "1min" + wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") ManualIP = wifi_ns.struct("ManualIP") @@ -250,6 +259,14 @@ WiFiConfigureAction = wifi_ns.class_( ) +def _validate_dpp_device_info(value: object) -> str: + """The supplicant puts the text on a space separated command line and reads it up to the next space.""" + value = cv.string_strict(value) + if any(c.isspace() for c in value): + raise cv.Invalid("device_info cannot contain whitespace") + return value + + def validate_password(value): value = cv.string_strict(value) if not value: @@ -316,6 +333,26 @@ EAP_AUTH_SCHEMA = cv.All( cv.has_at_least_one_key(CONF_IDENTITY, CONF_CERTIFICATE), ) +# Wi-Fi Easy Connect (DPP) enrollee; the ESP-IDF supplicant supports the QR code method +DPP_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PRIVATE_KEY): cv.sensitive(cv.string_strict), + cv.Optional(CONF_DEVICE_INFO): _validate_dpp_device_info, + cv.Optional(CONF_CHANNELS, default=[6]): cv.ensure_list( + cv.int_range(min=1, max=14) + ), + cv.Optional( + CONF_TIMEOUT, default=DEFAULT_DPP_TIMEOUT + ): cv.positive_time_period_milliseconds, + } +) + + +def _dpp_schema(value: ConfigType | None) -> ConfigType: + # A bare `dpp:` takes every default + return DPP_SCHEMA({} if value is None else value) + + WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), @@ -410,15 +447,18 @@ def _apply_min_auth_mode_default(config): def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config + has_dpp = CONF_DPP in config full_config = fv.full_config.get() has_improv = "improv_ble" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config has_web_server = "web_server" in full_config - if not (has_sta or has_ap or has_improv or has_improv_serial): + if not (has_sta or has_ap or has_dpp or has_improv or has_improv_serial): raise cv.Invalid( - "Please specify at least an SSID or an Access Point to create." + "Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)." ) + if CONF_ON_DPP_URI in config and not has_dpp: + raise cv.Invalid(f"'{CONF_ON_DPP_URI}' requires '{CONF_DPP}'") ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP if ap_is_wisp: @@ -615,6 +655,7 @@ CONFIG_SCHEMA = cv.All( ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, + cv.Optional(CONF_DPP): cv.All(cv.only_on_esp32, _dpp_schema), cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT @@ -661,6 +702,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( single=True ), + cv.Optional(CONF_ON_DPP_URI): automation.validate_automation(single=True), cv.Optional(CONF_USE_PSRAM): cv.All( cv.only_on_esp32, cv.requires_component("psram"), cv.boolean ), @@ -807,6 +849,18 @@ async def to_code(config): if CORE.is_esp32: request_wifi(ap=CONF_AP in config) + if (dpp := config.get(CONF_DPP)) is not None: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DPP_SUPPORT", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_DPP", True) + if (key := dpp.get(CONF_PRIVATE_KEY)) is not None: + cg.add(var.set_dpp_key(key)) + if (info := dpp.get(CONF_DEVICE_INFO)) is not None: + cg.add(var.set_dpp_device_info(info)) + cg.add(var.set_dpp_channels(",".join(str(c) for c in dpp[CONF_CHANNELS]))) + if (timeout := dpp[CONF_TIMEOUT]) != cv.time_period(DEFAULT_DPP_TIMEOUT): + cg.add(var.set_dpp_timeout(timeout)) + cg.add_define("USE_WIFI_DPP") + # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) @@ -977,6 +1031,12 @@ async def to_code(config): var.get_disconnect_trigger(), [], on_disconnect_config ) + if (on_dpp_uri := config.get(CONF_ON_DPP_URI)) is not None: + cg.add_define("USE_WIFI_DPP_URI_TRIGGER") + await automation.build_callback_automation( + var, "add_on_dpp_uri_callback", [(cg.const_char_ptr, "uri")], on_dpp_uri + ) + CORE.add_job(final_step) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 221efef07d9..837c0a1a9d6 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -187,11 +187,11 @@ bool CompactString::operator==(const StringRef &other) const { /// │ ↓ │ /// │ 5. FAILED → RESTARTING_ADAPTER │ /// │ - Normal: restart adapter, clear state │ -/// │ - AP/improv active: skip restart, just disconnect │ +/// │ - AP/DPP/improv active: skip restart, just disconnect │ /// │ ↓ │ /// │ 6. Loop back to start: │ /// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │ -/// │ - If AP/improv active → RETRY_HIDDEN (blind retry, see below) │ +/// │ - If AP/DPP/improv active → RETRY_HIDDEN (blind retry, see below)│ /// │ - Otherwise → SCAN_CONNECTING (rescan) │ /// │ ↓ │ /// │ 7. RESCAN → Apply stored priorities, sort again │ @@ -655,12 +655,17 @@ void WiFiComponent::setup() { this->configured_power_save_ = this->power_save_; #endif -#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) - // 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. A bridge AP carries no portal. +#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP))) + // The access point, the portals on it and the DPP listen are provisioning surfaces: once the + // window has closed, end them (mirrors the teardown on a successful connection). The blocks in + // loop() that start them are gated so none of them is started again afterwards. A bridge AP + // carries no portal and stays up. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); +#endif +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) 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"); @@ -674,6 +679,7 @@ void WiFiComponent::setup() { #endif this->disable_ap_(); } +#endif // USE_WIFI_AP }); } #endif @@ -790,6 +796,11 @@ void WiFiComponent::start() { this->start_ap_portal_(); #endif // USE_WIFI_AP } +#ifdef USE_WIFI_DPP + // Without networks there is nothing to try first, so listen for a configurator right away + if (!this->has_sta() && this->is_dpp_active_() && this->wifi_mode_(true, {})) + this->start_dpp_listen_(millis()); +#endif #ifdef USE_IMPROV_BLE if (!this->has_sta() && improv_ble::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) @@ -849,7 +860,7 @@ void WiFiComponent::loop() { break; } // 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_(); + bool portal_active = this->is_provisioning_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 @@ -934,12 +945,7 @@ void WiFiComponent::loop() { // Once the provisioning window has closed, don't bring up the fallback AP (or // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. - bool provisioning_closed = false; -#ifdef USE_PROVISIONING - provisioning_closed = - provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); -#endif - if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) { + if (this->has_ap() && !this->ap_setup_ && !this->is_provisioning_closed_()) { if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_) #ifdef USE_WIFI_AP_EXCLUSIVE @@ -975,7 +981,6 @@ void WiFiComponent::loop() { improv_ble::global_improv_component->start(); } } - #endif if (this->reboot_timeout_ != 0 && now - this->last_connected_ > this->reboot_timeout_) { @@ -1004,6 +1009,15 @@ void WiFiComponent::loop() { } } +#ifdef USE_WIFI_DPP + // Without networks no cooldown starts the listen, so a failed or abandoned enrollment is listened for again + // here, at most once per timeout + if (!this->has_sta() && !this->dpp_listening_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && + now - this->dpp_listen_started_ > this->dpp_timeout_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) { + this->start_dpp_listen_(now); + } +#endif + #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) // Check if power save mode needs to be updated based on high-performance requests if (this->high_performance_semaphore_ != nullptr) { @@ -1356,13 +1370,23 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { ESP_LOGE(TAG, "wifi_sta_connect_ failed"); // Enter cooldown to allow WiFi hardware to stabilize // (immediate failure suggests hardware not ready, different from connection timeout) - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - } else { - this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; + this->start_cooldown_(); + return; } + this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; this->action_started_ = millis(); } +void WiFiComponent::start_cooldown_() { + this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; + this->action_started_ = millis(); +#ifdef USE_WIFI_DPP + // The cooldown is the configurator's window; the attempt that ends it stops the listen again + if (!this->dpp_listening_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) + this->start_dpp_listen_(this->action_started_); +#endif +} + const LogString *get_signal_bars(int8_t rssi) { // LOWER ONE QUARTER BLOCK // Unicode: U+2582, UTF-8: E2 96 82 @@ -1475,6 +1499,9 @@ void WiFiComponent::disable() { this->state_ = WIFI_COMPONENT_STATE_DISABLED; #ifdef USE_WIFI_LEASE_CACHE this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); +#endif +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); #endif this->wifi_disconnect_(); this->wifi_mode_(false, false); @@ -1731,6 +1758,12 @@ void WiFiComponent::dump_config() { ESP_LOGCONFIG(TAG, " Disabled"); return; } +#ifdef USE_WIFI_DPP + ESP_LOGCONFIG(TAG, + " DPP channels: %s\n" + " DPP timeout: %" PRIu32 " ms", + this->dpp_channels_, this->dpp_timeout_); +#endif #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) const char *band_mode_s; switch (this->band_mode_) { @@ -1782,6 +1815,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { this->disable_ap_(); } #endif +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); +#endif #ifdef USE_IMPROV_BLE if (this->is_improv_ble_active_()) { improv_ble::global_improv_component->stop(); @@ -1959,7 +1995,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // No hidden networks - always go through RESTARTING_ADAPTER phase // This ensures num_retried_ gets reset and a fresh scan is triggered - // The actual adapter restart will be skipped if captive portal/improv is active + // The actual adapter restart will be skipped if DPP/captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RETRY_HIDDEN: @@ -1982,7 +2018,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER // This ensures num_retried_ gets reset and a fresh scan is triggered - // The actual adapter restart will be skipped if captive portal/improv is active + // The actual adapter restart will be skipped if DPP/captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RESTARTING_ADAPTER: @@ -2012,14 +2048,14 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { // attempting to connect to all configured networks in sequence. // Captive portal needs scan results to show available networks. // If captive portal is active, only skip scanning if we've done a scan after it started. - // If only improv is active (no captive portal), skip scanning since improv doesn't need results. + // If only DPP or improv is active (no captive portal), skip scanning since neither needs results. if (this->is_captive_portal_active_()) { if (this->has_completed_scan_after_captive_portal_start_) { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal - } else if (this->is_improv_ble_active_()) { - // Improv doesn't need scan results + } else if (this->is_improv_ble_active_() || this->is_dpp_active_()) { + // Improv and DPP don't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } return WiFiRetryPhase::SCAN_CONNECTING; @@ -2109,7 +2145,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // 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 portal/improv connection - if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) { + if (!this->is_provisioning_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2117,15 +2153,14 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle - // This is critical for captive portal/improv flow: when determine_next_phase_() + // This is critical for DPP/captive portal/improv flow: when determine_next_phase_() // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_() // will see BLIND_RETRY mode and treat ALL networks as candidates, // effectively cycling through all configured networks without scan results. this->retry_hidden_mode_ = RetryHiddenMode::BLIND_RETRY; // Always enter cooldown after restart (or skip-restart) to allow stabilization // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - this->action_started_ = millis(); + this->start_cooldown_(); // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting return true; @@ -2361,6 +2396,32 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { #endif } +#ifdef USE_WIFI_DPP +void WiFiComponent::start_dpp_listen_(uint32_t now) { + this->dpp_listen_started_ = now; + this->dpp_listening_ = this->wifi_dpp_start_listen_(); +} + +void WiFiComponent::stop_dpp_listen_() { + if (!this->dpp_listening_) + return; + this->wifi_dpp_stop_listen_(); + this->dpp_listening_ = false; +} +#endif + +bool WiFiComponent::is_dpp_active_() { +#ifdef USE_WIFI_DPP + // A configurator is waited for once the networks have had their timeout, or from boot without any; the retry + // machine then treats DPP like a portal (blind retries, a long cooldown) whether the supplicant is listening at + // this moment or not + return !this->is_provisioning_closed_() && + (!this->has_sta() || App.get_loop_component_start_time() - this->last_connected_ > this->dpp_timeout_); +#else + return false; +#endif +} + bool WiFiComponent::is_captive_portal_active_() { #ifdef USE_CAPTIVE_PORTAL return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active(); @@ -2406,6 +2467,19 @@ bool WiFiComponent::is_improv_ble_active_() { #endif } +bool WiFiComponent::is_provisioning_active_() { + return this->is_ap_portal_active_() || this->is_improv_ble_active_() || this->is_dpp_active_(); +} + +bool WiFiComponent::is_provisioning_closed_() { +#ifdef USE_PROVISIONING + // The device stays unprovisionable until it is power-cycled + return provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); +#else + return false; +#endif +} + #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) bool WiFiComponent::request_high_performance() { // Already configured for high performance - request satisfied diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 4c346cb40a7..db4cbae8389 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -486,6 +487,21 @@ class WiFiComponent final : public Component { void set_ap_timeout(uint32_t ap_timeout) { ap_timeout_ = ap_timeout; } #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP + // Code generation passes string literals that live in flash + void set_dpp_key(const char *key) { this->dpp_key_ = key; } + void set_dpp_device_info(const char *info) { this->dpp_device_info_ = info; } + void set_dpp_channels(const char *channels) { this->dpp_channels_ = channels; } + void set_dpp_timeout(uint32_t timeout) { this->dpp_timeout_ = timeout; } +#ifdef USE_WIFI_DPP_URI_TRIGGER + template void add_on_dpp_uri_callback(F &&callback) { + this->dpp_uri_callback_.add(std::forward(callback)); + } +#endif + /// The Easy Connect URI to show as a QR code, nullptr until the supplicant has generated it + const char *get_dpp_uri() const { return this->dpp_uri_.get(); } +#endif + void enable(); void disable(); bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } @@ -851,6 +867,23 @@ class WiFiComponent final : public Component { network::IPAddress wifi_gateway_ip_(); network::IPAddress wifi_dns_ip_(int num); +#ifdef USE_WIFI_DPP + // Starts listening for a DPP configurator; the platform half reports whether the driver accepted + void start_dpp_listen_(uint32_t now); + void stop_dpp_listen_(); +#endif + /// Wait before the next attempt; while a configurator is waited for, the wait is its listen window + void start_cooldown_(); +#ifdef USE_WIFI_DPP + void wifi_dpp_init_(); + bool wifi_dpp_start_listen_(); + void wifi_dpp_stop_listen_(); +#endif + bool is_dpp_active_(); + /// A portal, improv or DPP is waiting for credentials, so the retry machine must not disturb the radio + bool is_provisioning_active_(); + /// The provisioning window has closed, so no new provisioning surface may start + bool is_provisioning_closed_(); 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_(); @@ -1008,6 +1041,17 @@ class WiFiComponent final : public Component { // 1-byte members so they pack into its trailing bytes instead of padding after it WiFiAP ap_; #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP + const char *dpp_key_{nullptr}; + const char *dpp_device_info_{nullptr}; + const char *dpp_channels_{nullptr}; + std::unique_ptr dpp_uri_; // written by the event task before the URI event is queued +#ifdef USE_WIFI_DPP_URI_TRIGGER + CallbackManager dpp_uri_callback_; +#endif + uint32_t dpp_timeout_{60000}; // Keep in sync with DEFAULT_DPP_TIMEOUT in __init__.py + uint32_t dpp_listen_started_{0}; +#endif // 1-byte enums and integers WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; @@ -1091,6 +1135,9 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; +#ifdef USE_WIFI_DPP + bool dpp_listening_{false}; +#endif #ifdef USE_WIFI_AP // Stations joined to the AP; they hold the reboot timeout and keep an exclusive AP from pausing. // Counted on the main loop on ESP32 and LibreTiny (queued events), in the SDK callback on ESP8266 and diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c31fe336484..71e8511e03e 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -24,6 +24,10 @@ #endif #endif +#ifdef USE_WIFI_DPP +#include +#endif + #ifdef USE_WIFI_AP #include "dhcpserver/dhcpserver.h" #endif // USE_WIFI_AP @@ -64,6 +68,15 @@ static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid- static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_wifi_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_WIFI_DPP +/// Terminated copies of the received station credentials; the supplicant's wifi_config_t would grow every event + +static void copy_terminated(char *dst, const uint8_t *src, size_t len) { + memcpy(dst, src, len); + dst[len] = '\0'; +} +#endif + struct IDFWiFiEvent { esp_event_base_t event_base; int32_t event_id; @@ -76,6 +89,10 @@ struct IDFWiFiEvent { wifi_event_ap_stadisconnected_t ap_stadisconnected; wifi_event_ap_probe_req_rx_t ap_probe_req_rx; wifi_event_bss_rssi_low_t bss_rssi_low; +#ifdef USE_WIFI_DPP + SavedWifiSettings *dpp_credentials; // owned by the event; the rare 98 byte record does not widen the union + wifi_event_dpp_failed_t dpp_failed; +#endif ip_event_got_ip_t ip_got_ip; #if USE_NETWORK_IPV6 ip_event_got_ip6_t ip_got_ip6; @@ -125,6 +142,23 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi memcpy(&event.data.ap_staconnected, event_data, sizeof(wifi_event_ap_staconnected_t)); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) { memcpy(&event.data.ap_stadisconnected, event_data, sizeof(wifi_event_ap_stadisconnected_t)); +#ifdef USE_WIFI_DPP + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_URI_READY) { + // Kept on the component for the whole boot; the queued event only announces it + const auto *uri = static_cast(event_data); + const size_t len = strnlen(uri->uri, uri->uri_data_len); + auto copy = std::make_unique(len + 1); + copy_terminated(copy.get(), reinterpret_cast(uri->uri), len); + global_wifi_component->dpp_uri_ = std::move(copy); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD) { + const auto &sta = static_cast(event_data)->wifi_cfg.sta; + auto *credentials = new SavedWifiSettings; // NOLINT(cppcoreguidelines-owning-memory) + copy_terminated(credentials->ssid, sta.ssid, sizeof(sta.ssid)); + copy_terminated(credentials->password, sta.password, sizeof(sta.password)); + event.data.dpp_credentials = credentials; + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_FAILED) { + memcpy(&event.data.dpp_failed, event_data, sizeof(wifi_event_dpp_failed_t)); +#endif #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) } else if (event_base == IP_EVENT && event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { memcpy(&event.data.ip_assigned_ip_to_client, event_data, sizeof(ip_event_assigned_ip_to_client_t)); @@ -141,6 +175,10 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi auto *to_send = new IDFWiFiEvent; // NOLINT(cppcoreguidelines-owning-memory) memcpy(to_send, &event, sizeof(IDFWiFiEvent)); if (!global_wifi_component->event_queue_.push(to_send)) { +#ifdef USE_WIFI_DPP + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD) + delete to_send->data.dpp_credentials; // NOLINT(cppcoreguidelines-owning-memory) +#endif delete to_send; // NOLINT(cppcoreguidelines-owning-memory) } } @@ -224,6 +262,9 @@ void WiFiComponent::wifi_lazy_init_() { } s_wifi_started = true; this->wifi_initialized_ = true; +#ifdef USE_WIFI_DPP + this->wifi_dpp_init_(); +#endif return; } @@ -242,9 +283,28 @@ void WiFiComponent::wifi_lazy_init_() { ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); return; } + this->wifi_initialized_ = true; +#ifdef USE_WIFI_DPP + this->wifi_dpp_init_(); +#endif } +#ifdef USE_WIFI_DPP +void WiFiComponent::wifi_dpp_init_() { + // With no callback the supplicant posts WIFI_EVENT_DPP_* events, handled with the other events + esp_err_t err = esp_supp_dpp_init(nullptr); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_init"), esp_err_to_name(err)); + return; + } + err = esp_supp_dpp_bootstrap_gen(this->dpp_channels_, DPP_BOOTSTRAP_QR_CODE, this->dpp_key_, this->dpp_device_info_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_bootstrap_gen"), esp_err_to_name(err)); + } +} +#endif + bool WiFiComponent::wifi_mode_(optional sta, optional ap) { esp_err_t err; wifi_mode_t current_mode = WIFI_MODE_NULL; @@ -354,6 +414,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // enable STA if (!this->wifi_mode_(true, {})) return false; +#ifdef USE_WIFI_DPP + // A connect attempt ends the supplicant's listen; the next cooldown starts it again + this->stop_dpp_listen_(); +#endif // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_wifi.html#_CPPv417wifi_sta_config_t wifi_config_t conf; @@ -1232,6 +1296,31 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf); #endif +#ifdef USE_WIFI_DPP + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_URI_READY) { + ESP_LOGD(TAG, "DPP URI: %s", this->dpp_uri_.get()); +#ifdef USE_WIFI_DPP_URI_TRIGGER + this->dpp_uri_callback_.call(this->dpp_uri_.get()); +#endif + + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_CFG_RECVD) { + const std::unique_ptr credentials(data->data.dpp_credentials); + this->dpp_listening_ = false; + ESP_LOGI(TAG, "DPP configured network " LOG_SECRET("'%s'"), credentials->ssid); + // The reboot timer starts over for the connect attempt, as after a boot with saved credentials + this->last_connected_ = App.get_loop_component_start_time(); + // Saved like portal credentials; from a cooldown this already restarts the retries + this->save_wifi_sta(credentials->ssid, credentials->password); + if (this->state_ == WIFI_COMPONENT_STATE_OFF || this->state_ == WIFI_COMPONENT_STATE_AP) { + // Without networks the station machine was never started; an AP without a portal sits in AP + this->start_connecting(*this->get_selected_sta_()); + } + + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_FAILED) { + this->dpp_listening_ = false; + ESP_LOGW(TAG, "DPP failed: %s", esp_err_to_name(data->data.dpp_failed.failure_reason)); +#endif // USE_WIFI_DPP + #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { const auto &it = data->data.ip_assigned_ip_to_client; @@ -1463,6 +1552,20 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { } #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP +bool WiFiComponent::wifi_dpp_start_listen_() { + esp_err_t err = esp_supp_dpp_start_listen(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_start_listen"), esp_err_to_name(err)); + return false; + } + ESP_LOGD(TAG, "DPP listening for a configurator"); + return true; +} + +void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); } +#endif + bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); } bssid_t WiFiComponent::wifi_bssid() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7763812054a..e0164c69245 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -496,6 +496,8 @@ #define USE_CAPTIVE_PORTAL_GZIP #endif #define USE_WIFI_11KV_SUPPORT +#define USE_WIFI_DPP +#define USE_WIFI_DPP_URI_TRIGGER #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_LEASE_CACHE diff --git a/tests/components/wifi/test-dpp.esp32-idf.yaml b/tests/components/wifi/test-dpp.esp32-idf.yaml new file mode 100644 index 00000000000..29ec40ba797 --- /dev/null +++ b/tests/components/wifi/test-dpp.esp32-idf.yaml @@ -0,0 +1,12 @@ +wifi: + dpp: + channels: [1, 6, 11] + device_info: ESPHome + timeout: 30s + on_dpp_uri: + - logger.log: + format: "DPP URI %s" + args: [uri] + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml b/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml new file mode 100644 index 00000000000..cdb69281c45 --- /dev/null +++ b/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml @@ -0,0 +1,6 @@ +# A bare dpp: takes every default +wifi: + dpp: + +packages: + wifi: !include common.yaml diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index b09e37aacf1..e52ec99418a 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -13,6 +13,7 @@ from esphome.components.wifi import ( CONF_AP_MODE, CONF_AP_TIMEOUT, CONF_DNS_SERVERS, + _validate_dpp_device_info, check_placeholder_credentials, final_validate as wifi_final_validate, has_native_wifi, @@ -219,6 +220,14 @@ def test_check_placeholder_credentials_skips_template_ssid() -> None: assert check_placeholder_credentials(config) is None +@pytest.mark.parametrize("info", ["ESPHome bench", "a\tb", " x"]) +def test_dpp_device_info_rejects_whitespace(info: str) -> None: + """The supplicant reads the text up to the first space.""" + with pytest.raises(cv.Invalid, match="whitespace"): + _validate_dpp_device_info(info) + assert _validate_dpp_device_info("bench-01") == "bench-01" + + @pytest.fixture def wisp_core(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: """An ESP32 build; the parameter overrides the framework or the ESP-IDF version.""" From 92e0e6db6b10ab47c774a8867d264b2e028aecea Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:40:07 -0400 Subject: [PATCH 403/651] [wifi] Call esp_supp_dpp_init without a callback on ESP-IDF 6 (#20789) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 71e8511e03e..d308ad2b977 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -293,7 +293,11 @@ void WiFiComponent::wifi_lazy_init_() { #ifdef USE_WIFI_DPP void WiFiComponent::wifi_dpp_init_() { // With no callback the supplicant posts WIFI_EVENT_DPP_* events, handled with the other events +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + esp_err_t err = esp_supp_dpp_init(); +#else esp_err_t err = esp_supp_dpp_init(nullptr); +#endif if (err != ESP_OK) { ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_init"), esp_err_to_name(err)); return; From 0f408234d043a3ed6a65183494bb02e785be99d2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:53:17 -0400 Subject: [PATCH 404/651] [core] Add ksz8851snl to the static analysis manifest for IDF 6 (#20788) --- esphome/idf_component.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 372059bd96f..5ed84fa2a3a 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -85,6 +85,10 @@ dependencies: version: "1.1.0" rules: - if: "idf_version >=6.0.0" + espressif/ksz8851snl: + version: "1.2.0" + rules: + - if: "idf_version >=6.0.0" # Keep in sync with esphome/components/ethernet/__init__.py espressif/ch390: version: "0.3.0" From 22defcd7042768c0ed52e5392ff888bd81ce8883 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 15:59:56 -1000 Subject: [PATCH 405/651] [esp32_ble_client] Value-initialize the connection parameters for ESP-IDF 6.1 (#20776) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index dde47b0c5e4..6905ef82228 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) { esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From 6edfbb594df63f615b907907e6482a1a4882617f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 16:00:07 -1000 Subject: [PATCH 406/651] [bluetooth_connection] Value-initialize the connection parameters for ESP-IDF 6.1 (#20774) --- .../bluetooth_connection/bluetooth_connection_bluedroid.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 7b66c671835..5fe1460c661 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() { esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From f7057e1270a97eb9ecf743e81834884e0927efa7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 16:01:45 -1000 Subject: [PATCH 407/651] [tests] Reset saved preferences before every C++ unit test (#20771) --- tests/components/daikin/daikin_regression_tests.cpp | 2 +- tests/components/daikin/daikin_tests.cpp | 2 +- tests/components/exponential_moving_average/common.h | 3 --- tests/components/friedrich/friedrich_test.cpp | 8 +------- tests/components/main.cpp | 8 +++++++- tests/components/mistral_ir/mistral_ir_tests.cpp | 2 +- tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp | 2 +- tests/components/whirlpool/whirlpool_tests.cpp | 4 ---- 8 files changed, 12 insertions(+), 19 deletions(-) diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp index fd695b9ca48..fc642a84e9d 100644 --- a/tests/components/daikin/daikin_regression_tests.cpp +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -121,7 +121,7 @@ struct Rig { DaikinClimate climate; Rig() { - // All instances share one preference key; clear it so setup starts from defaults + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); static const bool APP_CONSTRUCTED = (new (&App) Application(), true); diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp index 5a71cf41b7f..0664a82571c 100644 --- a/tests/components/daikin/daikin_tests.cpp +++ b/tests/components/daikin/daikin_tests.cpp @@ -143,7 +143,7 @@ struct Fixture { /// 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 + // A second fixture in the same test would otherwise restore what the first saved 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 diff --git a/tests/components/exponential_moving_average/common.h b/tests/components/exponential_moving_average/common.h index 599c36bb0d5..84b9a24a522 100644 --- a/tests/components/exponential_moving_average/common.h +++ b/tests/components/exponential_moving_average/common.h @@ -8,7 +8,6 @@ #include #include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h" -#include "esphome/core/preferences.h" #ifdef USE_HOST #include "esphome/components/host/preferences.h" #endif @@ -33,11 +32,9 @@ class ExponentialMovingAverageTest : public ::testing::Test { #ifdef USE_HOST host::setup_preferences(); #endif - global_preferences->reset(); } void TearDown() override { - global_preferences->reset(); if (this->saved_prefdir_.has_value()) { setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1); } else { diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp index baf4e53652e..610eca9c091 100644 --- a/tests/components/friedrich/friedrich_test.cpp +++ b/tests/components/friedrich/friedrich_test.cpp @@ -4,7 +4,6 @@ #include "esphome/components/friedrich/friedrich.h" #include "esphome/components/remote_base/aeha_protocol.h" #include "esphome/core/helpers.h" -#include "esphome/core/preferences.h" namespace esphome::friedrich::testing { @@ -42,12 +41,7 @@ struct Fixture { TestFriedrich sut; CapturingTransmitter transmitter; - Fixture() { - // Climate tests share one preference key, so clear what an earlier test saved or setup restores it - if (global_preferences != nullptr) - global_preferences->reset(); - this->sut.set_transmitter(&this->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{}); } diff --git a/tests/components/main.cpp b/tests/components/main.cpp index f1ceb732f91..4020dd9a505 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -2,6 +2,7 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/preferences.h" /* This special main.cpp replaces the default one. @@ -22,7 +23,12 @@ void original_setup() { // 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 OnTestStart(const ::testing::TestInfo &) override { + esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; + // Entities of the same kind share preference keys, so a value an earlier test saved would be restored + if (esphome::global_preferences != nullptr) + esphome::global_preferences->reset(); + } }; void setup() { diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp index 37db86d10a1..9b489154d02 100644 --- a/tests/components/mistral_ir/mistral_ir_tests.cpp +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -81,7 +81,7 @@ struct Fixture { MistralIR climate; Fixture() { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp index e7a5e2c18f2..293c363d5d6 100644 --- a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -69,7 +69,7 @@ struct Fixture { PDPioneerIR climate; Fixture() { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does diff --git a/tests/components/whirlpool/whirlpool_tests.cpp b/tests/components/whirlpool/whirlpool_tests.cpp index ef628abe54d..96cd288c61b 100644 --- a/tests/components/whirlpool/whirlpool_tests.cpp +++ b/tests/components/whirlpool/whirlpool_tests.cpp @@ -7,7 +7,6 @@ #include "esphome/components/remote_base/remote_base.h" #include "esphome/components/whirlpool/whirlpool.h" -#include "esphome/core/preferences.h" namespace esphome::whirlpool::testing { @@ -122,9 +121,6 @@ struct Fixture { 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(); From f3eaf698d6d35b8fbd5c3990d99772adaec2739c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:05:01 -0400 Subject: [PATCH 408/651] [mipi_rgb] Rotate pure RGB panels in software (#20757) --- .../components/mipi_rgb/models/__init__.py | 6 +++- .../mipi_rgb/test_mipi_rgb_config.py | 35 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py index 9e3fe2a476f..62f8c7789d4 100644 --- a/esphome/components/mipi_rgb/models/__init__.py +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip): def transforms(self) -> set[str]: """ Return the set of transformations supported by this driver chip. - RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY. + A model with an empty init sequence has no command interface at all, so it + cannot apply any transform in hardware. """ + if self.initsequence is not None and len(self.initsequence) == 0: + return set() return super().transforms - {CONF_SWAP_XY} diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index e677577ec08..58e7ba9d959 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -93,12 +93,12 @@ def test_transform_matches_model_support( from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS - # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays # never support axis swapping). - model = MODELS["ESP32-8048S070"] + model = MODELS["ST7701S"] assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} - base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21} CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) # An unsupported axis may be explicitly disabled (a harmless no-op)... @@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x( CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) +def test_pure_rgb_model_has_no_hardware_transform( + set_core_config: SetCoreConfigCallable, +) -> None: + """A model with no init sequence has no command interface, so no transform + can be applied in hardware and every rotation must be done in software. + + Previously such models claimed mirror_x/mirror_y support, so a rotation of + 180 was handed to the (nonexistent) hardware and the display was not rotated. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["ESP32-8048S070"] + assert model.initsequence == () + assert model.transforms == set() + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + for rotation in (90, 180, 270): + config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation}) + assert not model.rotation_as_transform(config) + metadata = get_display_metadata(config["id"]) + assert metadata.rotation == rotation + assert not metadata.has_hardware_rotation + + with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + def test_metadata_records_rotation( set_core_config: SetCoreConfigCallable, ) -> None: From 3188e0d36f45f8828325194d570b043bbb4815d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 17:30:56 -1000 Subject: [PATCH 409/651] [ota] Fall back to a UDP data phase when the link is lossy (#20498) --- .../components/esphome/ota/ota_esphome.cpp | 111 +++- esphome/components/esphome/ota/ota_esphome.h | 108 +++- .../esphome/ota/ota_esphome_noise.cpp | 12 +- .../esphome/ota/ota_esphome_udp.cpp | 375 +++++++++++ esphome/espota2.py | 605 +++++++++++++++--- tests/integration/test_host_ota.py | 94 ++- tests/unit_tests/test_espota2.py | 494 +++++++++++++- tests/unit_tests/test_espota2_noise.py | 54 +- 8 files changed, 1710 insertions(+), 143 deletions(-) create mode 100644 esphome/components/esphome/ota/ota_esphome_udp.cpp diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e6b19c42f0c..df2aa4ee37a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -44,11 +44,14 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { } #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; -static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake +static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 60000; // milliseconds for initial handshake // Milliseconds for data transfer. Covers the lwIP retransmit run seen in // practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits // longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000; +// A handshake this old gives way to a waiting connection: a client retrying over a lossy link otherwise queues +// behind its own stalled attempt until both time out +static constexpr uint32_t OTA_HANDSHAKE_PREEMPT = 10000; static constexpr uint32_t OTA_PROGRESS_INTERVAL_MS = 1000; static constexpr size_t OTA_SIZE_FIELD_BYTES = 4; // sizes on the wire are 4 bytes MSB first @@ -199,6 +202,9 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10; +#ifdef ESPHOME_OTA_UDP +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_UDP = 0x20; +#endif // Noise needs the extended protocol: the prologue binds the 2-byte feature ack static constexpr uint8_t CLIENT_NOISE_FEATURES = CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; @@ -208,6 +214,10 @@ static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04; // Raw deflate, window <= OTA_INFLATE_WINDOW_SIZE. Binding once offered: the // client must then send the image size frame and a deflate stream. static constexpr uint8_t SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08; +#ifdef ESPHOME_OTA_UDP +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_UDP = 0x10; +static constexpr uint8_t UDP_REQUEST_V1 = 0x01; // espota2.UDP_REQUEST +#endif #ifdef USE_OTA_ENCRYPTION inline bool ESPHomeOTAComponent::noise_offered_() const { @@ -266,6 +276,12 @@ void ESPHomeOTAComponent::handle_handshake_() { this->cleanup_connection_(); return; } + // Only unauthenticated handshakes reach here; the data phase blocks inside handle_data_ + if (now - this->client_connect_time_ > OTA_HANDSHAKE_PREEMPT && this->server_->ready()) { + ESP_LOGD(TAG, "Newer connection waiting"); + this->cleanup_connection_(); + return; + } switch (this->ota_state_) { case OTAState::MAGIC_READ: { @@ -329,6 +345,9 @@ void ESPHomeOTAComponent::handle_handshake_() { static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack"); this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); +#ifdef ESPHOME_OTA_UDP + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_UDP; +#endif #ifdef USE_OTA_PARTITIONS this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; #endif @@ -483,6 +502,10 @@ void ESPHomeOTAComponent::handle_data_() { char *sbuf = reinterpret_cast(buf); size_t image_size; ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; +#ifdef ESPHOME_OTA_UDP + UdpLink udp_link; + this->udp_ = nullptr; // client_connect_time_ is done with +#endif // Set socket timeouts and blocking mode (see strategy table above) struct timeval tv; @@ -498,6 +521,38 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK); +#ifdef ESPHOME_OTA_UDP + // The server always offers UDP on the extended protocol + if (this->extended_proto_() && (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_UDP) != 0) { + // Whether the client wants UDP (0: its link looks good), answered with our token + if (!this->data_readall_(buf, 1)) { + if (this->client_left_before_start_()) + return; + this->log_read_error_(LOG_STR("UDP request")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + // 1 is this UDP format; other values are free for later ones, which we decline (a zero reply keeps TCP) + if (buf[0] == UDP_REQUEST_V1) { + this->udp_open_(udp_link); + if (udp_link.reply[0] == 0) { + ESP_LOGW(TAG, "UDP unavailable, using TCP"); + } + } + if (!this->data_write_(udp_link.reply, sizeof(udp_link.reply))) + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + if (udp_link.reply[0] != 0) { + this->udp_ = &udp_link; + if (!this->udp_start_()) { + // Nothing started yet: just drop the session, which the client retries as a network failure + this->log_read_error_(LOG_STR("UDP start")); + this->cleanup_connection_(); + return; + } + } + } + +#endif + if (this->extended_proto_()) { // Read ota type, 1 byte if (!this->data_readall_(buf, 1)) { @@ -598,11 +653,17 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge Update end OK - 1 byte this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK); - // Read ACK - if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { - this->log_read_error_(LOG_STR("ack")); - // do not go to error, this is not fatal - } +#ifdef ESPHOME_OTA_UDP + // UDP never reports a close: wait only until the client has our responses, not for its final ack + if (this->udp_ != nullptr) { + this->udp_linger_(); + } else +#endif + // Read ACK + if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { + this->log_read_error_(LOG_STR("ack")); + // do not go to error, this is not fatal + } this->cleanup_connection_(); delay(10); @@ -623,6 +684,9 @@ void ESPHomeOTAComponent::handle_data_() { error: this->data_write_byte_(static_cast(error_code)); +#ifdef ESPHOME_OTA_UDP + this->udp_linger_(); +#endif // Abort backend before cleanup - cleanup_connection_() destroys the backend. // Always call abort() unconditionally: backends register external partitions before @@ -651,7 +715,7 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { return false; } - ssize_t read = this->client_->read(buf + at, len - at); + ssize_t read = this->transport_read_(buf + at, len - at); if (read == -1) { const int err = errno; if (!this->would_block_(err)) { @@ -676,6 +740,10 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { return true; } bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { +#ifdef ESPHOME_OTA_UDP + if (this->udp_ != nullptr) + return this->udp_send_(buf, len); +#endif uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { @@ -728,7 +796,20 @@ void ESPHomeOTAComponent::log_read_error_(const LogString *what) { void ESPHomeOTAComponent::log_start_(const LogString *phase) { char peername[socket::SOCKADDR_STR_LEN]; - this->client_->getpeername_to(peername); +#ifdef ESPHOME_OTA_UDP + if (this->client_ == nullptr) { + // A committed UDP session has already dropped TCP +#ifdef ESPHOME_OTA_UDP_LWIP + ipaddr_ntoa_r(&this->udp_->peer_ip, peername, sizeof(peername)); +#else + socket::format_sockaddr_to(reinterpret_cast(&this->udp_->peer), this->udp_->peer_len, + peername); +#endif + } else +#endif + { + this->client_->getpeername_to(peername); + } ESP_LOGD(TAG, "Starting %s from %s", LOG_STR_ARG(phase), peername); } @@ -840,7 +921,7 @@ ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) { break; } #endif - read = this->client_->read(buf, requested); + read = this->transport_read_(buf, requested); if (read > 0) break; if (read == 0) { @@ -874,6 +955,11 @@ ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) { void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) { #if USE_OTA_VERSION == 2 +#ifdef ESPHOME_OTA_UDP + // UDP acks every message itself + if (this->udp_ != nullptr) + return; +#endif while (xfer.acknowledged + OTA_BLOCK_SIZE <= xfer.total || (xfer.total == xfer.ota_size && xfer.acknowledged < xfer.ota_size)) { this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK); @@ -883,7 +969,12 @@ void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) { } void ESPHomeOTAComponent::cleanup_connection_() { - this->client_->close(); +#ifdef ESPHOME_OTA_UDP + this->udp_ = nullptr; // the link itself lives on handle_data_'s stack +#endif + // Already gone once a UDP session committed + if (this->client_ != nullptr) + this->client_->close(); this->client_ = nullptr; this->client_connect_time_ = 0; this->handshake_buf_pos_ = 0; diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 910a5b3d99d..fbb1ba5447b 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -15,6 +15,25 @@ #include "esphome/core/preferences.h" #include "esphome/core/hash_base.h" +// UDP data phase: raw lwIP needs a main loop lock (or a single thread); the host polls a socket instead +#ifdef USE_ESP32 +#include +#endif +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || \ + (defined(USE_ESP32) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING)) +#define ESPHOME_OTA_UDP +#define ESPHOME_OTA_UDP_LWIP +#include "lwip/ip_addr.h" +#include "lwip/pbuf.h" +#include "lwip/udp.h" +#if defined(USE_LIBRETINY) && !LWIP_TCPIP_CORE_LOCKING +#undef ESPHOME_OTA_UDP +#undef ESPHOME_OTA_UDP_LWIP +#endif +#elif defined(USE_HOST) +#define ESPHOME_OTA_UDP +#endif + namespace esphome { /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. @@ -65,6 +84,69 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); + +#ifdef ESPHOME_OTA_UDP + // UDP transport for the data phase; wire format in ota_esphome_udp.cpp + static constexpr size_t UDP_OUT_SIZE = 64; // our unacked responses: at most three encrypted ones + // Messages the device keeps unread: like TCP's 4 segment window, so no more memory than TCP holds + static constexpr uint8_t UDP_WINDOW = 4; +#ifdef ESPHOME_OTA_UDP_LWIP + using UdpSlot = struct pbuf *; +#else + using UdpSlot = struct OtaUdpDatagram *; +#endif + // Lives on handle_data_'s stack; the receive callback shares it under the lwIP lock + struct UdpLink { + ~UdpLink(); + void release(); // closes the socket and frees what is queued +#ifdef ESPHOME_OTA_UDP_LWIP + struct udp_pcb *pcb{nullptr}; + ip_addr_t peer_ip; +#else + std::unique_ptr sock; + struct sockaddr_storage peer; // the TCP peer; its port is replaced by peer_port + socklen_t peer_len{0}; +#endif + uint16_t peer_port{0}; // learned from the client's datagrams + // The session token, our answer to the client: top bit clear, so never an error code; all zero declines + uint8_t reply[4]{}; + UdpSlot queue[UDP_WINDOW]{}; // slot seq % UDP_WINDOW for seq in [consumed, consumed + UDP_WINDOW) + uint16_t received{0}; // every seq before it is here + uint16_t consumed{0}; // the message being read + uint16_t read_offset{0}; // payload bytes of it already read + uint16_t out_base{0}; // stream offset of out[0] + uint8_t out_len{0}; + bool committed{false}; // the client heard us over UDP + uint8_t out[UDP_OUT_SIZE]; + }; + // Fills link.reply on success; leaves it all zero (declined) otherwise + void udp_open_(UdpLink &link); + // Waits for the client to commit to UDP or fall back to TCP; false on failure + bool udp_start_(); + // Like a blocking socket read with SO_RCVTIMEO: up to len bytes, or -1 with EWOULDBLOCK + ssize_t udp_read_(uint8_t *buf, size_t len); + bool udp_send_(const uint8_t *data, size_t len); + // In a UDP session, keeps answering until the client has our responses (an error code), briefly + void udp_linger_(); + // A datagram from the client's IP; caller holds the lwIP lock (or runs in the receive callback) + static void udp_on_datagram(UdpLink &link, UdpSlot p, uint16_t port); + // Caller holds the lwIP lock (or runs in the receive callback) + static void udp_send_ack(UdpLink &link, uint16_t prompted_by); +#ifdef ESPHOME_OTA_UDP_LWIP + static void udp_recv_cb(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, uint16_t port); + void udp_poll_() {} +#else + void udp_poll_(); // hands what the socket received to udp_on_datagram +#endif +#endif + // Data phase only (udp_ shares storage with the handshake's connect time): TCP or, once committed, the UDP stream + inline ssize_t transport_read_(uint8_t *buf, size_t len) { +#ifdef ESPHOME_OTA_UDP + if (this->udp_ != nullptr) + return this->udp_read_(buf, len); +#endif + return this->client_->read(buf, len); + } #ifdef USE_OTA_PASSWORD static constexpr size_t SHA256_HEX_SIZE = 64; // SHA256 hash as hex string (32 bytes * 2) bool handle_auth_send_(); @@ -75,7 +157,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); - inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); } #ifdef USE_OTA_ENCRYPTION // Heap-allocated only while an encrypted OTA session is active. @@ -105,18 +186,21 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { ssize_t noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext); bool noise_readall_(uint8_t *buf, size_t len); ssize_t noise_read_data_(uint8_t *buf, size_t capacity); - bool noise_write_byte_(uint8_t byte); + static constexpr size_t NOISE_MAX_RESPONSE = 4; // the UDP token + bool noise_write_(const uint8_t *data, size_t len); #endif // USE_OTA_ENCRYPTION // Data-phase I/O dispatch: through the noise transport when a session is // active, straight to the socket otherwise. - inline bool data_write_byte_(uint8_t byte) { + // len is at most NOISE_MAX_RESPONSE + inline bool data_write_(const uint8_t *data, size_t len) { #ifdef USE_OTA_ENCRYPTION if (this->noise_ != nullptr) - return this->noise_write_byte_(byte); + return this->noise_write_(data, len); #endif - return this->write_byte_(byte); + return this->writeall_(data, len); } + inline bool data_write_byte_(uint8_t byte) { return this->data_write_(&byte, 1); } // When encrypted, buf must have room for len + noise::MAC_SIZE bytes. inline bool data_readall_(uint8_t *buf, size_t len) { #ifdef USE_OTA_ENCRYPTION @@ -200,7 +284,21 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::unique_ptr client_; ota::OTABackendPtr backend_; +#ifdef ESPHOME_OTA_UDP_LWIP + union { + uint32_t client_connect_time_{0}; // handshake states + UdpLink *udp_; // data phase (never both), points into handle_data_'s stack + }; + static_assert(sizeof(UdpLink *) == sizeof(uint32_t), "clearing client_connect_time_ must clear udp_"); +#else uint32_t client_connect_time_{0}; +#ifdef ESPHOME_OTA_UDP + UdpLink *udp_{nullptr}; // data phase, points into handle_data_'s stack +#endif +#endif +#if defined(ESPHOME_OTA_UDP) && defined(USE_OTA_ENCRYPTION) + static_assert(sizeof(UdpLink::reply) <= NOISE_MAX_RESPONSE, "the token goes out through noise_write_"); +#endif static constexpr size_t HANDSHAKE_BUF_SIZE = 5; // Buffer size for OTA data transfer. The upload client derives its maximum // encrypted frame plaintext from this (espota2.NOISE_MAX_PLAINTEXT is this diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index a271ef9d39d..02c19ed7b7d 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -266,13 +266,15 @@ ssize_t ESPHomeOTAComponent::noise_read_data_(uint8_t *buf, size_t capacity) { return this->noise_read_frame_blocking_(buf, noise::MAC_SIZE + 1, max_ciphertext); } -/// Blocking write of one response byte as an encrypted frame. -bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) { - uint8_t frame[noise::FRAME_HEADER_SIZE + 1 + noise::MAC_SIZE]; - frame[noise::FRAME_HEADER_SIZE] = byte; +/// Blocking write of a short response (at most NOISE_MAX_RESPONSE bytes) as one encrypted frame. +bool ESPHomeOTAComponent::noise_write_(const uint8_t *data, size_t len) { + uint8_t frame[noise::FRAME_HEADER_SIZE + NOISE_MAX_RESPONSE + noise::MAC_SIZE]; + if (len > NOISE_MAX_RESPONSE) + return false; + memcpy(frame + noise::FRAME_HEADER_SIZE, data, len); NoiseBuffer mbuf; noise_buffer_init(mbuf); - noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE); + noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, len, len + noise::MAC_SIZE); int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf); if (err != 0) { ESP_LOGW(TAG, "Encrypt: %d", err); diff --git a/esphome/components/esphome/ota/ota_esphome_udp.cpp b/esphome/components/esphome/ota/ota_esphome_udp.cpp new file mode 100644 index 00000000000..6044284755c --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_udp.cpp @@ -0,0 +1,375 @@ +#include "ota_esphome.h" +#if defined(USE_OTA) && defined(ESPHOME_OTA_UDP) + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#ifdef ESPHOME_OTA_UDP_LWIP +#include "lwip/inet.h" +#ifdef USE_LIBRETINY +#include "lwip/tcpip.h" +#endif +#endif + +#include +#include +#include + +/* + * UDP data phase: the same byte stream as TCP, cut into numbered datagrams. + * client -> device [type | COMMITTED][token u32][seq u16][device bytes received u16][payload] + * device -> client [ACK][token u32][received u16][seq that prompted it u16][stream offset u16][window u8][our bytes] + * The lwIP callback acks each datagram on arrival, so flash writes never look like loss, and keeps messages in its + * UDP_WINDOW slots until the OTA loop reads them. "window": free slots in the high nibble, and in the low nibble bit k + * set when received + k is already here (like TCP SACK). + */ + +namespace esphome { + +ESPHOME_LOG_TAG(TAG, "esphome.ota"); + +// PING (0x02) and PROBE (0x03) only prompt an ACK +static constexpr uint8_t UDP_MSG_DATA = 0x01; +static constexpr uint8_t UDP_FLAG_COMMITTED = 0x80; +static constexpr uint8_t UDP_MSG_ACK = 0x40; +static constexpr size_t UDP_HEADER_SIZE = 9; +static constexpr size_t UDP_ACK_HEADER_SIZE = 12; +// The client's 60 s wait for the token over a lossy TCP link, plus its 15 s probe +static constexpr uint32_t UDP_COMMIT_TIMEOUT = 80000; +// Like the TCP socket's SO_RCVTIMEO in handle_data_, so readall_ treats both alike +static constexpr uint32_t UDP_READ_WAIT_MS = 2000; +// How often the commit wait looks at the UDP link between reads of the TCP fallback byte +static constexpr uint32_t UDP_COMMIT_POLL_MS = 100; +static constexpr uint32_t UDP_LINGER_MS = 3000; + +#ifdef ESPHOME_OTA_UDP_LWIP +#ifdef USE_LIBRETINY +// LibreTiny's LwIPLock is a no-op; take the core lock directly (the header drops UDP without one) +struct UdpLock { + UdpLock() { LOCK_TCPIP_CORE(); } + ~UdpLock() { UNLOCK_TCPIP_CORE(); } +}; +#else +using UdpLock = LwIPLock; +#endif +static uint16_t slot_len(struct pbuf *p) { return p->tot_len; } +static uint16_t slot_copy(struct pbuf *p, void *dst, uint16_t len, uint16_t offset) { + return pbuf_copy_partial(p, dst, len, offset); +} +static void slot_free(struct pbuf *p) { pbuf_free(p); } +#else +// The host has no lwIP thread: the OTA loop drains the socket itself. Empty bodies, not = default, so clang-tidy +// does not flag the guards as unused +struct UdpLock { + UdpLock() {} + ~UdpLock() {} +}; +struct OtaUdpDatagram { + uint16_t len; + uint8_t data[1500]; +}; +static uint16_t slot_len(OtaUdpDatagram *p) { return p->len; } +static uint16_t slot_copy(OtaUdpDatagram *p, void *dst, uint16_t len, uint16_t offset) { + const uint16_t n = std::min(len, p->len - offset); + memcpy(dst, p->data + offset, n); + return n; +} +static void slot_free(OtaUdpDatagram *p) { delete p; } +#endif + +static void put_u16(uint8_t *at, uint16_t value) { + at[0] = value >> 8; + at[1] = value & 0xFF; +} + +ESPHomeOTAComponent::UdpLink::~UdpLink() { this->release(); } + +void ESPHomeOTAComponent::UdpLink::release() { + UdpLock lock; +#ifdef ESPHOME_OTA_UDP_LWIP + if (this->pcb != nullptr) + udp_remove(this->pcb); + this->pcb = nullptr; +#else + this->sock = nullptr; +#endif + for (auto &p : this->queue) { + if (p != nullptr) + slot_free(p); + p = nullptr; + } +} + +void ESPHomeOTAComponent::udp_send_ack(UdpLink &link, uint16_t prompted_by) { + const uint16_t len = UDP_ACK_HEADER_SIZE + link.out_len; +#ifdef ESPHOME_OTA_UDP_LWIP + struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM); + if (p == nullptr) + return; // Like a lost ACK: the client asks again + auto *ack = static_cast(p->payload); +#else + uint8_t ack[UDP_ACK_HEADER_SIZE + UDP_OUT_SIZE]; +#endif + ack[0] = UDP_MSG_ACK; + memcpy(ack + 1, link.reply, 4); + put_u16(ack + 5, link.received); + put_u16(ack + 7, prompted_by); + put_u16(ack + 9, link.out_base); + uint8_t window = (UDP_WINDOW - static_cast(link.received - link.consumed)) << 4; + for (uint8_t k = 1; k < UDP_WINDOW; k++) { + if (link.queue[(link.received + k) % UDP_WINDOW] != nullptr) + window |= 1 << k; + } + ack[11] = window; + memcpy(ack + UDP_ACK_HEADER_SIZE, link.out, link.out_len); + // Best effort: a lost ACK is repeated for the client's next datagram +#ifdef ESPHOME_OTA_UDP_LWIP + udp_sendto(link.pcb, p, &link.peer_ip, link.peer_port); + pbuf_free(p); +#else + struct sockaddr_storage to = link.peer; + if (to.ss_family == AF_INET6) { + reinterpret_cast(&to)->sin6_port = htons(link.peer_port); + } else { + reinterpret_cast(&to)->sin_port = htons(link.peer_port); + } + link.sock->sendto(ack, len, 0, reinterpret_cast(&to), link.peer_len); +#endif +} + +void ESPHomeOTAComponent::udp_on_datagram(UdpLink &link, UdpSlot p, uint16_t port) { + uint8_t hdr[UDP_HEADER_SIZE]; + if (slot_copy(p, hdr, UDP_HEADER_SIZE, 0) != UDP_HEADER_SIZE || memcmp(hdr + 1, link.reply, 4) != 0) { + slot_free(p); + return; + } + // The token proves the sender; answer the port it arrived from, which NAT may have rewritten + link.peer_port = port; + link.committed |= (hdr[0] & UDP_FLAG_COMMITTED) != 0; + // Drop the responses the client already has + const uint16_t acked = encode_uint16(hdr[7], hdr[8]) - link.out_base; + if (acked != 0 && acked <= link.out_len) { + link.out_len -= acked; + memmove(link.out, link.out + acked, link.out_len); + link.out_base += acked; + } + const uint16_t seq = encode_uint16(hdr[5], hdr[6]); + UdpSlot &slot = link.queue[seq % UDP_WINDOW]; + if ((hdr[0] & ~UDP_FLAG_COMMITTED) == UDP_MSG_DATA && slot_len(p) > UDP_HEADER_SIZE && + static_cast(seq - link.consumed) < UDP_WINDOW && slot == nullptr) { + slot = p; + while (static_cast(link.received - link.consumed) < UDP_WINDOW && + link.queue[link.received % UDP_WINDOW] != nullptr) + link.received++; + } else { + slot_free(p); + } + udp_send_ack(link, seq); +} + +#ifdef ESPHOME_OTA_UDP_LWIP +// lwIP context: the tcpip thread (ESP32, LibreTiny), SYS (ESP8266) or an IRQ (RP2040) +void ESPHomeOTAComponent::udp_recv_cb(void *arg, struct udp_pcb * /*pcb*/, struct pbuf *p, const ip_addr_t *addr, + uint16_t port) { + auto &link = *static_cast(arg); + if (!ip_addr_cmp(addr, &link.peer_ip)) { + pbuf_free(p); + return; + } + udp_on_datagram(link, p, port); +} +#else +static bool same_ip(const struct sockaddr_storage &a, const struct sockaddr_storage &b) { + uint32_t ipv4_a, ipv4_b; + const bool v4_a = socket::sockaddr_to_ipv4(reinterpret_cast(&a), &ipv4_a); + const bool v4_b = socket::sockaddr_to_ipv4(reinterpret_cast(&b), &ipv4_b); + if (v4_a || v4_b) + return v4_a && v4_b && ipv4_a == ipv4_b; + return a.ss_family == AF_INET6 && b.ss_family == AF_INET6 && + memcmp(&reinterpret_cast(&a)->sin6_addr, + &reinterpret_cast(&b)->sin6_addr, sizeof(in6_addr)) == 0; +} + +void ESPHomeOTAComponent::udp_poll_() { + UdpLink &link = *this->udp_; + for (;;) { + uint8_t buf[sizeof(OtaUdpDatagram::data)]; + struct sockaddr_storage from; + socklen_t from_len = sizeof(from); + const ssize_t len = link.sock->recvfrom(buf, sizeof(buf), reinterpret_cast(&from), &from_len); + if (len < 0) + return; + if (!same_ip(from, link.peer)) + continue; + auto *datagram = new OtaUdpDatagram; // NOLINT(cppcoreguidelines-owning-memory) + datagram->len = len; + memcpy(datagram->data, buf, len); + const uint16_t port = from.ss_family == AF_INET6 ? ntohs(reinterpret_cast(&from)->sin6_port) + : ntohs(reinterpret_cast(&from)->sin_port); + udp_on_datagram(link, datagram, port); + } +} +#endif + +void ESPHomeOTAComponent::udp_open_(UdpLink &link) { + struct sockaddr_storage peer; + socklen_t peer_len = sizeof(peer); + if (this->client_->getpeername(reinterpret_cast(&peer), &peer_len) != 0) + return; + uint8_t token[4]; + if (!random_bytes(token, sizeof(token))) + return; +#ifdef ESPHOME_OTA_UDP_LWIP + uint32_t ipv4; + if (socket::sockaddr_to_ipv4(reinterpret_cast(&peer), &ipv4)) { + ip_addr_set_ip4_u32(&link.peer_ip, ipv4); + } else { +#if USE_NETWORK_IPV6 && LWIP_IPV6 + inet6_addr_to_ip6addr(ip_2_ip6(&link.peer_ip), &reinterpret_cast(&peer)->sin6_addr); + IP_SET_TYPE_VAL(link.peer_ip, IPADDR_TYPE_V6); +#else + return; +#endif + } + UdpLock lock; +#if LWIP_IPV6 + link.pcb = udp_new_ip_type(IPADDR_TYPE_ANY); +#else + link.pcb = udp_new(); +#endif + // On failure the reply stays all zero and ~UdpLink removes the pcb + if (link.pcb == nullptr || udp_bind(link.pcb, IP_ANY_TYPE, this->port_) != ERR_OK) + return; + udp_recv(link.pcb, udp_recv_cb, &link); +#else + link.peer = peer; + link.peer_len = peer_len; + // Same domain as the listener, so the peer address from getpeername fits + link.sock = socket::socket_ip(SOCK_DGRAM, IPPROTO_UDP); + struct sockaddr_storage local; + const socklen_t local_len = + socket::set_sockaddr_any(reinterpret_cast(&local), sizeof(local), this->port_); + if (link.sock == nullptr || local_len == 0 || + link.sock->bind(reinterpret_cast(&local), local_len) != 0 || + link.sock->setblocking(false) != 0) + return; +#endif + // Below the error codes (0x80 and up) the client checks the first byte against, and never all zero + token[0] = (token[0] & 0x7F) | 0x01; + memcpy(link.reply, token, sizeof(token)); +} + +bool ESPHomeOTAComponent::udp_start_() { + // TCP now only carries a fallback byte; short read timeouts let the UDP link be checked in between + struct timeval tv { + 0, UDP_COMMIT_POLL_MS * 1000 + }; + this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + const uint32_t start = millis(); + while (millis() - start < UDP_COMMIT_TIMEOUT) { + this->udp_poll_(); + bool committed; + { + UdpLock lock; + committed = this->udp_->committed; + } + if (committed) { + // The client heard us over UDP, so TCP is done. Outside the lock: closing a socket waits on the tcpip thread + this->client_ = nullptr; + return true; + } + // A raw byte outside any Noise frame: the client heard nothing over UDP. Forging it only forces the ordinary + // TCP path, which dropping our datagrams forces anyway + uint8_t fallback; + const ssize_t read = this->client_->read(&fallback, 1); + if (read == 1) { + this->udp_->release(); + this->udp_ = nullptr; + tv = {UDP_READ_WAIT_MS / 1000, 0}; + this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + return true; + } + if (read == 0) { + this->remote_closed_ = true; + return false; + } + if (!this->would_block_(errno)) + return false; + App.feed_wdt(); + } + return false; +} + +ssize_t ESPHomeOTAComponent::udp_read_(uint8_t *buf, size_t len) { + UdpLink &link = *this->udp_; + const uint32_t start = millis(); + size_t got = 0; + for (;;) { + this->udp_poll_(); + UdpSlot p; + { + UdpLock lock; + p = link.consumed != link.received ? link.queue[link.consumed % UDP_WINDOW] : nullptr; + } + if (p == nullptr) { + if (got != 0) + return got; + if (millis() - start >= UDP_READ_WAIT_MS) + break; + this->yield_and_feed_watchdog_(); + continue; + } + // The callback never touches the slot being read, so the copy runs without the lock + const uint16_t offset = UDP_HEADER_SIZE + link.read_offset; + const uint16_t n = slot_copy(p, buf + got, std::min(len - got, slot_len(p) - offset), offset); + got += n; + link.read_offset += n; + if (UDP_HEADER_SIZE + link.read_offset >= slot_len(p)) { + UdpLock lock; + slot_free(p); + link.queue[link.consumed % UDP_WINDOW] = nullptr; + // A full window just opened: tell the client, which is holding back + const bool was_full = static_cast(link.received - link.consumed) == UDP_WINDOW; + link.consumed++; + link.read_offset = 0; + if (was_full) + udp_send_ack(link, link.received - 1); + } + if (got == len) + return got; + } + errno = EWOULDBLOCK; + return -1; +} + +bool ESPHomeOTAComponent::udp_send_(const uint8_t *data, size_t len) { + UdpLink &link = *this->udp_; + UdpLock lock; + if (link.out_len + len > UDP_OUT_SIZE) + return false; + memcpy(link.out + link.out_len, data, len); + link.out_len += len; + // Prompted by nothing new, so the client takes no round trip sample from it + udp_send_ack(link, link.received - 1); + return true; +} + +void ESPHomeOTAComponent::udp_linger_() { + if (this->udp_ == nullptr) + return; + const uint32_t start = millis(); + while (millis() - start < UDP_LINGER_MS) { + this->udp_poll_(); + { + UdpLock lock; + if (this->udp_->out_len == 0) + return; + } + this->yield_and_feed_watchdog_(); + } +} + +} // namespace esphome +#endif // USE_OTA && ESPHOME_OTA_UDP diff --git a/esphome/espota2.py b/esphome/espota2.py index 14e163676d1..aaef178753a 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -70,11 +70,13 @@ CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02 CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04 CLIENT_FEATURE_SUPPORTS_NOISE = 0x08 CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10 +CLIENT_FEATURE_SUPPORTS_UDP = 0x20 SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01 SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 SERVER_FEATURE_SUPPORTS_NOISE = 0x04 # Binding once offered: the device then expects the image size and a deflate stream SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08 +SERVER_FEATURE_SUPPORTS_UDP = 0x10 # Wire constant: the deflate bit promises a 4 KB window (OTA_INFLATE_WINDOW_SIZE) DEFLATE_WINDOW_BITS = 12 @@ -103,17 +105,64 @@ COMPRESS_LEVEL = 9 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 # Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time -# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# to clean up a half-open connection (its handshake watchdog runs at 60s) before it # accepts a new one, so wait between attempts instead of failing the upload outright. # Every resolved address is tried once, and this many extra attempts are shared # across the addresses on top of that. -EXTRA_UPLOAD_ATTEMPTS = 2 +EXTRA_UPLOAD_ATTEMPTS = 4 +# Once a connection has reached the device, a lossy link (not a dead device) is +# the likely cause, so keep trying like an API reconnect would +EXTRA_UPLOAD_ATTEMPTS_REACHED = 8 UPLOAD_RETRY_DELAY = 5.0 +# A SYN getting through is the cheap step; an unreachable address still fails fast +CONNECT_TIMEOUT = 20.0 +# Handshake waits; as patient as an API connect on a lossy link, and matching the +# device's OTA_SOCKET_TIMEOUT_HANDSHAKE +SETUP_TIMEOUT = 60.0 # Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA # (105 s) so a stalled session is gone before a retry, and long enough for lwIP # to get a lost chunk ack through after the retransmit run seen in practice DATA_PHASE_TIMEOUT = 160.0 +# UDP data phase; wire format in ota_esphome_udp.cpp +UDP_MSG_DATA = 0x01 +UDP_MSG_PING = 0x02 +UDP_MSG_PROBE = 0x03 +UDP_FLAG_COMMITTED = 0x80 +UDP_MSG_ACK = 0x40 +UDP_HEADER_SIZE = 9 +UDP_ACK_HEADER_SIZE = 12 +# Stream bytes per datagram, within one Ethernet frame +UDP_MAX_PAYLOAD = 1400 +# Messages the device queues unconsumed (UDP_WINDOW in ota_esphome.h); as many +# as TCP's window holds, so UDP costs the device no more memory than TCP +UDP_WINDOW = 4 +# Probe this long before falling back to TCP, enough to ride out a radio busy +# with BLE; the device waits 80 s +UDP_PROBE_TIMEOUT = 15.0 +UDP_PROBE_INTERVAL = 0.1 +# Resend timeout bounds around srtt + 4 * rttvar; random loss never grows it +UDP_RESEND_INITIAL = 0.5 +UDP_RESEND_MIN = 0.2 +UDP_RESEND_MAX = 2.0 +# Acks still asking for the head after later messages arrived; resend it once +UDP_DUP_ACKS = 2 +# Progress acks in a row with no round trip to time: the link is slower than +# the resend timer, so back it off (random loss still yields samples) +UDP_UNTIMED_ACKS = 8 +# Refusals in a row with no ACK between: the device's UDP port is closed +UDP_REFUSALS = 3 +UDP_FALLBACK = b"\x00" +# The UDP format this client speaks; devices decline values they do not know +UDP_REQUEST = b"\x01" +# A plain handshake exchange slower than this hid a retransmit (the device's +# lwIP waits about a second before resending); UDP is used from then on +UDP_SLOW_EXCHANGE = 0.75 +# getsockopt(IPPROTO_TCP) option, size, offset and format of the retransmit +# count: tcp_info.tcpi_total_retrans (Linux), tcp_connection_info. +# tcpi_txretransmitbytes (macOS) +_TCP_RETRANS_OPTION = {"linux": (11, 104, 100, "I"), "darwin": (0x106, 80, 72, "Q")} + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -511,6 +560,10 @@ class NoiseSocketWrapper: self._decrypt = None self._buffer = b"" + def use_transport(self, transport: UdpChannel) -> None: + """Continue the session over another byte stream.""" + self._sock = transport + # Only harmless socket controls pass through; byte-moving methods are # deliberately absent so plaintext cannot leak past the transport. def settimeout(self, timeout: float | None) -> None: @@ -613,19 +666,316 @@ class NoiseSocketWrapper: return data +class _UdpMessage: + """One datagram of the UDP stream and its send history.""" + + __slots__ = ("held", "last_send", "payload", "resend", "sends", "seq") + + def __init__(self, seq: int, payload: bytes) -> None: + self.seq = seq + self.payload = payload + self.last_send = 0.0 + self.sends = 0 + self.resend = False # duplicate acks asked for it + self.held = False # the device keeps it, waiting for an earlier one + + +class UdpChannel: + """Byte stream over numbered UDP datagrams, standing in for the socket. + + The device acks every datagram on arrival and says how many it has room + for; each is resent after TCP's srtt + 4 * rttvar, so loss slows the + transfer instead of ending it. It backs off only while acks come back + untimed, i.e. the link is slower than the timer, never on random loss. + """ + + def __init__(self, sock: socket.socket, token: bytes) -> None: + self._sock = sock + self._token = token + self._timeout = DATA_PHASE_TIMEOUT + self._next_seq = 0 + self._queue: list[_UdpMessage] = [] # not yet received, oldest first + self._rx = bytearray() + self._device_bytes = 0 + self._committed = False + self._srtt: float | None = None + self._rttvar = 0.0 + # Doubles while acks come back untimed, so a link slower than the + # timer still yields messages sent once to time (Karn, RFC 6298) + self._backoff = 1 + self._untimed = 0 + self._dup_acks = 0 + self._free = UDP_WINDOW # the device's empty slots, as of its last ACK + self._last_ping = 0.0 + self._last_progress = time.monotonic() + self._sends = 0 + # Last socket error, e.g. refused once the device's socket is gone + self._last_error: OSError | None = None + self._refusals = 0 + + def settimeout(self, timeout: float) -> None: + self._timeout = timeout + + def setsockopt(self, level: int, optname: int, value: int) -> None: + """TCP tuning does not apply.""" + + def close(self) -> None: + _LOGGER.debug( + "UDP: %d messages in %d datagrams, smoothed RTT %.0f ms", + self._next_seq, + self._sends, + (self._srtt or 0) * 1000, + ) + self._sock.close() + + def probe(self) -> bool: + """True once the device answers over UDP within UDP_PROBE_TIMEOUT.""" + deadline = time.monotonic() + UDP_PROBE_TIMEOUT + try: + while (now := time.monotonic()) < deadline: + self._send(UDP_MSG_PROBE, 0, b"") + self._receive(min(UDP_PROBE_INTERVAL, deadline - now)) + if self._committed: + return True + except OTANetworkError: + pass # rejected, e.g. by a firewall + return False + + def sendall(self, data: bytes) -> None: + for offset in range(0, len(data), UDP_MAX_PAYLOAD): + self._queue.append( + _UdpMessage(self._next_seq, data[offset : offset + UDP_MAX_PAYLOAD]) + ) + self._next_seq = (self._next_seq + 1) & 0xFFFF + # Return once everything is at least in flight, or the device spoke (an error) + while len(self._queue) > UDP_WINDOW and not self._rx: + self._pump() + + def pending(self) -> bool: + """Whether the device sent bytes nobody has read yet.""" + return bool(self._rx) + + def recv(self, amount: int) -> bytes: + while not self._rx: + self._pump() + data = bytes(self._rx[:amount]) + del self._rx[:amount] + return data + + def flush(self, timeout: float) -> None: + """Best effort: wait until the device has every message.""" + deadline = time.monotonic() + timeout + with contextlib.suppress(OTANetworkError): + while self._queue and time.monotonic() < deadline: + self._pump() + + def _rto(self) -> float: + if self._srtt is None: + rto = UDP_RESEND_INITIAL + else: + rto = max(self._srtt + 4 * self._rttvar, UDP_RESEND_MIN) + return min(rto * self._backoff, UDP_RESEND_MAX) + + def _send(self, msg_type: int, seq: int, payload: bytes) -> None: + flags = UDP_FLAG_COMMITTED if self._committed else 0 + header = ( + bytes([msg_type | flags]) + + self._token + + seq.to_bytes(2, "big") + + (self._device_bytes & 0xFFFF).to_bytes(2, "big") + ) + self._sends += 1 + try: + self._sock.send(header + payload) + except OSError as err: + # A full buffer or ICMP error is just more loss, but worth reporting + self._socket_error(err) + + def _pump(self) -> None: + now = time.monotonic() + if now - self._last_progress > self._timeout: + reason = f" (last error: {self._last_error})" if self._last_error else "" + raise OTANetworkError( + f"no progress over UDP for {self._timeout:.0f}s{reason}" + ) + rto = self._rto() + wake = now + rto + # At least the head, which doubles as the probe while the device is busy + in_flight = self._queue[: max(self._free, 1)] + for message in in_flight: + if message.held: + continue + if message.sends == 0 or message.resend or now - message.last_send >= rto: + message.last_send = now + message.sends += 1 + message.resend = False + self._send(UDP_MSG_DATA, message.seq, message.payload) + wake = min(wake, message.last_send + rto) + if all(message.held for message in in_flight): + # Nothing to send: ask for the device's responses + if now - self._last_ping >= rto: + self._last_ping = now + self._send(UDP_MSG_PING, 0, b"") + wake = self._last_ping + rto + self._receive(wake - now) + + def _receive(self, wait: float) -> None: + import select + + # Wait for the first datagram, then drain whatever else arrived + while select.select([self._sock], [], [], max(wait, 0))[0]: + wait = 0 + try: + data = self._sock.recv(2048) + except OSError as err: + self._socket_error(err) + continue + self._handle_ack(data) + + def _socket_error(self, err: OSError) -> None: + if self._last_error is None or err.errno != self._last_error.errno: + _LOGGER.debug("UDP socket error: %s", err) + self._last_error = err + # Windows reports the ICMP port unreachable as a reset + if isinstance(err, (ConnectionRefusedError, ConnectionResetError)): + self._refusals += 1 + if self._refusals >= UDP_REFUSALS: + raise OTANetworkError(f"device closed its UDP port: {err}") + + def _sample_rtt(self, sample: float) -> None: + # RFC 6298 smoothing + self._backoff = 1 + self._untimed = 0 + if self._srtt is None: + self._srtt, self._rttvar = sample, sample / 2 + else: + self._rttvar = 0.75 * self._rttvar + 0.25 * abs(self._srtt - sample) + self._srtt = 0.875 * self._srtt + 0.125 * sample + + def _handle_ack(self, data: bytes) -> None: + if ( + len(data) < UDP_ACK_HEADER_SIZE + or data[0] != UDP_MSG_ACK + or data[1:5] != self._token + ): + return + now = time.monotonic() + self._committed = True + self._refusals = 0 + received = int.from_bytes(data[5:7], "big") + prompted = int.from_bytes(data[7:9], "big") + self._free = data[11] >> 4 + if self._queue: + head = self._queue[0].seq + index = (prompted - head) & 0xFFFF + # This datagram's own round trip; only from messages sent once (Karn) + timed = index < len(self._queue) and self._queue[index].sends == 1 + if timed: + self._sample_rtt(now - self._queue[index].last_send) + done = (received - head) & 0xFFFF + if 0 < done <= len(self._queue): + del self._queue[:done] + self._last_progress = now + self._dup_acks = 0 + if not timed: + self._untimed += 1 + if self._untimed >= UDP_UNTIMED_ACKS: + self._untimed = 0 + if self._rto() < UDP_RESEND_MAX: + self._backoff *= 2 + elif done == 0 and self._queue[0].sends: + # A later message arrived while the head is missing; resend it once + self._dup_acks += 1 + if self._dup_acks == UDP_DUP_ACKS: + self._queue[0].resend = True + # Rebuilt from every ACK, so a reordered one cannot leave a stale mark + for k, message in enumerate(self._queue[:UDP_WINDOW]): + message.held = (received + k - message.seq) & 0xFFFF == 0 and bool( + data[11] & (1 << k) + ) + skip = (self._device_bytes - int.from_bytes(data[9:11], "big")) & 0xFFFF + fresh = data[UDP_ACK_HEADER_SIZE + skip :] + if skip <= len(data) - UDP_ACK_HEADER_SIZE and fresh: + self._rx += fresh + self._device_bytes += len(fresh) + self._last_progress = now + + +def _tcp_retransmits(sock: socket.socket) -> int | None: + """Segments this side retransmitted, where the OS reports it.""" + import struct + import sys + + if (option := _TCP_RETRANS_OPTION.get(sys.platform)) is None: + return None + optname, size, offset, fmt = option + try: + info = sock.getsockopt(socket.IPPROTO_TCP, optname, size) + except (OSError, TypeError): + return None + if len(info) < offset + struct.calcsize(fmt): + return None + return struct.unpack_from(fmt, info, offset)[0] + + +def _link_lossy( + tcp_sock: socket.socket, slowest_exchange: float, prefer_udp: bool +) -> bool: + """Whether the handshake showed loss, so the data phase should use UDP.""" + return ( + prefer_udp + or slowest_exchange > UDP_SLOW_EXCHANGE + or bool(_tcp_retransmits(tcp_sock)) + ) + + +def _start_udp( + sock: socket.socket | NoiseSocketWrapper, tcp_sock: socket.socket, lossy: bool +) -> UdpChannel | None: + """Move the data phase to UDP; None keeps it on TCP.""" + # A clean link asks to stay on TCP + send_check(sock, UDP_REQUEST if lossy else UDP_FALLBACK, "UDP request") + token = receive_exactly(sock, 4, "UDP token", None, decode=False) + if not lossy: + _LOGGER.debug("Link looks clean, staying on TCP") + return None + if token == bytes(4): + _LOGGER.debug("Device declined UDP") + return None + udp_sock = socket.socket(tcp_sock.family, socket.SOCK_DGRAM) + try: + udp_sock.connect(tcp_sock.getpeername()) + except OSError as err: + udp_sock.close() + _LOGGER.info("UDP unavailable (%s), using TCP", err) + else: + channel = UdpChannel(udp_sock, token) + if channel.probe(): + _LOGGER.info("Using UDP") + return channel + channel.close() + _LOGGER.info("UDP blocked, using TCP") + # Raw, outside any Noise frame: forging it only forces the TCP path + send_check(tcp_sock, UDP_FALLBACK, "UDP fallback") + return None + + def _negotiate_session( sock: socket.socket, noise_psk: str | None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, -) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool]: +) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool, float]: """Negotiate through the Noise handshake; returns the socket to go on with - (wrapped once encrypted), version, features, extended.""" + (wrapped once encrypted), version, features, extended and the slowest + plaintext exchange in seconds.""" # Enable nodelay, we need it for phase 1 sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + exchange_start = time.monotonic() send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) + slowest_exchange = time.monotonic() - exchange_start _LOGGER.debug("Device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: @@ -639,9 +989,11 @@ def _negotiate_session( | CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | CLIENT_FEATURE_SUPPORTS_DEFLATE + | CLIENT_FEATURE_SUPPORTS_UDP ) if noise_psk: features_to_send |= CLIENT_FEATURE_SUPPORTS_NOISE + exchange_start = time.monotonic() send_check(sock, features_to_send, "features") features = receive_exactly( sock, @@ -649,6 +1001,7 @@ def _negotiate_session( "features", None, # Accept any response )[0] + slowest_exchange = max(slowest_exchange, time.monotonic() - exchange_start) extended_proto = False if features == RESPONSE_FEATURE_FLAGS: @@ -705,7 +1058,7 @@ def _negotiate_session( _LOGGER.debug("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) - return sock, version, features, extended_proto + return sock, version, features, extended_proto, slowest_exchange def perform_ota( @@ -718,6 +1071,7 @@ def perform_ota( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, on_upload_start: Callable[[], None] | None = None, + prefer_udp: bool = False, ) -> None: # Validate up front; an out-of-range value would only surface as a # ValueError deep inside send_check, bypassing OTAError handling @@ -740,7 +1094,9 @@ def perform_ota( file_size = len(file_contents) _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) - sock, version, features, extended_proto = _negotiate_session( + # sock may become a Noise wrapper or a UDP channel; UDP needs the peer + tcp_sock = sock + sock, version, features, extended_proto, slowest_exchange = _negotiate_session( sock, noise_psk, plaintext_fallback, allow_plaintext_upload ) _LOGGER.info("Connection established; device supports OTA version %s", version) @@ -832,117 +1188,153 @@ def perform_ota( if on_upload_start is not None: on_upload_start() # the device may commit anything sent from here + udp: UdpChannel | None = None + # Still under SETUP_TIMEOUT, which the device's commit wait is sized for + if extended_proto and features & SERVER_FEATURE_SUPPORTS_UDP: + udp = _start_udp( + sock, tcp_sock, _link_lossy(tcp_sock, slowest_exchange, prefer_udp) + ) + if udp is not None: + # The device drops TCP once it hears the committed channel + if isinstance(sock, NoiseSocketWrapper): + sock.use_transport(udp) + else: + sock = udp + sock.settimeout(DATA_PHASE_TIMEOUT) - - if extended_proto: - send_check(sock, ota_type, "ota type") - - upload_size = len(upload_contents) - # The device erases flash between receiving the size and acking the - # prepare, so this window shows the erase cost (near zero when the - # device erases lazily during the upload) - prepare_start = time.perf_counter() - send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size") - if deflate: - # Own frame: an encrypted session carries one field per frame - send_check(sock, file_size.to_bytes(SIZE_FIELD_BYTES, "big"), "image size") - receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK) - prepare_duration = time.perf_counter() - prepare_start - _LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration) - - # The device hashes what it writes: the inflated image, else the received bytes - upload_md5 = hashlib.md5(file_contents if deflate else upload_contents).hexdigest() - _LOGGER.debug("MD5 of upload is %s", upload_md5) - - send_check(sock, upload_md5, "file checksum") - receive_exactly(sock, 1, "file checksum result", RESPONSE_BIN_MD5_OK) - - # Disable nodelay for transfer - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0) - # Limit send buffer (usually around 100kB) in order to have progress bar - # show the actual progress - - sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_BUFFER_SIZE) - start_time = time.perf_counter() - - offset = 0 - progress = ProgressBar("Uploading") try: - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + if extended_proto: + send_check(sock, ota_type, "ota type") - try: - sock.sendall(chunk) - except OSError as err: - # A send failure can hide an error byte the device reported - # just before dropping the connection; surface that as the - # real, non-retryable cause when it is available - try: - sock.settimeout(1.0) - check_error(recv_decode(sock, 1), None) - except (OSError, OTANetworkError) as probe_err: - _LOGGER.debug( - "No device error behind the send failure: %s", probe_err - ) - raise OTANetworkError(f"sending data: {err}") from err + upload_size = len(upload_contents) + # The device erases flash between receiving the size and acking the + # prepare, so this window shows the erase cost (near zero when the + # device erases lazily during the upload) + prepare_start = time.perf_counter() + send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size") + if deflate: + # Own frame: an encrypted session carries one field per frame + send_check(sock, file_size.to_bytes(SIZE_FIELD_BYTES, "big"), "image size") + receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK) + prepare_duration = time.perf_counter() - prepare_start + _LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration) + + # The device hashes what it writes: the inflated image, else the received bytes + upload_md5 = hashlib.md5( + file_contents if deflate else upload_contents + ).hexdigest() + _LOGGER.debug("MD5 of upload is %s", upload_md5) + + send_check(sock, upload_md5, "file checksum") + receive_exactly(sock, 1, "file checksum result", RESPONSE_BIN_MD5_OK) + + # Disable nodelay for transfer + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0) + # Limit send buffer (usually around 100kB) in order to have progress bar + # show the actual progress + + sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_BUFFER_SIZE) + start_time = time.perf_counter() + + offset = 0 + progress = ProgressBar("Uploading") + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) - if version >= OTA_VERSION_2_0: try: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err except OTANetworkError as err: + if udp is None: + raise + # Over UDP: surface a device error code if one arrived, and + # treat a failure on the last chunk like a lost final ack + if udp.pending(): + receive_exactly(sock, 1, "data", []) if offset < upload_size: raise - # The device already had the complete image when this ack - # was lost, so it may be committing; do not retry raise _committed_error(err) from err - progress.update(offset / upload_size) - except OTAError: - # Terminate the progress bar line before the error is logged + # The UDP channel acks every message itself, so anything the device + # sends mid upload is an error code + if udp is not None and udp.pending() and offset < upload_size: + receive_exactly(sock, 1, "data", []) + if version >= OTA_VERSION_2_0 and udp is None: + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err + + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() - raise - progress.done() - # Enable nodelay for last checks - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - duration = time.perf_counter() - start_time + # Enable nodelay for last checks + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + duration = time.perf_counter() - start_time - _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) + _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - # Once the device has the complete image it commits the update and - # reboots on its own; the exact commit point is not observable from - # here, so treat everything past the data phase as non-retryable. A - # re-upload could flash a device that already updated successfully. - commit_start = time.perf_counter() - try: - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - except OTANetworkError as err: - raise _committed_error(err) from err - commit_duration = time.perf_counter() - commit_start + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + commit_start = time.perf_counter() + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err + commit_duration = time.perf_counter() - commit_start - # Sum of the named windows so the breakdown is self consistent; connect, - # handshake, auth, and the one MD5 round trip are not included - _LOGGER.info( - "Update took %.2f seconds (prepare %.2f, upload %.2f, commit %.2f)", - prepare_duration + duration + commit_duration, - prepare_duration, - duration, - commit_duration, - ) + # Sum of the named windows so the breakdown is self consistent; connect, + # handshake, auth, and the one MD5 round trip are not included + _LOGGER.info( + "Update took %.2f seconds (prepare %.2f, upload %.2f, commit %.2f)", + prepare_duration + duration + commit_duration, + prepare_duration, + duration, + commit_duration, + ) - try: - send_check(sock, RESPONSE_OK, "end acknowledgement") - except OTANetworkError as err: - # The device treats a missing end acknowledgement as non-fatal and is - # already rebooting into the new firmware, so the update succeeded - _LOGGER.warning("Failed sending end acknowledgement: %s", err) - _LOGGER.info("OTA successful (end acknowledgement not delivered)") - else: - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") + if udp is not None: + # Our end acknowledgement also tells the device we have its + # responses, ending its wait before it reboots + udp.flush(2.0) + finally: + if udp is not None: + udp.close() # Do not connect logs until it is fully on time.sleep(1) @@ -1121,7 +1513,7 @@ def run_ota_impl_( # are shared across the addresses, cycling through them. Wait before an # attempt when the previous one actually reached the device, or when # revisiting an address, so a flaky link can recover and the device can - # clean up a half-open connection (its handshake watchdog runs at 20s); + # clean up a half-open connection (its handshake watchdog runs at 60s); # moving on to the next address family stays immediate. Known limitation: # a silent mid-transfer drop with no reset can wedge the device until its # 105s data timeout, which outlasts this budget; the retries target the @@ -1139,6 +1531,8 @@ def run_ota_impl_( on_upload_start() on_upload_start = None # once per upload, whatever the retries do + # A network failure means a lossy link, so later attempts send data over UDP + prefer_udp = False while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] if not reconnect and (reached_device or attempt >= len(res)): @@ -1153,7 +1547,7 @@ def run_ota_impl_( reconnect = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) - sock.settimeout(20.0) + sock.settimeout(CONNECT_TIMEOUT) try: sock.connect(sa) except OSError as err: @@ -1164,7 +1558,9 @@ def run_ota_impl_( continue _LOGGER.info("Connected to %s", sa[0]) + sock.settimeout(SETUP_TIMEOUT) reached_device = True + total_attempts = max(total_attempts, len(res) + EXTRA_UPLOAD_ATTEMPTS_REACHED) with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota( @@ -1177,6 +1573,7 @@ def run_ota_impl_( encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, on_upload_start=upload_started, + prefer_udp=prefer_udp, ) except OTAKeyRejected as err: # The device sent a reject and closed on its own, so reconnect @@ -1196,6 +1593,7 @@ def run_ota_impl_( raise except OTAHandshakeNetworkError as err: last_error = str(err) + prefer_udp = True if encryption.handshake_fault_falls_back(): encryption.downgrade(last_error) continue @@ -1205,6 +1603,7 @@ def run_ota_impl_( except OTANetworkError as err: # Transient network failure; retry last_error = str(err) + prefer_udp = True _LOGGER.warning("%s", last_error) attempt += 1 continue diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index 17c4dea25f9..2102363b903 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -37,6 +37,59 @@ DEVICE_NAME = "host-ota-test" API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +_REAL_COMPRESS = zlib.compress + + +def _corrupt_compress(data: bytes, *args: object, **kwargs: object) -> bytes: + """Reserved block type in the first header: rejected by the decoder on + every build, unlike a flipped data bit that may only fail the MD5.""" + out = bytearray(_REAL_COMPRESS(data, *args, **kwargs)) + out[0] |= 0x06 + return bytes(out) + + +@contextmanager +def _udp_mode( + monkeypatch: pytest.MonkeyPatch, + force: bool = True, + loss: float = 0.0, + blocked: bool = False, +) -> Generator[list[bool]]: + """Force the UDP data phase (unless force is False) and drop datagrams both + ways; yields one entry per upload, True when it used UDP.""" + import random + + rng = random.Random(1) + used: list[bool] = [] + real_start = espota2._start_udp + real_send = espota2.UdpChannel._send + real_ack = espota2.UdpChannel._handle_ack + + def start(*args: object) -> espota2.UdpChannel | None: + channel = real_start(*args) + used.append(channel is not None) + return channel + + def send(self: espota2.UdpChannel, *args: object) -> None: + if not blocked and rng.random() >= loss: + real_send(self, *args) + + def ack(self: espota2.UdpChannel, data: bytes) -> None: + if rng.random() >= loss: + real_ack(self, data) + + with monkeypatch.context() as m: + if force: + m.setattr(espota2, "UDP_SLOW_EXCHANGE", -1.0) + if blocked: + # Nothing answers, so do not wait the full probe + m.setattr(espota2, "UDP_PROBE_TIMEOUT", 1.0) + m.setattr(espota2, "_start_udp", start) + m.setattr(espota2.UdpChannel, "_send", send) + m.setattr(espota2.UdpChannel, "_handle_ack", ack) + yield used + + @contextmanager def _reserve_port() -> Generator[tuple[int, socket.socket]]: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -272,18 +325,9 @@ async def test_host_ota_deflate( errors.append(line) dev.on_log(line) - real_compress = zlib.compress - - def corrupt_compress(data: bytes, *args: object, **kwargs: object) -> bytes: - # Reserved block type in the first header: rejected by the decoder on - # every build, unlike a flipped data bit that may only fail the MD5 - out = bytearray(real_compress(data, *args, **kwargs)) - out[0] |= 0x06 - return bytes(out) - def overlong_compress(data: bytes, *args: object, **kwargs: object) -> bytes: """A stream that inflates past the size the client announced.""" - return real_compress(data + bytes(8192), *args, **kwargs) + return _REAL_COMPRESS(data + bytes(8192), *args, **kwargs) async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines): dev.proc = proc @@ -301,7 +345,7 @@ async def test_host_ota_deflate( # A corrupt stream fails the upload and leaves the device running with monkeypatch.context() as m: - m.setattr(zlib, "compress", corrupt_compress) + m.setattr(zlib, "compress", _corrupt_compress) await dev.refused_ota(None, None, "corrupt deflate stream was accepted") assert errors, "device did not report the corrupt stream" @@ -318,6 +362,29 @@ async def test_host_ota_deflate( await dev.ota(None, None, "upload after a rejected stream failed") assert dev.inflates == 2 + # Localhost is clean, so the client stays on TCP + with _udp_mode(monkeypatch, force=False) as used: + await dev.ota(None, None, "TCP upload failed") + assert used == [False], "a clean link asked for UDP" + + # A lossy link moves the data phase to UDP and completes through loss + with _udp_mode(monkeypatch, loss=0.2) as used: + await dev.ota(None, None, "UDP upload with loss failed") + assert used == [True], "the upload did not use UDP" + assert dev.inflates == 4 + + # Blocked UDP falls back to TCP + with _udp_mode(monkeypatch, blocked=True) as used: + await dev.ota(None, None, "fallback to TCP failed") + assert used == [False], "blocked UDP did not fall back to TCP" + + # A device error mid upload surfaces at once, not as a 160 s timeout + with _udp_mode(monkeypatch) as used, monkeypatch.context() as m: + m.setattr(zlib, "compress", _corrupt_compress) + async with asyncio.timeout(30): + await dev.refused_ota(None, None, "corrupt stream over UDP accepted") + assert used == [True] + @pytest.mark.asyncio async def test_host_ota_encrypted( @@ -325,6 +392,7 @@ async def test_host_ota_encrypted( write_yaml_config: ConfigWriter, compile_esphome: CompileFunction, reserved_tcp_port: tuple[int, socket.socket], + monkeypatch: pytest.MonkeyPatch, ) -> None: """A client that leaves right after the handshake, as a key probe does, is a clean close, not an OTA error; a plaintext upload is refused; an @@ -348,6 +416,10 @@ async def test_host_ota_encrypted( None, None, "plaintext upload to an encrypted device must fail" ) await dev.ota(None, API_KEY, "encrypted OTA reported failure") + # Noise frames ride the UDP stream unchanged, through loss + with _udp_mode(monkeypatch, loss=0.2) as used: + await dev.ota(None, API_KEY, "encrypted UDP upload failed") + assert used == [True] @pytest.mark.asyncio diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index e83335a175f..04b09b4d542 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -11,6 +11,7 @@ import logging from pathlib import Path import socket import struct +from typing import Self from unittest.mock import Mock, call, patch import zlib @@ -357,6 +358,7 @@ def test_perform_ota_successful_md5_auth( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -765,7 +767,7 @@ def test_run_ota_impl_successful( assert result_host == "192.168.1.100" # Verify socket was configured correctly - mock_socket.settimeout.assert_called_with(20.0) + mock_socket.settimeout.assert_called_with(espota2.SETUP_TIMEOUT) mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) mock_socket.close.assert_called_once() @@ -853,13 +855,22 @@ def test_run_ota_impl_network_error_retry_succeeds( assert result_host == "192.168.1.100" assert mock_perform_ota.call_count == 2 mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + # The failure marks the link lossy, so the retry moves the data to UDP + assert [c.kwargs["prefer_udp"] for c in mock_perform_ota.call_args_list] == [ + False, + True, + ] @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_network_error_exhausts_attempts( mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock ) -> None: - """Test run_ota_impl_ gives up after all attempts hit network errors.""" + """Test run_ota_impl_ gives up after all attempts hit network errors. + + Every attempt connected, so the budget is the longer one for a reachable + device on a lossy link. + """ mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") result_code, result_host = espota2.run_ota_impl_( @@ -868,8 +879,8 @@ def test_run_ota_impl_network_error_exhausts_attempts( assert result_code == 1 assert result_host is None - assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 - assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS_REACHED + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS_REACHED @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") @@ -890,12 +901,10 @@ def test_run_ota_impl_multiple_addresses_cycle( assert mock_socket.connect.call_args_list == [ call(DUAL_STACK_SA6), call(DUAL_STACK_SA4), - call(DUAL_STACK_SA6), - call(DUAL_STACK_SA4), - ] + ] * (1 + espota2.EXTRA_UPLOAD_ATTEMPTS // 2) # No connect ever reached the device, so the delay only applies before # the revisits - assert mock_sleep.call_count == 2 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") @@ -1079,6 +1088,7 @@ def test_perform_ota_successful_sha256_auth( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -1136,6 +1146,7 @@ def test_perform_ota_sha256_fallback_to_md5( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -1246,6 +1257,7 @@ def test_perform_ota_extended_protocol_app( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -1307,6 +1319,7 @@ def test_perform_ota_successful_partition_table( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -1572,3 +1585,468 @@ def test_perform_ota_with_deflate(mock_socket: Mock, server_features: int) -> No assert len(payload) == sent_size < len(original_content) assert zlib.decompress(payload, -espota2.DEFLATE_WINDOW_BITS) == original_content assert sent[5] == hashlib.md5(original_content).hexdigest().encode() + + +class _LossyUdpDevice: + """The device side of the UDP channel (ota_esphome_udp.cpp) on a lossy link.""" + + def __init__(self, token: bytes, loss: float, seed: int = 1) -> None: + import random + import threading + + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind(("127.0.0.1", 0)) + self.sock.settimeout(0.05) + self.token = token + self.loss = loss + self.random = random.Random(seed) + self.next_seq = 0 + self.slots: dict[int, bytes] = {} # early messages within the window + self.received: list[bytes] = [] + self.out = bytearray() + self.out_base = 0 + self.committed = False + self._stop = threading.Event() + self._thread = threading.Thread(target=self._run, daemon=True) + + def __enter__(self) -> Self: + self._thread.start() + return self + + def __exit__(self, *exc: object) -> None: + self._stop.set() + self._thread.join() + self.sock.close() + + def _run(self) -> None: + while not self._stop.is_set(): + try: + data, peer = self.sock.recvfrom(2048) + except TimeoutError: + continue + if self.random.random() < self.loss: + continue + if data[1:5] != self.token: + continue + self.committed |= bool(data[0] & espota2.UDP_FLAG_COMMITTED) + acked = (int.from_bytes(data[7:9], "big") - self.out_base) & 0xFFFF + if acked <= len(self.out): + del self.out[:acked] + self.out_base += acked + seq = int.from_bytes(data[5:7], "big") + msg_type = data[0] & ~espota2.UDP_FLAG_COMMITTED + if ( + msg_type == espota2.UDP_MSG_DATA + and (seq - self.next_seq) & 0xFFFF < espota2.UDP_WINDOW + ): + self.slots.setdefault(seq, data[espota2.UDP_HEADER_SIZE :]) + # Consumed as soon as it is next, like a device that is never busy + while self.next_seq in self.slots: + self.received.append(self.slots.pop(self.next_seq)) + self.next_seq = (self.next_seq + 1) & 0xFFFF + if self.random.random() < self.loss: + continue + window = sum( + 1 << k + for k in range(1, espota2.UDP_WINDOW) + if (self.next_seq + k) & 0xFFFF in self.slots + ) + # Consumes at once, so every slot it does not hold is free + window |= (espota2.UDP_WINDOW - len(self.slots)) << 4 + self.sock.sendto( + bytes([espota2.UDP_MSG_ACK]) + + self.token + + self.next_seq.to_bytes(2, "big") + + seq.to_bytes(2, "big") + + (self.out_base & 0xFFFF).to_bytes(2, "big") + + bytes([window]) + + bytes(self.out), + peer, + ) + + +def _udp_client(device: _LossyUdpDevice) -> espota2.UdpChannel: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect(device.sock.getsockname()) + return espota2.UdpChannel(sock, device.token) + + +@pytest.mark.parametrize("loss", [0.0, 0.5]) +def test_udp_channel_delivers_in_order(loss: float) -> None: + """The stream arrives intact and responses come back, despite loss.""" + data = bytes(range(256)) * 160 + with _LossyUdpDevice(b"\x01\x02\x03\x04", loss) as device: + channel = _udp_client(device) + assert channel.probe() + channel.sendall(data) + device.out += bytes([espota2.RESPONSE_RECEIVE_OK]) + assert channel.recv(1) == bytes([espota2.RESPONSE_RECEIVE_OK]) + channel.flush(30.0) + channel.close() + assert b"".join(device.received) == data + assert all(len(m) <= espota2.UDP_MAX_PAYLOAD for m in device.received) + assert device.committed + + +def test_start_udp_falls_back_when_blocked(mock_socket: Mock) -> None: + """No answer to the probes sends the fallback over TCP and stays on TCP.""" + with _LossyUdpDevice(b"\x05\x06\x07\x08", 1.0) as device: + mock_socket.family = socket.AF_INET + mock_socket.getpeername.return_value = device.sock.getsockname() + mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"] + with patch.object(espota2, "UDP_PROBE_TIMEOUT", 0.3): + assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert mock_socket.sendall.call_args_list == [ + call(espota2.UDP_REQUEST), + call(espota2.UDP_FALLBACK), # raw on the TCP socket + ] + + +def test_start_udp_declined(mock_socket: Mock) -> None: + """A zero token means the device could not open UDP; nothing is probed.""" + mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"] + assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] + + +def test_start_udp_clean_link_stays_on_tcp(mock_socket: Mock) -> None: + """A clean handshake asks to stay on TCP and opens no UDP socket.""" + mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"] + with patch.object(espota2.socket, "socket") as udp_socket: + assert espota2._start_udp(mock_socket, mock_socket, False) is None + udp_socket.assert_not_called() + assert mock_socket.sendall.call_args_list == [call(espota2.UDP_FALLBACK)] + + +@pytest.mark.parametrize( + ("slowest", "prefer_udp", "retransmits", "lossy"), + [ + (0.01, False, 0, False), + (0.01, False, None, False), + (espota2.UDP_SLOW_EXCHANGE + 0.1, False, 0, True), + (0.01, True, 0, True), + (0.01, False, 2, True), + ], +) +def test_link_lossy( + slowest: float, prefer_udp: bool, retransmits: int | None, lossy: bool +) -> None: + """A slow exchange, a retransmit or an earlier failure means UDP.""" + with patch.object(espota2, "_tcp_retransmits", return_value=retransmits): + assert espota2._link_lossy(Mock(), slowest, prefer_udp) is lossy + + +@pytest.mark.parametrize( + ("platform", "info", "expected"), + [ + ("linux", bytes(100) + (3).to_bytes(4, "little") + bytes(100), 3), + ("darwin", bytes(72) + (1460).to_bytes(8, "little") + bytes(8), 1460), + ("win32", b"", None), + ("linux", bytes(10), None), + ], +) +def test_tcp_retransmits(platform: str, info: bytes, expected: int | None) -> None: + """The retransmit counter is read where the OS reports it.""" + sock = Mock() + sock.getsockopt.return_value = info + with patch("sys.platform", platform): + assert espota2._tcp_retransmits(sock) == expected + + +def test_udp_channel_reports_socket_errors() -> None: + """A closed device port fails fast instead of waiting out the timeout.""" + gone = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + gone.bind(("127.0.0.1", 0)) + addr = gone.getsockname() + gone.close() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect(addr) + channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") + channel.settimeout(10.0) + with pytest.raises(espota2.OTANetworkError, match="closed its UDP port"): + channel.sendall(bytes(espota2.UDP_MAX_PAYLOAD * 5)) + # A probe that is refused falls back instead + assert not espota2.UdpChannel(sock, b"\x01\x01\x01\x01").probe() + channel.close() + + +def test_udp_channel_no_progress_names_last_error() -> None: + """Without refusals, the no-progress timeout names the last socket error.""" + sock = Mock() + sock.send.side_effect = BlockingIOError(35, "full") + channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") + channel.settimeout(0.2) + channel.sendall(b"x") + with ( + patch("select.select", return_value=([], [], [])), + pytest.raises(espota2.OTANetworkError, match="no progress.*full"), + ): + while True: + channel._pump() + + +def test_udp_channel_ack_resets_refusals() -> None: + """Refusals only count in a row; an ACK in between resets them.""" + channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01") + ack = bytes([espota2.UDP_MSG_ACK]) + b"\x01\x01\x01\x01" + bytes(6) + b"\x40" + for _ in range(espota2.UDP_REFUSALS * 2): + channel._socket_error(ConnectionRefusedError(61, "refused")) + channel._handle_ack(ack) + # Windows' form of the same refusal counts too + with pytest.raises(espota2.OTANetworkError, match="closed its UDP port"): + for _ in range(espota2.UDP_REFUSALS): + channel._socket_error(ConnectionResetError(10054, "forcibly closed")) + + +def _udp_handshake() -> list[bytes]: + """TCP recv responses up to auth, from a device that offers UDP.""" + return [ + bytes([espota2.RESPONSE_OK]), + bytes([espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_FEATURE_FLAGS]), + bytes([espota2.SERVER_FEATURE_SUPPORTS_UDP]), + bytes([espota2.RESPONSE_AUTH_OK]), + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_over_udp(mock_socket: Mock) -> None: + """The data phase moves to the channel, which acks chunks itself.""" + mock_socket.recv.side_effect = _udp_handshake() + udp = Mock() + udp.pending.return_value = False + udp.recv.side_effect = [ + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + bytes([espota2.RESPONSE_BIN_MD5_OK]), + bytes([espota2.RESPONSE_RECEIVE_OK]), + bytes([espota2.RESPONSE_UPDATE_END_OK]), + ] + with patch.object(espota2, "_start_udp", return_value=udp) as start: + espota2.perform_ota( + mock_socket, None, io.BytesIO(bytes(20000)), "test.bin", prefer_udp=True + ) + assert start.call_args.args[2] is True # an earlier failure means lossy + assert udp.recv.call_count == 4 # no chunk acks + udp.flush.assert_called_once() + udp.close.assert_called_once() + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_over_udp_device_error(mock_socket: Mock) -> None: + """A device error mid upload stops the upload and is not retried.""" + mock_socket.recv.side_effect = _udp_handshake() + mock_socket.getsockopt.return_value = b"" # no retransmit counter + udp = Mock() + udp.pending.return_value = True + udp.recv.side_effect = [ + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + bytes([espota2.RESPONSE_BIN_MD5_OK]), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), + ] + with ( + patch.object(espota2, "_start_udp", return_value=udp), + pytest.raises(espota2.OTAError, match="Writing OTA data") as exc, + ): + espota2.perform_ota(mock_socket, None, io.BytesIO(bytes(20000)), "test.bin") + assert not isinstance(exc.value, espota2.OTANetworkError) + udp.close.assert_called() + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_udp_declined_stays_on_tcp(mock_socket: Mock) -> None: + """Without a channel the upload continues on TCP with chunk acks.""" + mock_socket.recv.side_effect = [ + *_udp_handshake(), + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + bytes([espota2.RESPONSE_BIN_MD5_OK]), + *_UPLOAD_TAIL, + ] + with patch.object(espota2, "_start_udp", return_value=None): + espota2.perform_ota( + mock_socket, None, io.BytesIO(b"x" * 100), "test.bin", prefer_udp=True + ) + assert mock_socket.sendall.call_args_list[-1] == call(bytes([espota2.RESPONSE_OK])) + + +def test_udp_channel_records_socket_errors() -> None: + """Send and receive errors are recorded like loss.""" + sock = Mock() + sock.send.side_effect = BlockingIOError(35, "full") + sock.recv.side_effect = ConnectionRefusedError(61, "refused") + channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") + channel._send(espota2.UDP_MSG_PING, 0, b"") + assert isinstance(channel._last_error, BlockingIOError) + with patch("select.select", side_effect=[([sock], [], []), ([], [], [])]): + channel._receive(0.0) + assert isinstance(channel._last_error, ConnectionRefusedError) + + +def test_start_udp_probe_succeeds(mock_socket: Mock) -> None: + """A device that answers the probe gets the data phase over UDP.""" + with _LossyUdpDevice(b"\x05\x06\x07\x09", 0.0) as device: + mock_socket.family = socket.AF_INET + mock_socket.getpeername.return_value = device.sock.getsockname() + mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x09"] + channel = espota2._start_udp(mock_socket, mock_socket, True) + assert channel is not None + channel.close() + assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] + + +def test_udp_channel_pings_when_all_held() -> None: + """With every message in flight already held, it asks for responses instead, + at most once per resend timeout.""" + device = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + device.bind(("127.0.0.1", 0)) + device.settimeout(1.0) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect(device.getsockname()) + channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") + channel.sendall(b"x") + channel._queue[0].held = True + with patch.object(channel, "_receive"): + channel._pump() + channel._pump() + assert device.recv(2048)[0] == espota2.UDP_MSG_PING + device.settimeout(0.1) + with pytest.raises(TimeoutError): + device.recv(2048) + channel.close() + device.close() + + +def test_udp_channel_ignores_foreign_datagrams() -> None: + """Short, non-ACK or wrong-token datagrams change nothing.""" + channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01") + for data in (b"\x40", bytes(espota2.UDP_ACK_HEADER_SIZE), b"\x40" + bytes(11)): + channel._handle_ack(data) + assert not channel.pending() + # An ACK before the head was ever sent is no duplicate + channel.sendall(b"x") + ack = bytes([espota2.UDP_MSG_ACK]) + b"\x01\x01\x01\x01" + bytes(6) + b"\x40" + channel._handle_ack(ack) + channel._handle_ack(ack) + assert not channel._queue[0].resend + + +def test_udp_channel_logs_each_socket_error_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """A repeated error is recorded but logged only when it changes.""" + channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01") + with caplog.at_level(logging.DEBUG, "esphome.espota2"): + channel._socket_error(ConnectionRefusedError(61, "refused")) + channel._socket_error(ConnectionRefusedError(61, "refused again")) + assert caplog.text.count("UDP socket error") == 1 + assert "again" in str(channel._last_error) + + +def test_tcp_retransmits_unsupported_option() -> None: + """An OS that rejects the option reports nothing.""" + sock = Mock() + sock.getsockopt.side_effect = OSError("not supported") + with patch("sys.platform", "linux"): + assert espota2._tcp_retransmits(sock) is None + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_handshake_failure_prefers_udp( + firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """A handshake that dies after reaching the device moves the retry to UDP.""" + mock_perform_ota.side_effect = [ + espota2.OTAHandshakeNetworkError("noise handshake: timed out"), + None, + ] + result_code, _ = espota2.run_ota_impl_("test.local", 3232, None, str(firmware_file)) + assert result_code == 0 + assert [c.kwargs["prefer_udp"] for c in mock_perform_ota.call_args_list] == [ + False, + True, + ] + + +def test_start_udp_socket_error_falls_back(mock_socket: Mock) -> None: + """A UDP socket that cannot reach the peer stays on TCP.""" + mock_socket.family = socket.AF_INET + mock_socket.getpeername.side_effect = OSError("not connected") + mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"] + assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert mock_socket.sendall.call_args_list == [ + call(espota2.UDP_REQUEST), + call(espota2.UDP_FALLBACK), + ] + + +def test_udp_channel_backs_off_when_acks_come_back_untimed() -> None: + """Progress acks that only answer resent messages mean the link is slower + than the timer: it backs off, up to its cap, until a sample resets it.""" + token = b"\x01\x01\x01\x01" + channel = espota2.UdpChannel(Mock(), token) + channel._queue = [espota2._UdpMessage(seq, b"x") for seq in range(40)] + rtos = [] + for received in range(1, 33): + for message in channel._queue[:1]: + message.sends = 2 # every message was resent before its ack + rtos.append(channel._rto()) + ack = ( + bytes([espota2.UDP_MSG_ACK]) + + token + + received.to_bytes(2, "big") + + (received - 1).to_bytes(2, "big") + + bytes(2) + + b"\x40" + ) + channel._handle_ack(ack) + n = espota2.UDP_UNTIMED_ACKS + assert rtos[0] == espota2.UDP_RESEND_INITIAL + assert rtos[n] == espota2.UDP_RESEND_INITIAL * 2 + assert rtos[-1] == espota2.UDP_RESEND_MAX + channel._sample_rtt(0.1) + assert channel._rto() == pytest.approx(0.3) # srtt 0.1 + 4 * rttvar 0.05 + + +@pytest.mark.usefixtures("mock_time") +@pytest.mark.parametrize( + ("size", "pending", "match", "retryable"), + [ + (100, False, "may have already committed", False), # one, final chunk + (20000, False, "no progress", True), # fails on an earlier chunk + (20000, True, "Writing OTA data", False), # device error waiting + ], +) +def test_perform_ota_over_udp_send_failure( + mock_socket: Mock, size: int, pending: bool, match: str, retryable: bool +) -> None: + """A UDP send failure surfaces a waiting device error, and on the last + chunk is not retried, like a lost final chunk ack over TCP.""" + mock_socket.recv.side_effect = _udp_handshake() + udp = Mock() + udp.pending.return_value = pending + udp.recv.side_effect = [ + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + bytes([espota2.RESPONSE_BIN_MD5_OK]), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), + ] + # ota type, size and MD5 go through; the first data chunk fails + udp.sendall.side_effect = [None] * 3 + [espota2.OTANetworkError("no progress")] + with ( + patch.object(espota2, "_start_udp", return_value=udp), + pytest.raises(espota2.OTAError, match=match) as exc, + ): + espota2.perform_ota( + mock_socket, None, io.BytesIO(bytes(size)), "test.bin", prefer_udp=True + ) + assert isinstance(exc.value, espota2.OTANetworkError) is retryable + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_tcp_send_network_error_unchanged(mock_socket: Mock) -> None: + """Over TCP an OTANetworkError from a send stays retryable, even on the + last chunk.""" + mock_socket.recv.side_effect = _no_auth_handshake(espota2.OTA_VERSION_2_0) + mock_socket.sendall.side_effect = [None] * 4 + [ + espota2.OTANetworkError("sending frame") + ] + with pytest.raises(espota2.OTANetworkError, match="sending frame"): + espota2.perform_ota(mock_socket, None, io.BytesIO(b"x" * 100), "test.bin") diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index f447e03986e..c316b55a9cf 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -76,9 +76,11 @@ class FakeEncryptedDevice(threading.Thread): prologue_features_override: int | None = None, connections: int = 1, drop_handshakes: int = 0, + udp: bool = False, reject_reason: str | None = None, ) -> None: super().__init__(daemon=True) + self.udp = udp # offer UDP; the data phase then has no chunk acks 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 @@ -125,6 +127,8 @@ class FakeEncryptedDevice(threading.Thread): sock.sendall(bytes([espota2.RESPONSE_ERROR_ENCRYPTION_REQUIRED])) return server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0 + if self.udp: + server_flags |= espota2.SERVER_FEATURE_SUPPORTS_UDP sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags])) if not (noise_negotiated and self.offer_noise): # A device that does not require encryption continues in @@ -217,7 +221,7 @@ class FakeEncryptedDevice(threading.Thread): acked = 0 while len(received) < size: received += recv_data(size - len(received)) - if self.version >= espota2.OTA_VERSION_2_0: + if self.version >= espota2.OTA_VERSION_2_0 and not self.udp: while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or ( len(received) == size and acked < size ): @@ -913,6 +917,54 @@ def test_bare_block_refuses_a_device_that_cannot_encrypt( assert not any("Retrying in plaintext" in r.message for r in caplog.records) +class _TcpAsUdp: + """Stands in for a UdpChannel, carrying its byte stream over the TCP socket.""" + + def __init__(self, sock: socket.socket) -> None: + self._sock = sock + self.flushed = False + self.closed = False + + def settimeout(self, timeout: float | None) -> None: + self._sock.settimeout(timeout) + + def setsockopt(self, level: int, optname: int, value: int) -> None: + self._sock.setsockopt(level, optname, value) + + def sendall(self, data: bytes) -> None: + self._sock.sendall(data) + + def recv(self, amount: int) -> bytes: + return self._sock.recv(amount) + + def pending(self) -> bool: + return False + + def flush(self, timeout: float) -> None: + self.flushed = True + + def close(self) -> None: + self.closed = True + + +def test_encrypted_upload_over_udp() -> None: + """After the handshake, Noise frames continue on the UDP channel.""" + pytest.importorskip("aioesphomeapi.noise") + firmware = bytes(range(256)) * 80 + device = FakeEncryptedDevice(udp=True) + channels: list[_TcpAsUdp] = [] + + def start_udp(sock: Any, tcp_sock: socket.socket, lossy: bool) -> _TcpAsUdp: + channels.append(_TcpAsUdp(tcp_sock)) + return channels[0] + + with patch("time.sleep"), patch.object(espota2, "_start_udp", start_udp): + _upload(device, firmware, PSK) + device.join_and_check() + assert device.received == firmware + assert channels[0].flushed and channels[0].closed + + def test_probe_ota_key_recomputes_the_budget_after_connect() -> None: """A slow connect leaves less for the handshake than the budget had.""" clock = [0.0] From 9a5d2d97536e6204c4b9d0a8080612d3153dacdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 17:38:39 -1000 Subject: [PATCH 410/651] [api] Encode action strings straight from flash (#20425) --- esphome/components/api/__init__.py | 33 ---------- esphome/components/api/api.proto | 16 ++--- esphome/components/api/api_options.proto | 2 +- esphome/components/api/api_pb2.cpp | 16 ++--- esphome/components/api/api_pb2.h | 16 ++--- esphome/components/api/api_pb2_dump.cpp | 28 ++++++--- esphome/components/api/api_server.cpp | 2 +- esphome/components/api/custom_api_device.h | 16 ++--- .../components/api/homeassistant_service.h | 32 ++-------- esphome/components/api/list_entities.cpp | 3 +- esphome/components/api/proto.h | 34 ++++++++++ esphome/components/api/user_services.cpp | 29 +++------ esphome/components/api/user_services.h | 31 +++------- .../button/homeassistant_button.cpp | 22 +++---- .../number/homeassistant_number.cpp | 29 +++++---- .../select/homeassistant_select.cpp | 28 ++++----- .../switch/homeassistant_switch.cpp | 18 +++--- .../homeassistant/text/homeassistant_text.cpp | 28 ++++----- esphome/core/defines.h | 1 - esphome/core/string_ref.h | 62 +++++++++++++------ script/api_protobuf/api_protobuf.py | 61 ++++++++++++------ .../api/test_action_metadata.py | 50 ++------------- .../core/test_progmem_string_ref.cpp | 30 +++++++++ .../api/test_api_protobuf_generator.py | 35 ++++++++++- 24 files changed, 327 insertions(+), 295 deletions(-) create mode 100644 tests/components/core/test_progmem_string_ref.cpp diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9611228ba1d..44d8b443676 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -251,9 +251,6 @@ def _validate_supports_response(value: Any) -> str: return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) -# ESP8266 copies every string of an action into a stack buffer sized by codegen; keep it small -ESP8266_ACTION_STRINGS_MAX_TOTAL = 384 - VARIABLE_SCHEMA = cv.Schema( { cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), @@ -484,29 +481,6 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]: return strings -def _action_strings_size(strings: list[str | None]) -> int: - """Bytes needed to copy every string out of flash, each with its terminator.""" - return sum( - len(string.encode("utf-8")) + 1 for string in strings if string is not None - ) - - -def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: - if not CORE.is_esp8266: - return config - actions = config.get(CONF_ACTIONS, []) - has_metadata = _has_action_metadata(actions) - for conf in actions: - size = _action_strings_size(_action_strings(conf, has_metadata)) - if size > ESP8266_ACTION_STRINGS_MAX_TOTAL: - raise cv.Invalid( - f"Action '{conf[CONF_ACTION]}' has {size} bytes of name, variable name, " - f"description and example text; ESP8266 allows at most " - f"{ESP8266_ACTION_STRINGS_MAX_TOTAL} bytes per action" - ) - return config - - def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: """An IPv6 host can never be parsed, so never dialed, without IPv6.""" if ( @@ -531,7 +505,6 @@ def _validate_wizard(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = cv.All( - _validate_esp8266_action_strings, _validate_outgoing_host_ipv6, _validate_wizard, ) @@ -587,7 +560,6 @@ async def to_code(config: ConfigType) -> None: if config[CONF_HOMEASSISTANT_STATES]: cg.add_define("USE_API_HOMEASSISTANT_STATES") - scratch_size = 0 if actions: # Metadata is compiled in for every action once any action declares it, because the # string table layout is fixed by the define rather than per action @@ -639,8 +611,6 @@ async def to_code(config: ConfigType) -> None: func_args.append((native, name)) strings = _action_strings(conf, has_metadata) 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 templ = cg.TemplateArguments(supports_response, *service_template_args) # Key is hashed here because the name is not readable at runtime on ESP8266 @@ -667,9 +637,6 @@ async def to_code(config: ConfigType) -> None: 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: - # Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action - cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1)) if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None: await wizard.to_code(wizard_config) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 41ece9275fd..679aff06b5e 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1029,8 +1029,8 @@ message SubscribeHomeassistantServicesRequest { } message HomeassistantServiceMap { - string key = 1; - string value = 2; + string key = 1 [(progmem) = true]; + string value = 2 [(progmem) = true]; } message HomeassistantActionRequest { @@ -1039,7 +1039,7 @@ message HomeassistantActionRequest { option (no_delay) = true; option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES"; - string service = 1; + string service = 1 [(progmem) = true]; repeated HomeassistantServiceMap data = 2 [(fixed_vector) = true]; repeated HomeassistantServiceMap data_template = 3 [(fixed_vector) = true]; repeated HomeassistantServiceMap variables = 4 [(fixed_vector) = true]; @@ -1160,21 +1160,21 @@ enum SupportsResponseType { } message ListEntitiesServicesArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; - string name = 1; + string name = 1 [(progmem) = true]; ServiceArgType type = 2; - string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; - string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; + string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; + string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; } message ListEntitiesServicesResponse { option (id) = 41; option (source) = SOURCE_SERVER; option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; - string name = 1; + string name = 1 [(progmem) = true]; fixed32 key = 2 [(force) = true]; repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; SupportsResponseType supports_response = 4; - string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; + string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; } message ExecuteServiceArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 8e8fa99833d..052b0c36bc7 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -123,7 +123,7 @@ extend google.protobuf.FieldOptions { // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros). optional bool track_presence = 50020 [default=false]; - // progmem: The pointer_to_buffer bytes field points into flash (PROGMEM). + // progmem: The string field, or pointer_to_buffer bytes field, points into flash (PROGMEM). // The generated encode and dump copy it with progmem_memcpy, as ESP8266 // cannot read flash with ordinary loads. optional bool progmem = 50021 [default=false]; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 227af11bc08..8f7b622bc96 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1142,8 +1142,8 @@ uint32_t NoiseEncryptionSetKeyResponse::calc_size_msg(const void *self) { #ifdef USE_API_HOMEASSISTANT_SERVICES uint8_t *HomeassistantServiceMap::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value); return pos; } uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) { @@ -1155,7 +1155,7 @@ uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) { } uint8_t *HomeassistantActionRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service); for (auto &it : msg.data) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } @@ -1326,13 +1326,13 @@ void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example); #endif return pos; } @@ -1352,14 +1352,14 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) { uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); for (auto &it : msg.args) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description); #endif return pos; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7ec8955610c..fd12882dc9a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1389,8 +1389,8 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_SERVICES class HomeassistantServiceMap final : public ProtoMessage { public: - StringRef key{nullptr, 0}; // null until set, encode only - StringRef value{nullptr, 0}; // null until set, encode only + ProgmemStringRef key{}; + ProgmemStringRef value{}; 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); @@ -1410,7 +1410,7 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); } #endif - StringRef service{nullptr, 0}; // null until set, encode only + ProgmemStringRef service{}; FixedVector data{}; FixedVector data_template{}; FixedVector variables{}; @@ -1577,13 +1577,13 @@ class GetTimeResponse final : public ProtoDecodableMessage { #ifdef USE_API_USER_DEFINED_ACTIONS class ListEntitiesServicesArgument final : public ProtoMessage { public: - StringRef name{nullptr, 0}; // null until set, encode only + ProgmemStringRef name{}; enums::ServiceArgType type{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{nullptr, 0}; // null until set, encode only + ProgmemStringRef description{}; #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef example{nullptr, 0}; // null until set, encode only + ProgmemStringRef example{}; #endif 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 { @@ -1604,12 +1604,12 @@ class ListEntitiesServicesResponse final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); } #endif - StringRef name{nullptr, 0}; // null until set, encode only + ProgmemStringRef name{}; uint32_t key{0}; FixedVector args{}; enums::SupportsResponseType supports_response{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{nullptr, 0}; // null until set, encode only + ProgmemStringRef description{}; #endif 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 { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 23ce6319ed1..688ec48e6e6 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -12,8 +12,9 @@ namespace esphome::api { #ifdef USE_ESP8266 // Out-of-line to avoid inlining strlen_P/memcpy_P at every call site -void DumpBuffer::append_p_esp8266(const char *str) { - size_t len = strlen_P(str); +void DumpBuffer::append_p_esp8266(const char *str) { this->append_p_esp8266(str, strlen_P(str)); } + +void DumpBuffer::append_p_esp8266(const char *str, size_t len) { size_t space = CAPACITY - 1 - pos_; if (len > space) len = space; @@ -134,6 +135,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co progmem_memcpy(data_buf, data, len); dump_bytes_field(out, field_name, data_buf, len, indent); } + +// Helper for string fields in flash +static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, + int indent = 2) { + append_field_prefix(out, field_name, indent); + out.append("'").append_p(value.progmem_ptr(), value.size()).append("'\n"); +} #pragma GCC diagnostic pop template<> const char *proto_enum_to_string(enums::DisconnectReason value) { @@ -1512,13 +1520,13 @@ const char *NoiseEncryptionSetKeyResponse::dump_to(DumpBuffer &out) const { #ifdef USE_API_HOMEASSISTANT_SERVICES const char *HomeassistantServiceMap::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantServiceMap")); - dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("value"), this->value); + dump_progmem_string_field(out, ESPHOME_PSTR("key"), this->key); + dump_progmem_string_field(out, ESPHOME_PSTR("value"), this->value); return out.c_str(); } const char *HomeassistantActionRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantActionRequest")); - dump_field(out, ESPHOME_PSTR("service"), this->service); + dump_progmem_string_field(out, ESPHOME_PSTR("service"), this->service); for (const auto &it : this->data) { out.append(4, ' ').append_p(ESPHOME_PSTR("data")).append(": "); it.dump_to(out); @@ -1613,19 +1621,19 @@ const char *GetTimeResponse::dump_to(DumpBuffer &out) const { #ifdef USE_API_USER_DEFINED_ACTIONS const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesArgument")); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("type"), static_cast(this->type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - dump_field(out, ESPHOME_PSTR("description"), this->description); + dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - dump_field(out, ESPHOME_PSTR("example"), this->example); + dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example); #endif return out.c_str(); } const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesResponse")); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("key"), this->key); for (const auto &it : this->args) { out.append(4, ' ').append_p(ESPHOME_PSTR("args")).append(": "); @@ -1634,7 +1642,7 @@ const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { } dump_field(out, ESPHOME_PSTR("supports_response"), static_cast(this->supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - dump_field(out, ESPHOME_PSTR("description"), this->description); + dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description); #endif return out.c_str(); } diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index f63039a9714..a8d14256dcb 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -547,7 +547,7 @@ void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call // arrive before the subscription and are lost - warn instead of failing silently. ESP_LOGW(TAG, "Home Assistant %s '%.*s' dropped; %s", call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"), - static_cast(call.service.size()), call.service.empty() ? "" : call.service.c_str(), + static_cast(call.service.size()), call.service.empty() ? "" : call.service.progmem_ptr(), this->is_connected() ? LOG_STR_LITERAL("client has not subscribed to actions (yet)") : LOG_STR_LITERAL("no client connected")); } diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index c2b0f14bcc5..208c956eb17 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -247,7 +247,7 @@ class CustomAPIDevice { */ void call_homeassistant_service(const std::string &service_name) { HomeassistantActionRequest resp; - resp.service = StringRef(service_name); + resp.service = ProgmemStringRef(StringRef(service_name)); global_api_server->send_homeassistant_action(resp); } @@ -267,12 +267,12 @@ class CustomAPIDevice { */ void call_homeassistant_service(const std::string &service_name, const std::map &data) { HomeassistantActionRequest resp; - resp.service = StringRef(service_name); + resp.service = ProgmemStringRef(StringRef(service_name)); resp.data.init(data.size()); for (auto &it : data) { auto &kv = resp.data.emplace_back(); - kv.key = StringRef(it.first); - kv.value = StringRef(it.second); // data map lives until send completes + kv.key = ProgmemStringRef(StringRef(it.first)); + kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes } global_api_server->send_homeassistant_action(resp); } @@ -289,7 +289,7 @@ class CustomAPIDevice { */ void fire_homeassistant_event(const std::string &event_name) { HomeassistantActionRequest resp; - resp.service = StringRef(event_name); + resp.service = ProgmemStringRef(StringRef(event_name)); resp.is_event = true; global_api_server->send_homeassistant_action(resp); } @@ -309,13 +309,13 @@ class CustomAPIDevice { */ void fire_homeassistant_event(const std::string &service_name, const std::map &data) { HomeassistantActionRequest resp; - resp.service = StringRef(service_name); + resp.service = ProgmemStringRef(StringRef(service_name)); resp.is_event = true; resp.data.init(data.size()); for (auto &it : data) { auto &kv = resp.data.emplace_back(); - kv.key = StringRef(it.first); - kv.value = StringRef(it.second); // data map lives until send completes + kv.key = ProgmemStringRef(StringRef(it.first)); + kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes } global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index e7798a5d267..8964e8ca352 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -116,42 +116,18 @@ template class HomeAssistantServiceCallAction final : public Act const Field *fields = this->fields_; const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_; - // Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send + // Lambda results must live until the send; the constant strings are encoded from flash where they are size_t lambda_count = 0; -#ifdef USE_ESP8266 - size_t flash_len = 0; -#endif - for (size_t i = 0; i < total; i++) { + for (size_t i = 0; i < total; i++) lambda_count += fields[i].fn != nullptr; -#ifdef USE_ESP8266 - if (fields[i].fn == nullptr) - flash_len += ESPHOME_strlen_P(fields[i].value); - if (fields[i].key != nullptr) - flash_len += ESPHOME_strlen_P(fields[i].key); -#endif - } FixedVector results; results.init(lambda_count); -#ifdef USE_ESP8266 - SmallBufferWithHeapFallback<128, char> flash_copy(flash_len); - char *cursor = flash_copy.get(); -#endif - auto string_ref = [&](const char *str) { -#ifdef USE_ESP8266 - size_t len = ESPHOME_strlen_P(str); - memcpy_P(cursor, str, len); - StringRef ref(cursor, len); - cursor += len; - return ref; -#else - return StringRef(str); -#endif - }; + auto string_ref = [](const char *str) { return ProgmemStringRef(str, ESPHOME_strlen_P(str)); }; auto value_ref = [&](const Field &field) { if (field.fn == nullptr) return string_ref(field.value); results.push_back(field.fn(x...)); - return StringRef(results.back()); + return ProgmemStringRef(StringRef(results.back())); }; auto fill = [&](FixedVector &dest, uint8_t count) { dest.init(count); diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index 507b098fb48..57ff616ca7c 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -99,8 +99,7 @@ ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(clie static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3; bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { - UserActionScratch scratch; - auto resp = service->encode_list_service_response(scratch); + auto resp = service->encode_list_service_response(); if (!this->client_->send_message(resp)) return false; // at_ is this service's index diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 0fdda5067ea..e01deccbc21 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -418,6 +418,18 @@ class ProtoEncode { std::memcpy(pos + 2, ref.c_str(), ref.size()); return pos + 2 + ref.size(); } + /// encode_short_string_force for a string that may be in flash (PROGMEM). + [[nodiscard]] static inline uint8_t *encode_short_progmem_string_force( + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const ProgmemStringRef &ref) { +#ifdef ESPHOME_DEBUG_API + assert(ref.size() < 128 && "encode_short_progmem_string_force: string exceeds max_data_length < 128"); +#endif + PROTO_ENCODE_CHECK_BOUNDS(pos, 2 + ref.size()); + pos[0] = tag; + pos[1] = static_cast(ref.size()); + progmem_memcpy(pos + 2, ref.progmem_ptr(), ref.size()); + return pos + 2 + ref.size(); + } /// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field. [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32( uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, uint32_t value) { @@ -481,6 +493,17 @@ class ProtoEncode { progmem_memcpy(pos, data, len); return pos + len; } + /// encode_progmem_bytes for a string field that may be in flash. + [[nodiscard]] static inline uint8_t *encode_progmem_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const ProgmemStringRef &ref) { + return encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, field_id, + reinterpret_cast(ref.progmem_ptr()), ref.size()); + } + [[nodiscard]] static inline uint8_t *encode_progmem_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const ProgmemStringRef &ref) { + return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, + reinterpret_cast(ref.progmem_ptr()), ref.size()); + } [[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t value) { pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); @@ -680,9 +703,20 @@ class DumpBuffer { return *this; } + /// Append len bytes of a PROGMEM string + DumpBuffer &append_p(const char *str, size_t len) { +#ifdef USE_ESP8266 + append_p_esp8266(str, len); +#else + append_impl_(str, len); +#endif + return *this; + } + #ifdef USE_ESP8266 /// Out-of-line ESP8266 PROGMEM append to avoid inlining strlen_P/memcpy_P at every call site void append_p_esp8266(const char *str); + void append_p_esp8266(const char *str, size_t len); #endif const char *c_str() const { return buf_; } diff --git a/esphome/components/api/user_services.cpp b/esphome/components/api/user_services.cpp index fad3cde29b9..a9836170640 100644 --- a/esphome/components/api/user_services.cpp +++ b/esphome/components/api/user_services.cpp @@ -1,47 +1,36 @@ #include "user_services.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::api { -StringRef UserServiceStatic::str_(size_t idx, std::span &scratch) const { +ProgmemStringRef UserServiceStatic::str_(size_t idx) const { const char *s = progmem_read_ptr(&this->strings_[idx]); if (s == nullptr) return {}; -#ifdef USE_ESP8266 - // Codegen sizes the scratch buffer for the largest service; the bound only guards other callers - if (scratch.empty()) - return {}; - size_t len = strnlen_P(s, scratch.size() - 1); - progmem_memcpy(scratch.data(), s, len); - scratch[len] = '\0'; - StringRef ref(scratch.data(), len); - scratch = scratch.subspan(len + 1); - return ref; -#else - return StringRef(s); -#endif + return ProgmemStringRef(s, ESPHOME_strlen_P(s)); } ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_( - std::span arg_types, std::span scratch) const { + std::span arg_types) const { ListEntitiesServicesResponse msg; - msg.name = this->str_(0, scratch); + msg.name = this->str_(0); msg.key = this->key_; msg.supports_response = this->supports_response_; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - msg.description = this->str_(1, scratch); + msg.description = this->str_(1); #endif msg.args.init(arg_types.size()); for (size_t i = 0; i < arg_types.size(); i++) { size_t base = USER_ACTION_HEADER_STRINGS + i * USER_ACTION_ARG_STRINGS; auto &arg = msg.args.emplace_back(); arg.type = arg_types[i]; - arg.name = this->str_(base, scratch); + arg.name = this->str_(base); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - arg.description = this->str_(base + 1, scratch); - arg.example = this->str_(base + 2, scratch); + arg.description = this->str_(base + 1); + arg.example = this->str_(base + 2); #endif } return msg; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 3b17bdb7bcd..057e04dd7a7 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -20,9 +20,8 @@ class APIServer; class UserServiceDescriptor { public: - /// Build the list-entities message. On ESP8266 the strings live in PROGMEM and are copied into - /// `scratch`, so the returned message is only valid while `scratch` is; other platforms ignore it. - virtual ListEntitiesServicesResponse encode_list_service_response(std::span scratch) = 0; + /// Build the list-entities message; its strings may point into flash (PROGMEM). + virtual ListEntitiesServicesResponse encode_list_service_response() = 0; virtual bool execute_service(const ExecuteServiceRequest &req) = 0; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -37,13 +36,6 @@ template T get_execute_arg_value(const ExecuteServiceArgument &arg); template enums::ServiceArgType to_service_arg_type(); -// Scratch buffer list-entities hands to encode_list_service_response(); only ESP8266 copies into it -#ifdef USE_ESP8266 -using UserActionScratch = std::array; -#else -using UserActionScratch = std::array; -#endif - // Non-template base for YAML-defined services so the list-entities encoder is compiled once. // All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in // __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then @@ -63,12 +55,9 @@ class UserServiceStatic : public UserServiceDescriptor { : strings_(strings), key_(key), supports_response_(supports_response) {} protected: - ListEntitiesServicesResponse encode_list_service_response_(std::span arg_types, - std::span scratch) const; - /// Reference table entry `idx`; nullptr gives an empty StringRef. - /// On ESP8266 the bytes are copied out of PROGMEM into `scratch` with a terminator, and the span - /// is advanced past the copy. - StringRef str_(size_t idx, std::span &scratch) const; + ListEntitiesServicesResponse encode_list_service_response_(std::span arg_types) const; + /// Table entry `idx` as it sits in flash; nullptr gives an empty string. + ProgmemStringRef str_(size_t idx) const; const char *const *strings_; // PROGMEM pointer table, read with progmem_read_ptr() uint32_t key_; @@ -79,9 +68,9 @@ template class UserServiceBase : public UserServiceStatic { public: using UserServiceStatic::UserServiceStatic; - ListEntitiesServicesResponse encode_list_service_response(std::span scratch) override { + ListEntitiesServicesResponse encode_list_service_response() override { std::array arg_types = {to_service_arg_type()...}; - return this->encode_list_service_response_(arg_types, scratch); + return this->encode_list_service_response_(arg_types); } bool execute_service(const ExecuteServiceRequest &req) override { @@ -125,9 +114,9 @@ template class UserServiceDynamic : public UserServiceDescriptor this->key_ = fnv1_hash(this->name_.c_str()); } - ListEntitiesServicesResponse encode_list_service_response(std::span /*scratch*/) override { + ListEntitiesServicesResponse encode_list_service_response() override { ListEntitiesServicesResponse msg; - msg.name = StringRef(this->name_); + msg.name = ProgmemStringRef(StringRef(this->name_)); msg.key = this->key_; msg.supports_response = enums::SUPPORTS_RESPONSE_NONE; // Dynamic services don't support responses yet std::array arg_types = {to_service_arg_type()...}; @@ -135,7 +124,7 @@ template class UserServiceDynamic : public UserServiceDescriptor for (size_t i = 0; i < sizeof...(Ts); i++) { auto &arg = msg.args.emplace_back(); arg.type = arg_types[i]; - arg.name = StringRef(this->arg_names_[i]); + arg.name = ProgmemStringRef(StringRef(this->arg_names_[i])); } return msg; } diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp index 6cf1c87eaa5..5c830c8c923 100644 --- a/esphome/components/homeassistant/button/homeassistant_button.cpp +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -5,6 +5,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -31,22 +32,21 @@ void HomeassistantButton::press_action() { } #endif - static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press"); - static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr char INPUT_PREFIX[] = "input_"; + // In flash on the ESP8266 and encoded from there + static const char SERVICE_BUTTON[] PROGMEM = "button.press"; + static const char SERVICE_INPUT_BUTTON[] PROGMEM = "input_button.press"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; api::HomeassistantActionRequest resp; - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = SERVICE_INPUT_BUTTON; - } else { - resp.service = SERVICE_BUTTON; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_BUTTON, sizeof("input_") - 1) == 0; + resp.service = + input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_BUTTON) : ProgmemStringRef::from_lit(SERVICE_BUTTON); resp.data.init(1); auto &entity_id_kv = resp.data.emplace_back(); - entity_id_kv.key = ENTITY_ID_KEY; - entity_id_kv.value = StringRef(this->entity_id_); + entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_)); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index df92aad38eb..73efc8e5922 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -5,6 +5,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -89,31 +90,29 @@ void HomeassistantNumber::control(float value) { this->publish_state(value); - static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value"); - static constexpr auto INPUT_NUMBER_SERVICE_NAME = StringRef::from_lit("input_number.set_value"); - static constexpr char INPUT_PREFIX[] = "input_"; - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr auto VALUE_KEY = StringRef::from_lit("value"); + // In flash on the ESP8266 and encoded from there + static const char NUMBER_SERVICE_NAME[] PROGMEM = "number.set_value"; + static const char INPUT_NUMBER_SERVICE_NAME[] PROGMEM = "input_number.set_value"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char VALUE_KEY[] PROGMEM = "value"; api::HomeassistantActionRequest resp; - // input_number entities have their own set_value action - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = INPUT_NUMBER_SERVICE_NAME; - } else { - resp.service = NUMBER_SERVICE_NAME; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, INPUT_NUMBER_SERVICE_NAME, sizeof("input_") - 1) == 0; + resp.service = input_helper ? ProgmemStringRef::from_lit(INPUT_NUMBER_SERVICE_NAME) + : ProgmemStringRef::from_lit(NUMBER_SERVICE_NAME); resp.data.init(2); auto &entity_id = resp.data.emplace_back(); - entity_id.key = ENTITY_ID_KEY; - entity_id.value = StringRef(this->entity_id_); + entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); auto &entity_value = resp.data.emplace_back(); - entity_value.key = VALUE_KEY; + entity_value.key = ProgmemStringRef::from_lit(VALUE_KEY); // Stack buffer - no heap allocation; %g produces shortest representation char value_buf[16]; buf_append_printf(value_buf, sizeof(value_buf), 0, "%g", value); - entity_value.value = StringRef(value_buf); + entity_value.value = ProgmemStringRef(StringRef(value_buf)); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp index 189a6b1f376..4f4fc7a3295 100644 --- a/esphome/components/homeassistant/select/homeassistant_select.cpp +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -6,6 +6,7 @@ #include "esphome/components/api/api_server.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -237,27 +238,26 @@ void HomeassistantSelect::control(size_t index) { } #endif - static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option"); - static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr auto OPTION_KEY = StringRef::from_lit("option"); - static constexpr char INPUT_PREFIX[] = "input_"; + // In flash on the ESP8266 and encoded from there + static const char SERVICE_SELECT[] PROGMEM = "select.select_option"; + static const char SERVICE_INPUT_SELECT[] PROGMEM = "input_select.select_option"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char OPTION_KEY[] PROGMEM = "option"; api::HomeassistantActionRequest resp; - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = SERVICE_INPUT_SELECT; - } else { - resp.service = SERVICE_SELECT; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_SELECT, sizeof("input_") - 1) == 0; + resp.service = + input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_SELECT) : ProgmemStringRef::from_lit(SERVICE_SELECT); resp.data.init(2); auto &entity_id = resp.data.emplace_back(); - entity_id.key = ENTITY_ID_KEY; - entity_id.value = StringRef(this->entity_id_); + entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); auto &option = resp.data.emplace_back(); - option.key = OPTION_KEY; - option.value = StringRef(this->option_at(index)); + option.key = ProgmemStringRef::from_lit(OPTION_KEY); + option.value = ProgmemStringRef(StringRef(this->option_at(index))); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 841f16df200..f6fbdc1ef48 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -1,6 +1,7 @@ #include "homeassistant_switch.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -47,21 +48,18 @@ void HomeassistantSwitch::write_state(bool state) { } #endif - static constexpr auto SERVICE_ON = StringRef::from_lit("homeassistant.turn_on"); - static constexpr auto SERVICE_OFF = StringRef::from_lit("homeassistant.turn_off"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + // In flash on the ESP8266 and encoded from there + static const char SERVICE_ON[] PROGMEM = "homeassistant.turn_on"; + static const char SERVICE_OFF[] PROGMEM = "homeassistant.turn_off"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; api::HomeassistantActionRequest resp; - if (state) { - resp.service = SERVICE_ON; - } else { - resp.service = SERVICE_OFF; - } + resp.service = state ? ProgmemStringRef::from_lit(SERVICE_ON) : ProgmemStringRef::from_lit(SERVICE_OFF); resp.data.init(1); auto &entity_id_kv = resp.data.emplace_back(); - entity_id_kv.key = ENTITY_ID_KEY; - entity_id_kv.value = StringRef(this->entity_id_); + entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_)); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp index 7155f6ee189..2a5598756a5 100644 --- a/esphome/components/homeassistant/text/homeassistant_text.cpp +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -6,6 +6,7 @@ #include "esphome/components/api/api_server.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -86,27 +87,26 @@ void HomeassistantText::control(const std::string &value) { } #endif - static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value"); - static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr auto VALUE_KEY = StringRef::from_lit("value"); - static constexpr char INPUT_PREFIX[] = "input_"; + // In flash on the ESP8266 and encoded from there + static const char SERVICE_TEXT[] PROGMEM = "text.set_value"; + static const char SERVICE_INPUT_TEXT[] PROGMEM = "input_text.set_value"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char VALUE_KEY[] PROGMEM = "value"; api::HomeassistantActionRequest resp; - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = SERVICE_INPUT_TEXT; - } else { - resp.service = SERVICE_TEXT; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_TEXT, sizeof("input_") - 1) == 0; + resp.service = + input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_TEXT) : ProgmemStringRef::from_lit(SERVICE_TEXT); resp.data.init(2); auto &entity_id_kv = resp.data.emplace_back(); - entity_id_kv.key = ENTITY_ID_KEY; - entity_id_kv.value = StringRef(this->entity_id_); + entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_)); auto &value_kv = resp.data.emplace_back(); - value_kv.key = VALUE_KEY; - value_kv.value = StringRef(value.data(), value.size()); + value_kv.key = ProgmemStringRef::from_lit(VALUE_KEY); + value_kv.value = ProgmemStringRef(value.data(), value.size()); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e0164c69245..3b499594944 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -322,7 +322,6 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 -#define API_USER_ACTION_STRINGS_SCRATCH_SIZE 64 #define API_WIZARD_DATA_SIZE 1 #define API_WIZARD_INPUT_COUNT 1 #define MAX_API_CONNECTIONS 6 diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index e9e31411e42..7deea8ff664 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -189,24 +190,6 @@ inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { r 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) { @@ -241,6 +224,49 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) { str.append(rhs.c_str(), rhs.size()); return str; } + +/// A string that may live in flash (on ESP8266): pointer and length, with no byte-reading operations. +/// progmem_ptr() is safe as a "%s" log argument; compare the contents with equals(). +class ProgmemStringRef { + public: + constexpr ProgmemStringRef() = default; + constexpr ProgmemStringRef(const char *s, size_t len) : ref_(s, len) {} + constexpr explicit ProgmemStringRef(const StringRef &s) : ref_(s) {} + /// A PROGMEM char array, with its length known at compile time + template static constexpr ProgmemStringRef from_lit(const char (&s)[N]) { return {s, N - 1}; } + + constexpr const char *progmem_ptr() const { return this->ref_.c_str(); } + constexpr size_t size() const { return this->ref_.size(); } + constexpr bool empty() const { return this->ref_.empty(); } + + bool equals(const StringRef &other) const { +#ifdef USE_ESP8266 + // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes + return other.size() == this->ref_.size() && + (other.empty() || memcmp_P(other.c_str(), this->ref_.c_str(), other.size()) == 0); +#else + return other == this->ref_; +#endif + } + + // Remove before 2027.5.0 + [[deprecated( + "May point to flash; use progmem_ptr() or the owner's accessors. Removed in 2027.5.0")]] constexpr const char * + c_str() const { + return this->ref_.c_str(); + } + + private: + StringRef ref_; +}; + +#ifdef USE_ESP8266 +inline bool operator==(const StringRef &lhs, const ProgmemStringRef &rhs) { return rhs.equals(lhs); } +inline bool operator!=(const StringRef &lhs, const ProgmemStringRef &rhs) { return !rhs.equals(lhs); } +inline bool operator==(const std::string &lhs, const ProgmemStringRef &rhs) { return rhs.equals(StringRef(lhs)); } +inline bool operator!=(const std::string &lhs, const ProgmemStringRef &rhs) { return !rhs.equals(StringRef(lhs)); } +#endif + // String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef) // Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation. namespace internal { diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index a143a850326..2f601f5c947 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -536,16 +536,19 @@ def create_field_type_info( f"track_presence on field '{field.name}' has no effect; it requires " "a non-repeated message field in a message that is decoded" ) - if get_field_opt(field, pb.progmem, False) and ( - field.label == FieldDescriptorProto.LABEL_REPEATED - or field.type != 12 - or not get_field_opt(field, pb.pointer_to_buffer, False) - or get_field_opt(field, pb.fixed_array_size) is not None - ): - raise ValueError( - f"progmem on field '{field.name}' requires a non-repeated bytes field " - "with pointer_to_buffer" + if get_field_opt(field, pb.progmem, False): + is_pointer_bytes = ( + field.type == FieldDescriptorProto.TYPE_BYTES + and get_field_opt(field, pb.pointer_to_buffer, False) + and get_field_opt(field, pb.fixed_array_size) is None ) + if field.label == FieldDescriptorProto.LABEL_REPEATED or not ( + field.type == FieldDescriptorProto.TYPE_STRING or is_pointer_bytes + ): + raise ValueError( + f"progmem on field '{field.name}' requires a non-repeated string field, " + "or a bytes field with pointer_to_buffer" + ) if field.label == FieldDescriptorProto.LABEL_REPEATED: # Check if this is a packed_buffer field (zero-copy packed repeated) if get_field_opt(field, pb.packed_buffer, False): @@ -1155,6 +1158,11 @@ class PointerToBufferTypeBase(TypeInfo): """Get the wire type for this field.""" return WireType.LENGTH_DELIMITED # Uses wire type 2 + @property + def progmem(self) -> bool: + """Whether the data is in flash, so encode and dump copy it with progmem_memcpy.""" + return get_field_opt(self._field, pb.progmem, False) + def get_estimated_size(self) -> int: # field ID + length varint + typical data (assume small for pointer fields) return self.calculate_field_id_size() + 2 + 16 @@ -1168,11 +1176,6 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): reference_type = "const uint8_t*" const_reference_type = "const uint8_t*" - @property - def progmem(self) -> bool: - """Whether the data is in flash, so encode and dump copy it with progmem_memcpy.""" - return get_field_opt(self._field, pb.progmem, False) - @property def public_content(self) -> list[str]: # Use uint16_t for length - max packet size is well below 65535 @@ -1252,6 +1255,8 @@ class PointerToStringBufferType(PointerToBufferTypeBase): @property def public_content(self) -> list[str]: + if self.progmem: + return [f"ProgmemStringRef {self.field_name}{{}};"] if self._starts_null: return [ f"StringRef {self.field_name}{{nullptr, 0}}; // null until set, encode only" @@ -1267,9 +1272,19 @@ class PointerToStringBufferType(PointerToBufferTypeBase): ) tag = self.calculate_tag() if tag < 128: - return _encode_call( - "encode_short_string_force", str(tag), f"this->{self.field_name}" + encode_fn = ( + "encode_short_progmem_string_force" + if self.progmem + else "encode_short_string_force" ) + return _encode_call(encode_fn, str(tag), f"this->{self.field_name}") + if self.progmem: + return _encode_call( + "encode_progmem_string", + str(self.number), + f"this->{self.field_name}", + force=self.force, + ) if result := self._encode_bytes_with_precomputed_tag( f"this->{self.field_name}.c_str()", f"this->{self.field_name}.size()", @@ -1297,6 +1312,8 @@ class PointerToStringBufferType(PointerToBufferTypeBase): @property def dump_content(self) -> str: + if self.progmem: + return f'dump_progmem_string_field(out, ESPHOME_PSTR("{self.name}"), this->{self.field_name});' return f'dump_field(out, ESPHOME_PSTR("{self.name}"), this->{self.field_name});' def get_size_calculation(self, name: str, force: bool = False) -> str: @@ -3246,8 +3263,9 @@ namespace esphome::api { #ifdef USE_ESP8266 // Out-of-line to avoid inlining strlen_P/memcpy_P at every call site -void DumpBuffer::append_p_esp8266(const char *str) { - size_t len = strlen_P(str); +void DumpBuffer::append_p_esp8266(const char *str) { this->append_p_esp8266(str, strlen_P(str)); } + +void DumpBuffer::append_p_esp8266(const char *str, size_t len) { size_t space = CAPACITY - 1 - pos_; if (len > space) len = space; @@ -3368,6 +3386,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co progmem_memcpy(data_buf, data, len); dump_bytes_field(out, field_name, data_buf, len, indent); } + +// Helper for string fields in flash +static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, + int indent = 2) { + append_field_prefix(out, field_name, indent); + out.append("'").append_p(value.progmem_ptr(), value.size()).append("'\\n"); +} #pragma GCC diagnostic pop """ diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index 69c9605a675..2b7662b6655 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -8,18 +8,13 @@ import pytest from esphome.components.api import ( _action_strings, - _action_strings_size, _has_action_metadata, - _validate_esp8266_action_strings, validate_variable, ) from esphome.config_validation import Invalid -from esphome.const import PlatformFramework from esphome.core import CORE from esphome.cpp_generator import safe_exp from esphome.helpers import fnv1_hash -from tests.component_tests.helpers import get_define_value -from tests.component_tests.types import SetCoreConfigCallable CONFIG = "tests/component_tests/api/test_action_metadata.yaml" CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml" @@ -45,7 +40,6 @@ def test_metadata_is_emitted_as_progmem_table( ) 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( @@ -69,15 +63,13 @@ def test_esp8266_strings_are_shared_progmem_arrays( ) -def test_esp8266_sizes_scratch_buffer_for_largest_action( +def test_esp8266_encodes_action_strings_from_flash( generate_main: Callable[[str | Path], str], ) -> None: - """ESP8266 gets a scratch buffer define equal to the byte total of the largest action.""" - generate_main(CONFIG_ESP8266) + """ESP8266 uses the same flash string table.""" + main_cpp = generate_main(CONFIG_ESP8266) - # play_buzzer: name, description, two variable names, one description, one example, - # each with a terminator - assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") == "117" + assert "api_action0_strings[] PROGMEM" in main_cpp def test_shorthand_variables_emit_no_metadata( @@ -113,36 +105,8 @@ def test_variable_rejects_invalid(value: object) -> None: validate_variable(value) -def _oversized_action_config() -> dict: - return { - "actions": [ - { - "action": "big", - "description": "x" * 300, - "variables": {"a": {"type": "string", "example": "y" * 300}}, - } - ] - } - - -def test_esp8266_rejects_actions_over_string_budget( - set_core_config: SetCoreConfigCallable, -) -> None: - set_core_config(PlatformFramework.ESP8266_ARDUINO) - with pytest.raises(Invalid, match="ESP8266 allows at most 384 bytes"): - _validate_esp8266_action_strings(_oversized_action_config()) - - -def test_other_platforms_have_no_string_budget( - set_core_config: SetCoreConfigCallable, -) -> None: - set_core_config(PlatformFramework.ESP32_IDF) - config = _oversized_action_config() - assert _validate_esp8266_action_strings(config) is config - - -def test_empty_metadata_is_unset_and_not_counted() -> None: - """An empty description or example emits nullptr and takes no scratch space.""" +def test_empty_metadata_is_unset() -> None: + """An empty description or example emits nullptr.""" conf = { "action": "a", "description": "", @@ -150,8 +114,6 @@ def test_empty_metadata_is_unset_and_not_counted() -> None: } strings = _action_strings(conf, has_metadata=True) assert strings == ["a", None, "b", None, "ex"] - # Every emitted string counts its terminator: "a" + "b" + "ex" - assert _action_strings_size(strings) == 2 + 2 + 3 def test_empty_metadata_does_not_enable_the_define() -> None: diff --git a/tests/components/core/test_progmem_string_ref.cpp b/tests/components/core/test_progmem_string_ref.cpp new file mode 100644 index 00000000000..1d996f36db7 --- /dev/null +++ b/tests/components/core/test_progmem_string_ref.cpp @@ -0,0 +1,30 @@ +#include + +#include "esphome/core/progmem.h" +#include "esphome/core/string_ref.h" + +namespace esphome::core::testing { + +TEST(ProgmemStringRef, EqualsComparesLengthAndContent) { + ProgmemStringRef ref("Kitchen", 7); + EXPECT_TRUE(ref.equals(StringRef("Kitchen"))); + EXPECT_FALSE(ref.equals(StringRef("Kitche"))); + EXPECT_FALSE(ref.equals(StringRef("Kitchens"))); + EXPECT_FALSE(ref.equals(StringRef("Bedroom"))); +} + +TEST(ProgmemStringRef, FromLitTakesTheLengthAtCompileTime) { + static const char NAME[] PROGMEM = "entity_id"; + constexpr ProgmemStringRef ref = ProgmemStringRef::from_lit(NAME); + static_assert(ref.size() == 9); + EXPECT_TRUE(ref.equals(StringRef("entity_id"))); +} + +TEST(ProgmemStringRef, DefaultIsEmpty) { + ProgmemStringRef ref; + EXPECT_TRUE(ref.empty()); + EXPECT_EQ(ref.size(), 0u); + EXPECT_TRUE(ref.equals(StringRef(""))); +} + +} // namespace esphome::core::testing diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 5e29b06ef07..9d8eab69b90 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -408,8 +408,39 @@ def test_progmem_rejected_in_a_decoded_message() -> None: def test_progmem_rejected_on_non_bytes_field() -> None: - """(progmem) only applies to bytes fields.""" - field = _field(9) + """(progmem) only applies to string and bytes fields.""" + field = _field(13) + field.options.Extensions[pb.progmem] = True + with pytest.raises(ValueError, match="progmem on field 'value'"): + create_field_type_info(field, needs_decode=False) + + +@pytest.mark.parametrize( + ("force", "max_len", "encode_fn"), + [ + (True, 120, "encode_short_progmem_string_force("), + (False, None, "encode_progmem_string("), + (True, None, "encode_progmem_string_force("), + ], +) +def test_progmem_string_field_copies_from_flash( + force: bool, max_len: int | None, encode_fn: str +) -> None: + """A (progmem) string field is a ProgmemStringRef that encodes and dumps through the progmem helpers.""" + field = _field(9, force=force) + field.options.Extensions[pb.progmem] = True + if max_len is not None: + field.options.Extensions[pb.max_data_length] = max_len + ti = create_field_type_info(field, needs_decode=False) + assert "ProgmemStringRef value{};" in ti.public_content + assert encode_fn in ti.encode_content + assert "encode_string" not in ti.encode_content + assert "dump_progmem_string_field(" in ti.dump_content + + +def test_progmem_rejected_on_repeated_string() -> None: + """(progmem) on a repeated string field fails instead of silently using memcpy.""" + field = _field(9, repeated=True) field.options.Extensions[pb.progmem] = True with pytest.raises(ValueError, match="progmem on field 'value'"): create_field_type_info(field, needs_decode=False) From a07a1ab7282415f26f5e339aef83d2b615065d2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 17:43:34 -1000 Subject: [PATCH 411/651] [qmi8658] Keep the range name tables in flash (#20714) --- esphome/components/qmi8658/qmi8658.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index 4f35d5bf365..5c9402c2018 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -1,11 +1,16 @@ #include "qmi8658.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/progmem.h" namespace esphome::qmi8658 { ESPHOME_LOG_TAG(TAG, "qmi8658"); +PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±4g", "±8g", "±16g"); +PROGMEM_STRING_TABLE(GyroRangeStrings, "±16°/s", "±32°/s", "±64°/s", "±128°/s", "±256°/s", "±512°/s", "±1024°/s", + "±2048°/s"); + // Acceleration scale (g per LSB), indexed by accel_range_ >> 4. // Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts). static constexpr float ACCEL_SCALE[] = { @@ -86,12 +91,10 @@ void QMI8658Component::dump_config() { return; } - static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; - static constexpr const char *const GYRO_RANGE_STRS[] = {"±16°/s", "±32°/s", "±64°/s", "±128°/s", - "±256°/s", "±512°/s", "±1024°/s", "±2048°/s"}; - - ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[this->accel_range_ >> 4]); - ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[this->gyro_range_ >> 4]); + ESP_LOGCONFIG(TAG, " Accel range : %s", + LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_ >> 4, AccelRangeStrings::LAST_INDEX))); + ESP_LOGCONFIG(TAG, " Gyro range : %s", + LOG_STR_ARG(GyroRangeStrings::get_log_str(this->gyro_range_ >> 4, GyroRangeStrings::LAST_INDEX))); MotionComponent::dump_config(); } From a522553e8220c4d1fb96210003c290210c1dfd42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 17:43:54 -1000 Subject: [PATCH 412/651] [lsm6ds] Keep the range and chip names in flash (#20718) --- esphome/components/lsm6ds/lsm6ds.cpp | 74 ++++++++++++++-------------- esphome/components/lsm6ds/lsm6ds.h | 3 +- 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index b02a610c0a8..0bd14bd0875 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -1,15 +1,42 @@ #include "lsm6ds.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/progmem.h" namespace esphome::lsm6ds { ESPHOME_LOG_TAG(TAG, "lsm6ds"); -static const struct { - uint8_t who_am_i; - const char *const name; -} CHIP_IDS[] = {{0x69, "LSMDSO"}, {0x6A, "LSM6DS3"}}; +// Indexed by the accel range field, which is not in ascending order (datasheet Table 3) +PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±16g", "±4g", "±8g"); + +static const LogString *gyro_range_to_str(LSM6DSGyroRange range) { + switch (range) { + case LSM6DS_GYRO_RANGE_125: + return LOG_STR("±125dps"); + case LSM6DS_GYRO_RANGE_250: + return LOG_STR("±250dps"); + case LSM6DS_GYRO_RANGE_500: + return LOG_STR("±500dps"); + case LSM6DS_GYRO_RANGE_1000: + return LOG_STR("±1000dps"); + case LSM6DS_GYRO_RANGE_2000: + return LOG_STR("±2000dps"); + default: + return LOG_STR("unknown"); + } +} + +static const LogString *chip_name_from_who_am_i(uint8_t who_am_i) { + switch (who_am_i) { + case 0x69: + return LOG_STR("LSMDSO"); + case 0x6A: + return LOG_STR("LSM6DS3"); + default: + return nullptr; + } +} void LSM6DSComponent::setup() { MotionComponent::setup(); @@ -19,19 +46,13 @@ void LSM6DSComponent::setup() { this->mark_failed(); return; } - const char *chip_name = nullptr; - for (const auto &chip : CHIP_IDS) { - if (chip.who_am_i == who_am_i) { - chip_name = chip.name; - break; - } - } + const LogString *chip_name = chip_name_from_who_am_i(who_am_i); if (chip_name == nullptr) { ESP_LOGE(TAG, "Unknown WHO_AM_I: 0x%02X", who_am_i); this->mark_failed(LOG_STR("Unknown WHO_AM_I value")); return; } - ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", chip_name, who_am_i); + ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", LOG_STR_ARG(chip_name), who_am_i); this->chip_name_ = chip_name; // 2. Software reset — clears all registers to defaults @@ -85,34 +106,10 @@ void LSM6DSComponent::dump_config() { ESP_LOGCONFIG(TAG, "LSM6DS IMU:\n" " Chip type: %s\n", - this->chip_name_); + LOG_STR_ARG(this->chip_name_ != nullptr ? this->chip_name_ : LOG_STR("Unknown"))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - // Accel range — index into the sensitivity table (datasheet Table 3) - static const char *const ACCEL_RANGE_STR[] = {"±2g", "±16g", "±4g", "±8g"}; - - const char *gyro_str; - switch (this->gyro_range_) { - case LSM6DS_GYRO_RANGE_125: - gyro_str = "±125dps"; - break; - case LSM6DS_GYRO_RANGE_250: - gyro_str = "±250dps"; - break; - case LSM6DS_GYRO_RANGE_500: - gyro_str = "±500dps"; - break; - case LSM6DS_GYRO_RANGE_1000: - gyro_str = "±1000dps"; - break; - case LSM6DS_GYRO_RANGE_2000: - gyro_str = "±2000dps"; - break; - default: - gyro_str = "unknown"; - break; - } auto accel_odr = this->accel_odr_ == 0 ? 0 : 13 * (1 << (this->accel_odr_ - 1)); auto gyro_odr = this->gyro_odr_ == 0 ? 0 : 13 * (1 << (this->gyro_odr_ - 1)); ESP_LOGCONFIG(TAG, @@ -120,7 +117,8 @@ void LSM6DSComponent::dump_config() { " Accel data rate : %dHz\n" " Gyro range : %s\n" " Gyro data rate : %dHz", - ACCEL_RANGE_STR[this->accel_range_], accel_odr, gyro_str, gyro_odr); + LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX)), + accel_odr, LOG_STR_ARG(gyro_range_to_str(this->gyro_range_)), gyro_odr); } // update_data() diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index 47d6f55939e..2cc282ba124 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/log.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/motion/motion_component.h" @@ -97,7 +98,7 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } protected: - const char *chip_name_{"Unknown"}; + const LogString *chip_name_{nullptr}; bool update_data(motion::MotionData &data) override; LSM6DSAccelRange accel_range_{LSM6DS_ACCEL_RANGE_4G}; From 39705a65e3e5a010b6ae0a6cb54b560ebeaee330 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 17:44:46 -1000 Subject: [PATCH 413/651] [bmi270] Keep the range name and scale tables in flash (#20715) --- esphome/components/bmi270/bmi270.cpp | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index 37f718fb7eb..6f14598a5f4 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -2,6 +2,7 @@ #include "bmi270_config.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/progmem.h" namespace esphome::bmi270 { @@ -11,6 +12,9 @@ using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device defi ESPHOME_LOG_TAG(TAG, "bmi270"); +PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±4g", "±8g", "±16g"); +PROGMEM_STRING_TABLE(GyroRangeStrings, "±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"); + #if defined(USE_ARDUINO) && !defined(USE_ESP32) static constexpr size_t MAX_I2C_BUFFER_SIZE = 32; #else @@ -273,11 +277,10 @@ void BMI270Component::dump_config() { return; } - static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; - static constexpr const char *const GYRO_RANGE_STRS[] = {"±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"}; - - ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); - ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); + ESP_LOGCONFIG(TAG, " Accel range : %s", + LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX))); + ESP_LOGCONFIG(TAG, " Gyro range : %s", + LOG_STR_ARG(GyroRangeStrings::get_log_str(this->gyro_range_, GyroRangeStrings::LAST_INDEX))); #ifdef USE_BMI270_BMM150 if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)", @@ -315,7 +318,8 @@ bool BMI270Component::update_data(motion::MotionData &data) { } // Scale factor: LSB/g depends on range // raw is a signed 16-bit value; full-scale = range_g * 2^15 lsb - static constexpr float ACCEL_SCALE[] = { + // In PROGMEM; each read is a 32-bit load, which is safe from flash on ESP8266 + alignas(4) static constexpr float ACCEL_SCALE[] PROGMEM = { 2.0f / 32768.0f, 4.0f / 32768.0f, 8.0f / 32768.0f, @@ -329,7 +333,7 @@ bool BMI270Component::update_data(motion::MotionData &data) { // Gyroscope: registers 0x12–0x17 (6 bytes) // Scale: full-scale range / 2^15 - static constexpr float GYRO_SCALE[] = { + alignas(4) static constexpr float GYRO_SCALE[] PROGMEM = { 2000.0f / 32768.0f, 1000.0f / 32768.0f, 500.0f / 32768.0f, 250.0f / 32768.0f, 125.0f / 32768.0f, }; static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; From 41e82b029553810f581a4de0ef2232c669fb48ab Mon Sep 17 00:00:00 2001 From: Jos van Eijndhoven Date: Thu, 15 Oct 2026 06:16:09 +0200 Subject: [PATCH 414/651] [hdmi_cec] Add HDMI-CEC bus component (part 1: bus core) (#9189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stéphane Lepin Co-authored-by: Stéphane Lepin Co-authored-by: Niko Co-authored-by: Minims Co-authored-by: Nicholas Pace Co-authored-by: Nicholas Pace <3300476+segfly@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 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/esp32/__init__.py | 2 +- esphome/components/hdmi_cec/__init__.py | 141 ++++++ esphome/components/hdmi_cec/hdmi_cec.cpp | 450 ++++++++++++++++++ esphome/components/hdmi_cec/hdmi_cec.h | 218 +++++++++ esphome/core/defines.h | 1 + tests/components/hdmi_cec/common.yaml | 18 + .../components/hdmi_cec/test.bk72xx-ard.yaml | 5 + tests/components/hdmi_cec/test.esp32-idf.yaml | 5 + .../components/hdmi_cec/test.esp8266-ard.yaml | 5 + .../components/hdmi_cec/test.rp2040-ard.yaml | 5 + 11 files changed, 850 insertions(+), 1 deletion(-) create mode 100644 esphome/components/hdmi_cec/__init__.py create mode 100644 esphome/components/hdmi_cec/hdmi_cec.cpp create mode 100644 esphome/components/hdmi_cec/hdmi_cec.h create mode 100644 tests/components/hdmi_cec/common.yaml create mode 100644 tests/components/hdmi_cec/test.bk72xx-ard.yaml create mode 100644 tests/components/hdmi_cec/test.esp32-idf.yaml create mode 100644 tests/components/hdmi_cec/test.esp8266-ard.yaml create mode 100644 tests/components/hdmi_cec/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 6adf2a93594..ae813fa1be2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -257,6 +257,7 @@ esphome/components/hc8/* @omartijn esphome/components/hdc2010/* @optimusprimespace @ssieb esphome/components/hdc2080/* @G-Pereira @jesserockz esphome/components/hdc302x/* @joshuasing +esphome/components/hdmi_cec/* @JosVanEijndhoven esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 25a00b522c0..aae1a4c41f6 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -243,7 +243,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back "esp_driver_dac", # DAC driver - only needed by esp32_dac component - "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs + "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, hdmi_cec, opentherm, Arduino BLE libs "esp_driver_i2c", # I2C driver - re-included by i2c; esp32-camera pulls it back itself "esp_driver_i2s", # I2S driver - only needed by i2s_audio component "esp_driver_ledc", # LEDC PWM driver - re-included by ledc; esp32-camera pulls it back itself diff --git a/esphome/components/hdmi_cec/__init__.py b/esphome/components/hdmi_cec/__init__.py new file mode 100644 index 00000000000..c718968e201 --- /dev/null +++ b/esphome/components/hdmi_cec/__init__.py @@ -0,0 +1,141 @@ +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components.esp32 import include_builtin_idf_component +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_DATA, + CONF_ID, + CONF_ON_MESSAGE, + CONF_PIN, + CONF_SOURCE, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RP2, + PLATFORM_RTL87XX, +) +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@JosVanEijndhoven"] +DOMAIN = "hdmi_cec" + +MAX_LOGICAL_ADDRESS = 15 +MAX_PAYLOAD_LENGTH = 15 +CONF_PROMISCUOUS_MODE = "promiscuous_mode" +CONF_MONITOR_MODE = "monitor_mode" +CONF_DESTINATION = "destination" +CONF_OPCODE = "opcode" + +hdmi_cec_ns = cg.esphome_ns.namespace("hdmi_cec") +HDMICEC = hdmi_cec_ns.class_("HDMICEC", cg.Component) +_PAYLOAD_SPAN = cg.std_span.template(cg.uint8.operator("const")) +_PAYLOAD_VECTOR = cg.std_vector.template(cg.uint8) +_MESSAGE_ARGS = [ + (cg.uint8, "source"), + (cg.uint8, "destination"), + (_PAYLOAD_SPAN, "data"), +] + +_request_message_callback_slot = cg.slot_counter("HDMI_CEC_MESSAGE_CALLBACK_COUNT") + +logical_address = cv.int_range(min=0, max=MAX_LOGICAL_ADDRESS) +payload = cv.All(cv.ensure_list(cv.hex_uint8_t), cv.Length(max=MAX_PAYLOAD_LENGTH)) +payload_prefix = cv.All(payload, cv.Length(min=1)) + + +def _validate_message_filter(config: ConfigType) -> ConfigType: + # The opcode is the first payload byte: fold it into the data prefix + if (opcode := config.pop(CONF_OPCODE, None)) is not None: + data = config.setdefault(CONF_DATA, [opcode]) + if data[0] != opcode: + raise cv.Invalid( + f"opcode 0x{opcode:02X} differs from the first data byte 0x{data[0]:02X}" + ) + return config + + +def _message_filter(conf: ConfigType) -> str | None: + """The C++ condition an on_message automation runs under, or None for every frame.""" + checks = [] + if (source := conf.get(CONF_SOURCE)) is not None: + checks.append(f"source == {source}") + if (destination := conf.get(CONF_DESTINATION)) is not None: + checks.append(f"destination == {destination}") + if data := conf.get(CONF_DATA): + checks.append(f"data.size() >= {len(data)}") + checks.extend(f"data[{i}] == 0x{byte:02X}" for i, byte in enumerate(data)) + return " && ".join(checks) or None + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HDMICEC), + cv.Required(CONF_PIN): pins.internal_gpio_input_pullup_pin_schema, + cv.Required(CONF_ADDRESS): logical_address, + cv.Optional(CONF_PROMISCUOUS_MODE, default=False): cv.boolean, + cv.Optional(CONF_MONITOR_MODE, default=False): cv.boolean, + cv.Optional(CONF_ON_MESSAGE): automation.validate_automation( + { + cv.Optional(CONF_SOURCE): logical_address, + cv.Optional(CONF_DESTINATION): logical_address, + cv.Optional(CONF_OPCODE): cv.hex_uint8_t, + cv.Optional(CONF_DATA): payload_prefix, + }, + _validate_message_filter, + ), + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), +) + + +async def to_code(config: ConfigType) -> None: + if CORE.is_esp32: + # The acknowledge bit is released from a general purpose timer, left out of the build by default + include_builtin_idf_component("esp_driver_gptimer") + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + var = cg.new_Pvariable(config[CONF_ID], pin, config[CONF_ADDRESS]) + await cg.register_component(var, config) + if config[CONF_PROMISCUOUS_MODE]: + cg.add(var.set_promiscuous_mode(True)) + if config[CONF_MONITOR_MODE]: + cg.add(var.set_monitor_mode(True)) + + for conf in config.get(CONF_ON_MESSAGE, []): + _request_message_callback_slot() + await automation.build_callback_automation( + var, + "add_on_message_callback", + _MESSAGE_ARGS, + conf, + when=_message_filter(conf), + ) + + +automation.register_apply_action( + "hdmi_cec.send", + cv.Schema( + { + cv.GenerateID(): cv.use_id(HDMICEC), + cv.Required(CONF_DESTINATION): cv.templatable(logical_address), + cv.Required(CONF_DATA): cv.templatable(payload), + } + ), + automation.ApplyCall( + "send({}, {})", + ((CONF_DESTINATION, cg.uint8), (CONF_DATA, _PAYLOAD_VECTOR)), + ), +) diff --git a/esphome/components/hdmi_cec/hdmi_cec.cpp b/esphome/components/hdmi_cec/hdmi_cec.cpp new file mode 100644 index 00000000000..82e354dbae7 --- /dev/null +++ b/esphome/components/hdmi_cec/hdmi_cec.cpp @@ -0,0 +1,450 @@ +#include "hdmi_cec.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#ifdef USE_ESP8266 +#include +#endif +#ifdef USE_RP2040 +#include +#endif + +namespace esphome::hdmi_cec { + +ESPHOME_LOG_TAG(TAG, "hdmi_cec"); + +#if defined(USE_ESP8266) || defined(USE_RP2040) +// The timer interrupt of these platforms carries no argument +static HDMICEC *ack_timer_owner = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + +// Bit timing from the HDMI 1.4 CEC specification, in microseconds +static constexpr uint32_t START_BIT_MIN_US = 3500; // a low period at least this long is a start bit +static constexpr uint32_t START_BIT_NOM_US = 3700; +static constexpr uint32_t START_BIT_HIGH_US = 800; +static constexpr uint32_t HIGH_BIT_MIN_US = 400; // shortest low period of a logical 1 +static constexpr uint32_t HIGH_BIT_US = 600; // nominal low period of a logical 1 +static constexpr uint32_t LOW_BIT_US = 1500; // nominal low period of a logical 0 +static constexpr uint32_t SAFE_SAMPLE_US = 1050; // the line is sampled here; a longer low period is a logical 0 +static constexpr uint32_t TOTAL_BIT_US = 2400; +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_RECEIVE = TOTAL_BIT_US * 5; // also for a new initiator +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_FAIL = TOTAL_BIT_US * 3; +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS = TOTAL_BIT_US * 7; +static constexpr uint32_t ABORTED_FRAME_US = TOTAL_BIT_US * 15; // no edge for this long: our frame was cut short +static constexpr uint8_t MAX_ATTEMPTS = 8; // CEC allows 5, but a busy bus counts as an attempt here too + +Frame::Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span payload) { + this->data_[0] = ((initiator_addr & 0xf) << 4) | (destination_addr & 0xf); + this->size_ = 1 + payload.size(); + std::copy(payload.begin(), payload.end(), this->data_.begin() + 1); +} + +void HDMICEC::setup() { + // The line is open drain with a weak pull-up: driven low to send, released to read + this->pin_->setup(); + this->isr_pin_ = this->pin_->to_isr(); + this->set_pin_input_high_(); + this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE; + if (!this->setup_ack_timer_()) { + ESP_LOGE(TAG, "Failed to set up the acknowledge timer"); + this->mark_failed(); + return; + } + this->pin_->attach_interrupt(HDMICEC::gpio_isr_s, this, gpio::INTERRUPT_ANY_EDGE); + // loop() only has work once the interrupt queues a frame or send() queues one + this->disable_loop(); +} + +void HDMICEC::dump_config() { + ESP_LOGCONFIG(TAG, + "HDMI-CEC:\n" + " Address: 0x%X\n" + " Monitor mode: %s\n" + " Promiscuous mode: %s", + this->address_, YESNO(this->monitor_mode_), YESNO(this->promiscuous_mode_)); + LOG_PIN(" Pin: ", this->pin_); +} + +void HDMICEC::loop() { + const uint8_t dropped = atomic_get(this->dropped_frames_); + if (dropped != this->reported_dropped_frames_) { + const uint8_t count = dropped - this->reported_dropped_frames_; + ESP_LOGW(TAG, "%u received frame%s dropped (queue full or oversized)", count, + count == 1 ? LOG_STR_LITERAL("") : LOG_STR_LITERAL("s")); + this->reported_dropped_frames_ = dropped; + } + // One inbound frame per pass keeps the time spent in the automations bounded + if (const Frame *frame = this->frames_queue_.front()) { +#ifdef ESPHOME_LOG_HAS_DEBUG + char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; + ESP_LOGD(TAG, "Received: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); +#endif +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + this->message_callbacks_.call(frame->initiator_addr(), frame->destination_addr(), frame->payload()); +#endif + this->frames_queue_.pop_front(); + } + // A frame being sent stays at the front of the queue until its attempts are over + if (!this->send_queue_.is_empty()) { + this->transmit_message_(); + } + if (this->frames_queue_.is_empty() && this->send_queue_.is_empty()) { + this->disable_loop(); + } +} + +bool HDMICEC::send(uint8_t source, uint8_t destination, std::span payload) { + if (this->monitor_mode_) { + ESP_LOGW(TAG, "Monitor mode, not sending 0x%X->0x%X", source, destination); + return false; + } + if (source > 0xf || destination > 0xf) { + ESP_LOGE(TAG, "Send abort: logical address 0x%X->0x%X out of range", source, destination); + return false; + } + if (payload.size() > Frame::MAX_PAYLOAD_LENGTH) { + ESP_LOGE(TAG, "Send abort 0x%X->0x%X: payload of %zu bytes exceeds %zu", source, destination, payload.size(), + Frame::MAX_PAYLOAD_LENGTH); + return false; + } + Frame *frame = this->send_queue_.back(); + if (frame == nullptr) { + ESP_LOGW(TAG, "Send queue full, frame 0x%X->0x%X dropped", source, destination); + return false; + } + *frame = Frame(source, destination, payload); + this->send_queue_.push_back(); + this->enable_loop(); + return true; +} + +void HDMICEC::transmit_message_() { + const uint32_t now = micros(); + const uint32_t since_last_edge = now - atomic_get(this->last_edge_us_); + if (this->transmitting_) { + // The interrupt sees our own frame; wait for its end of message bit, or give up when the + // bus went quiet without one + if (!atomic_get(this->eom_received_) && since_last_edge < ABORTED_FRAME_US) { + return; + } + this->transmitting_ = false; + atomic_set(this->eom_received_, false); // the end of our own frame is not a foreign one + const Frame *frame = this->send_queue_.front(); + // A broadcast is acknowledged by nobody; a directed frame on every byte + const bool bytes_ok = this->n_bytes_received_ == frame->size(); + const bool acks_ok = this->n_acks_received_ == (frame->is_broadcast() ? 0 : frame->size()); + if (bytes_ok && acks_ok) { + ESP_LOGD(TAG, "Send frame success in %u attempt%s", this->transmit_attempts_, + this->transmit_attempts_ > 1 ? LOG_STR_LITERAL("s") : LOG_STR_LITERAL("")); + } else if (!bytes_ok) { + ESP_LOGD(TAG, "Send frame incorrect on attempt %u, saw %u bytes but expected %zu", this->transmit_attempts_, + this->n_bytes_received_, frame->size()); + } else { + ESP_LOGD(TAG, "Send frame not acknowledged on attempt %u", this->transmit_attempts_); + } + this->finish_attempt_(bytes_ok && acks_ok); + } else if (atomic_get(this->eom_received_)) { + // Another device finished a frame; the standard asks a new initiator to wait after it + this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE; + atomic_set(this->eom_received_, false); + } + + const Frame *frame = this->send_queue_.front(); + // An edge within the signal free time means the bus is in use or not yet free for us; a start + // bit refused by a held line drives no edge, so that attempt is timed on its own + if (frame == nullptr || since_last_edge < this->required_idle_period_ || + now - this->last_attempt_us_ < this->required_idle_period_) { + return; + } + + this->last_attempt_us_ = now; + this->transmit_attempts_++; +#ifdef ESPHOME_LOG_HAS_DEBUG + if (this->transmit_attempts_ == 1) { + char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; + ESP_LOGD(TAG, "Sending: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); + } +#endif + // The interrupt counts the bytes and acknowledge bits of our own frame + this->n_bytes_received_ = 0; + this->n_acks_received_ = 0; + atomic_set(this->eom_received_, false); + this->transmitting_ = true; + if (!this->transmit_frame_(frame)) { + // The line was held, or another initiator sent at the same time + this->transmitting_ = false; + this->finish_attempt_(false); + } + // Otherwise the acknowledge result arrives through the interrupt +} + +void HDMICEC::finish_attempt_(bool sent_ok) { + this->required_idle_period_ = sent_ok ? SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS : SIGNAL_FREE_TIME_AFTER_XMIT_FAIL; + if (!sent_ok) { + if (this->transmit_attempts_ < MAX_ATTEMPTS) { + return; // the signal free time has to pass before the retry + } + ESP_LOGW(TAG, "Frame not sent after %u attempts, dropped", this->transmit_attempts_); + } + this->transmit_attempts_ = 0; + this->send_queue_.pop_front(); +} + +bool HDMICEC::transmit_frame_(const Frame *frame) { + if (!this->send_start_bit_()) { + return false; + } + const auto bytes = frame->bytes(); + for (size_t byte_inx = 0; byte_inx < bytes.size(); byte_inx++) { + const uint8_t current_byte = bytes[byte_inx]; + // Most significant bit first. The four initiator address bits watch for a collision with + // another initiator that started at the same moment; a lower address wins by holding the line + for (int8_t i = 7; i >= 0; i--) { + const bool bit_value = (current_byte >> i) & 0x1; + if (!this->send_bit_(bit_value) && byte_inx == 0 && i >= 4) { + ESP_LOGD(TAG, "Bus collision on sending initiator address 0x%X on attempt %u", frame->initiator_addr(), + this->transmit_attempts_); + return false; + } + } + // End of message bit, then the acknowledge bit which the receiver may pull low + this->send_bit_(byte_inx == bytes.size() - 1); + this->send_bit_(true); + } + return true; +} + +bool HDMICEC::send_start_bit_() { + if (!this->isr_pin_.digital_read()) { + ESP_LOGD(TAG, "Bus occupied before start bit on attempt %u", this->transmit_attempts_); + return false; + } + this->set_pin_output_low_(); + delay_microseconds_safe(START_BIT_NOM_US); + // Release the line and check that nobody else keeps it low + this->set_pin_input_high_(); + delay_microseconds_safe(START_BIT_HIGH_US / 2); + bool value = this->isr_pin_.digital_read(); + delay_microseconds_safe(START_BIT_HIGH_US / 2); + value &= this->isr_pin_.digital_read(); + if (!value) { + ESP_LOGD(TAG, "Bus collision on sending start bit on attempt %u", this->transmit_attempts_); + } + return value; +} + +bool HDMICEC::send_bit_(bool bit_value) { + const uint32_t start_us = micros(); + this->set_pin_output_low_(); + delay_microseconds_safe(bit_value ? HIGH_BIT_US : LOW_BIT_US); + this->set_pin_input_high_(); + bool clear = true; + if (bit_value) { + // Sample in the middle of the safe sample period: a line still low means another initiator + // is sending a logical 0 at the same time + uint32_t elapsed = micros() - start_us; + if (elapsed < SAFE_SAMPLE_US) { + delay_microseconds_safe(SAFE_SAMPLE_US - elapsed); + } + clear = this->isr_pin_.digital_read(); + } + const uint32_t elapsed = micros() - start_us; + if (elapsed < TOTAL_BIT_US) { + delay_microseconds_safe(TOTAL_BIT_US - elapsed); + } + return clear; +} + +void IRAM_ATTR HDMICEC::send_ack_() { + // The acknowledge has to start right at this falling edge; the timer releases the line + this->set_pin_output_low_(); + this->start_ack_timer_(); +} + +#if defined(USE_ESP32) +bool HDMICEC::setup_ack_timer_() { + gptimer_config_t config = { + .clk_src = GPTIMER_CLK_SRC_DEFAULT, + .direction = GPTIMER_COUNT_UP, + .resolution_hz = 1000000, // one tick per microsecond + }; + if (gptimer_new_timer(&config, &this->ack_timer_) != ESP_OK) { + return false; + } + gptimer_event_callbacks_t callbacks = {.on_alarm = HDMICEC::ack_timer_isr_s}; + // The alarm reloads the count to zero itself, so the interrupt only has to start the timer + gptimer_alarm_config_t alarm = { + .alarm_count = LOW_BIT_US, .reload_count = 0, .flags = {.auto_reload_on_alarm = true}}; + return gptimer_register_event_callbacks(this->ack_timer_, &callbacks, this) == ESP_OK && + gptimer_set_alarm_action(this->ack_timer_, &alarm) == ESP_OK && gptimer_enable(this->ack_timer_) == ESP_OK; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { gptimer_start(this->ack_timer_); } + +bool IRAM_ATTR HDMICEC::ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg) { + auto *self = static_cast(arg); + self->set_pin_input_high_(); + gptimer_stop(timer); + return false; // no task to wake +} + +#elif defined(USE_ESP8266) +static constexpr uint32_t TIMER1_TICKS_PER_US = 5; // TIM_DIV16 of the 80 MHz clock + +bool HDMICEC::setup_ack_timer_() { + ack_timer_owner = this; + timer1_isr_init(); + timer1_attachInterrupt(HDMICEC::ack_timer_isr_s); + timer1_enable(TIM_DIV16, TIM_EDGE, TIM_SINGLE); // a write arms one shot + return true; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { timer1_write(LOW_BIT_US * TIMER1_TICKS_PER_US); } + +void IRAM_ATTR HDMICEC::ack_timer_isr_s() { ack_timer_owner->set_pin_input_high_(); } + +#elif defined(USE_RP2040) +bool HDMICEC::setup_ack_timer_() { + this->ack_alarm_ = hardware_alarm_claim_unused(false); + if (this->ack_alarm_ < 0) { + return false; // every hardware alarm is taken + } + ack_timer_owner = this; + hardware_alarm_set_callback(this->ack_alarm_, HDMICEC::ack_timer_isr_s); + return true; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { + hardware_alarm_set_target(this->ack_alarm_, make_timeout_time_us(LOW_BIT_US)); +} + +void IRAM_ATTR HDMICEC::ack_timer_isr_s(uint alarm_num) { ack_timer_owner->set_pin_input_high_(); } + +#else +bool HDMICEC::setup_ack_timer_() { return true; } + +// Without a one-shot timer the interrupt holds the line itself +void IRAM_ATTR HDMICEC::start_ack_timer_() { + delay_microseconds_safe(LOW_BIT_US); + this->set_pin_input_high_(); +} +#endif + +void IRAM_ATTR HDMICEC::gpio_isr_s(HDMICEC *self) { self->gpio_isr_(); } + +void IRAM_ATTR HDMICEC::gpio_isr_() { + const uint32_t now = micros(); + const bool level = this->isr_pin_.digital_read(); + if (level == this->prev_pin_level_) { + return; // a mode change of the pin, not a bus edge + } + this->prev_pin_level_ = level; + atomic_set(this->last_edge_us_, now); + + if (!level) { + // Falling edge: the start of a low period + if (now - this->last_falling_edge_us_ > START_BIT_NOM_US + TOTAL_BIT_US) { + // The bus was quiet; whatever was gathered belongs to an aborted frame + this->reset_receiver_(); + } + this->last_falling_edge_us_ = now; + if (this->recv_ack_queued_) { + // Acknowledge by stretching this low period + this->send_ack_(); + this->recv_ack_queued_ = false; + } + return; + } + + // Rising edge: the low period tells the bit value + const uint32_t pulse_duration = now - this->last_falling_edge_us_; + if (pulse_duration > START_BIT_MIN_US) { + this->reset_receiver_(); + this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE; + // Without a free slot the frame is neither acknowledged nor delivered, so its initiator retries + this->recv_frame_ = this->frames_queue_.back(); + if (this->recv_frame_ != nullptr) { + this->recv_frame_->clear(); + } + return; + } + if (pulse_duration < HIGH_BIT_MIN_US / 4) { + return; // glitch + } + const bool value = pulse_duration <= SAFE_SAMPLE_US; + // Our own frame is on the bus until its end of message; send() may give it any initiator address + const bool own_frame = this->transmitting_ && !atomic_get(this->eom_received_); + + switch (this->receiver_state_) { + case ReceiverState::RECEIVER_STATE_IDLE: + break; + + case ReceiverState::RECEIVER_STATE_RECEIVING_BYTE: + this->recv_byte_ = (this->recv_byte_ << 1) | value; + if (++this->recv_bit_counter_ == 8) { + if (this->recv_length_++ == 0) { + this->recv_header_ = this->recv_byte_; + } + if (this->recv_frame_ != nullptr && !this->recv_frame_->push_back(this->recv_byte_)) { + this->recv_frame_ = nullptr; // longer than the bus allows: corrupt, do not deliver it + } + this->recv_bit_counter_ = 0; + this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM; + } + break; + + case ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM: + this->recv_eom_ = value; + // A frame for us is acknowledged on the next bit, unless there is no room for it (so its + // initiator retries) or it is our own, which includes a polling message from a device + // claiming our logical address. A polling message for us (header only) needs no room, and + // goes unacknowledged only when nobody holds the address + if (!this->monitor_mode_ && !own_frame && (this->recv_frame_ != nullptr || (value && this->recv_length_ == 1)) && + (this->recv_header_ & 0xf) == this->address_ && (this->recv_header_ >> 4) != this->address_) { + this->recv_ack_queued_ = true; + } + this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK; + break; + + case ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK: + if (own_frame) { + // A low acknowledge bit means the addressed device took the byte + // (for a broadcast it means some device refused it) + this->n_bytes_received_++; + if (!value) { + this->n_acks_received_++; + } + } + if (!this->recv_eom_) { + this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE; + break; + } + atomic_set(this->eom_received_, true); // publishes the tally above to loop() + // The application only gets frames that are not our own and not a polling message (header + // only), which the acknowledge answered already, and that are addressed to us unless + // promiscuous + const uint8_t destination = this->recv_header_ & 0xf; + const bool wanted = this->recv_length_ > 1 && !own_frame && (this->recv_header_ >> 4) != this->address_ && + (this->promiscuous_mode_ || destination == 0xf || destination == this->address_); + if (!wanted) { + // nothing to deliver + } else if (this->recv_frame_ == nullptr) { + atomic_set(this->dropped_frames_, atomic_get(this->dropped_frames_) + 1); + } else { + this->frames_queue_.push_back(); + this->enable_loop_soon_any_context(); + } + this->reset_receiver_(); + break; + } +} + +void IRAM_ATTR HDMICEC::reset_receiver_() { + this->receiver_state_ = ReceiverState::RECEIVER_STATE_IDLE; + this->recv_bit_counter_ = 0; + this->recv_length_ = 0; + this->recv_ack_queued_ = false; + this->recv_frame_ = nullptr; +} + +} // namespace esphome::hdmi_cec diff --git a/esphome/components/hdmi_cec/hdmi_cec.h b/esphome/components/hdmi_cec/hdmi_cec.h new file mode 100644 index 00000000000..d001a7ae23b --- /dev/null +++ b/esphome/components/hdmi_cec/hdmi_cec.h @@ -0,0 +1,218 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +#ifdef USE_ESP32 +#include "driver/gptimer.h" +#endif +#ifdef USE_RP2040 +#include +#endif + +namespace esphome::hdmi_cec { + +// Release/acquire store and load. The read-modify-write forms of std::atomic are not available on +// every target (the ESP8266 has none), so a writer computes the new value itself; that is safe here +// because every atomic in this component has exactly one writer. +template ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic &atom, T value) { + atom.store(value, std::memory_order_release); +} +template ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic &atom) { + return atom.load(std::memory_order_acquire); +} + +/// One CEC bus message: a header byte with the initiator and destination addresses, then up to +/// 15 payload bytes of which the first is the opcode. Fixed storage, so the receive interrupt +/// never allocates. +class Frame { + public: + static constexpr size_t MAX_LENGTH = 16; // HDMI-CEC 1.4 + static constexpr size_t MAX_PAYLOAD_LENGTH = MAX_LENGTH - 1; + + Frame() = default; + /// The payload is at most MAX_PAYLOAD_LENGTH bytes; send() checks that + Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span payload); + + ESPHOME_ALWAYS_INLINE uint8_t initiator_addr() const { return this->data_[0] >> 4; } + ESPHOME_ALWAYS_INLINE uint8_t destination_addr() const { return this->data_[0] & 0xf; } + ESPHOME_ALWAYS_INLINE bool is_broadcast() const { return this->destination_addr() == 0xf; } + ESPHOME_ALWAYS_INLINE size_t size() const { return this->size_; } + std::span bytes() const { return {this->data_.data(), this->size_}; } + /// The bytes after the header, starting with the opcode + std::span payload() const { return this->bytes().subspan(1); } + ESPHOME_ALWAYS_INLINE void clear() { this->size_ = 0; } + /// False when the frame is full already; the bus allows no more + ESPHOME_ALWAYS_INLINE bool push_back(uint8_t data) { + if (this->size_ == MAX_LENGTH) { + return false; + } + this->data_[this->size_++] = data; + return true; + } + + protected: + std::array data_{}; + uint8_t size_{0}; +}; + +/// A queue of frames between one producer and one consumer, for instance the receive interrupt and +/// loop(). The indices run freely and each is written by one side only, so no lock is needed. +/// back() hands out a slot to fill and push_back() commits it; front() and pop_front() do the same +/// on the reading side. +template class FrameRingBuffer { + static_assert((SIZE & (SIZE - 1)) == 0, "SIZE must be a power of two"); + + public: + ESPHOME_ALWAYS_INLINE Frame *front() { return this->is_empty() ? nullptr : this->slot_(this->front_inx_); } + ESPHOME_ALWAYS_INLINE void pop_front() { this->advance_(this->front_inx_); } + ESPHOME_ALWAYS_INLINE Frame *back() { return this->count_() == SIZE ? nullptr : this->slot_(this->back_inx_); } + ESPHOME_ALWAYS_INLINE void push_back() { this->advance_(this->back_inx_); } + ESPHOME_ALWAYS_INLINE bool is_empty() const { return this->count_() == 0; } + + protected: + ESPHOME_ALWAYS_INLINE uint8_t count_() const { + return static_cast(atomic_get(this->back_inx_) - atomic_get(this->front_inx_)); + } + ESPHOME_ALWAYS_INLINE Frame *slot_(const std::atomic &inx) { return &this->store_[atomic_get(inx) % SIZE]; } + ESPHOME_ALWAYS_INLINE void advance_(std::atomic &inx) { + atomic_set(inx, static_cast(atomic_get(inx) + 1)); + } + + std::array store_{}; + std::atomic front_inx_{0}; + std::atomic back_inx_{0}; +}; + +/// The HDMI-CEC bus on one GPIO pin. The receive interrupt decodes the edges of every frame, +/// including those this device sends, and queues complete frames for loop(); loop() runs the +/// on_message automations and transmits queued frames, one blocking bit-banged attempt per pass. +class HDMICEC : public Component { + public: + HDMICEC(InternalGPIOPin *pin, uint8_t address) : pin_(pin), address_(address) {} + void setup() override; + void dump_config() override; + void loop() override; + + void set_promiscuous_mode(bool promiscuous) { this->promiscuous_mode_ = promiscuous; } + void set_monitor_mode(bool monitor_mode) { this->monitor_mode_ = monitor_mode; } +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + /// Called with the initiator, the destination and the payload (opcode first) of every received + /// frame; the payload is only valid during the call + template void add_on_message_callback(F &&callback) { + this->message_callbacks_.add(std::forward(callback)); + } +#endif + + /// Queue a frame for transmission; false when the queue is full or in monitor mode + bool send(uint8_t source, uint8_t destination, std::span payload); + bool send(uint8_t destination, std::span payload) { + return this->send(this->address_, destination, payload); + } + /// For lambdas: id(cec).send(0x0, {0x44, 0x41}); + bool send(uint8_t destination, std::initializer_list payload) { + return this->send(destination, std::span(payload.begin(), payload.size())); + } + + protected: + enum class ReceiverState : uint8_t { + RECEIVER_STATE_IDLE, + RECEIVER_STATE_RECEIVING_BYTE, + RECEIVER_STATE_WAITING_FOR_EOM, + RECEIVER_STATE_WAITING_FOR_ACK, + }; + + // Receive side, in the interrupt + static void gpio_isr_s(HDMICEC *self); + void gpio_isr_(); + void reset_receiver_(); + /// Pull the line low for the acknowledge bit of a frame addressed to us + void send_ack_(); + // A hardware one-shot timer releases the line after the acknowledge bit (gptimer on the ESP32, + // timer 1 on the ESP8266, a hardware alarm on the RP2040), so the interrupt returns at once; + // without one the interrupt holds the line for the 1.5 ms itself + bool setup_ack_timer_(); + void start_ack_timer_(); +#ifdef USE_ESP32 + static bool ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg); +#endif +#ifdef USE_ESP8266 + static void ack_timer_isr_s(); +#endif +#ifdef USE_RP2040 + static void ack_timer_isr_s(uint alarm_num); +#endif + + // Transmit side, in loop() + void transmit_message_(); + /// Keep the frame for another attempt, or drop it once the attempts are used up + void finish_attempt_(bool sent_ok); + /// Drive the frame onto the line; false when another initiator sent at the same time + bool transmit_frame_(const Frame *frame); + bool send_start_bit_(); + /// Send one bit; false when a logical 1 found the line held low by another initiator + bool send_bit_(bool bit_value); + ESPHOME_ALWAYS_INLINE void set_pin_input_high_() { this->isr_pin_.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP); } + ESPHOME_ALWAYS_INLINE void set_pin_output_low_() { + this->isr_pin_.pin_mode(gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN); + this->isr_pin_.digital_write(false); + } + + static constexpr size_t QUEUE_SIZE = 4; + // Each queue is 70 bytes, so two single-byte fields follow each to fill the alignment gap + FrameRingBuffer frames_queue_; // received, interrupt to loop() + uint8_t recv_bit_counter_{0}; + uint8_t recv_byte_{0}; + FrameRingBuffer send_queue_; // to send, both ends on loop() + uint8_t transmit_attempts_{0}; + uint8_t reported_dropped_frames_{0}; +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + StaticCallbackManager)> + message_callbacks_; +#endif + InternalGPIOPin *pin_; + ISRInternalGPIOPin isr_pin_; +#ifdef USE_ESP32 + gptimer_handle_t ack_timer_{nullptr}; +#endif +#ifdef USE_RP2040 + int ack_alarm_{-1}; +#endif + + // The word-sized fields come first and the single-byte ones form one run, so nothing is padded + std::atomic last_edge_us_{0}; // the bus is busy until this is some time ago; interrupt writes + Frame *recv_frame_{nullptr}; // interrupt only; null when the queue was full or the frame too long + uint32_t last_falling_edge_us_{0}; // interrupt only + uint32_t last_attempt_us_{0}; // loop() only; a refused start bit drives no edge, so it is timed here + uint16_t required_idle_period_{0}; // loop() only + + // Shared with the interrupt. The tally of our own frame is written by the interrupt while + // transmitting_ is set and no end of message has passed yet; loop() reads it once eom_received_ + // says the frame is over + std::atomic eom_received_{false}; // set for every frame, ours or not + std::atomic dropped_frames_{0}; + uint8_t n_bytes_received_{0}; + uint8_t n_acks_received_{0}; + bool transmitting_{false}; + + // Receive state, interrupt only + uint8_t recv_header_{0}; // kept outside the frame so a dropped frame can still be judged + uint8_t recv_length_{0}; + ReceiverState receiver_state_{ReceiverState::RECEIVER_STATE_IDLE}; + bool recv_eom_{false}; + bool recv_ack_queued_{false}; + bool prev_pin_level_{true}; + + uint8_t address_; // logical address, 0xf when only listening + bool promiscuous_mode_{false}; + bool monitor_mode_{false}; +}; + +} // namespace esphome::hdmi_cec diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3b499594944..0e020975e84 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -154,6 +154,7 @@ #define USE_LVGL_TOUCHSCREEN #define USE_MDNS #define USE_MDNS_STORE_SERVICES +#define HDMI_CEC_MESSAGE_CALLBACK_COUNT 2 #define I2C_PORT_FREQUENCY_COUNT 1 #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT diff --git a/tests/components/hdmi_cec/common.yaml b/tests/components/hdmi_cec/common.yaml new file mode 100644 index 00000000000..5ee1764e2e9 --- /dev/null +++ b/tests/components/hdmi_cec/common.yaml @@ -0,0 +1,18 @@ +hdmi_cec: + id: cec + pin: ${cec_pin} # HDMI pin 13, with HDMI pin 17 as ground + address: 0x5 + promiscuous_mode: true + on_message: + - opcode: 0x8F # Give Device Power Status + then: + - hdmi_cec.send: + destination: !lambda return source; + data: [0x90, 0x01] # Report Power Status: standby + - opcode: 0x8C # Give Device Vendor ID + then: + - lambda: |- + id(cec).send(0xf, {0x87, 0x00, 0x09, 0xb0}); + - data: [0x44, 0x41] # User Control Pressed: Volume Up + then: + - logger.log: "HDMI-CEC Volume Up" diff --git a/tests/components/hdmi_cec/test.bk72xx-ard.yaml b/tests/components/hdmi_cec/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..e4709b1d900 --- /dev/null +++ b/tests/components/hdmi_cec/test.bk72xx-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: P6 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/hdmi_cec/test.esp32-idf.yaml b/tests/components/hdmi_cec/test.esp32-idf.yaml new file mode 100644 index 00000000000..0210a56b6b6 --- /dev/null +++ b/tests/components/hdmi_cec/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: GPIO4 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/hdmi_cec/test.esp8266-ard.yaml b/tests/components/hdmi_cec/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7b3f9ffec0e --- /dev/null +++ b/tests/components/hdmi_cec/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: GPIO14 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/hdmi_cec/test.rp2040-ard.yaml b/tests/components/hdmi_cec/test.rp2040-ard.yaml new file mode 100644 index 00000000000..7b3f9ffec0e --- /dev/null +++ b/tests/components/hdmi_cec/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: GPIO14 + +packages: + hdmi_cec: !include common.yaml From ea6d5499b3b52ee81ee44e0ece768de3fdc2c6fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 19:24:00 -1000 Subject: [PATCH 415/651] [lsm6ds] Add the LSM6DSO and match all chips to their datasheets (#20733) --- esphome/components/lsm6ds/lsm6ds.cpp | 19 ++++++++---- esphome/components/lsm6ds/lsm6ds.h | 28 +++++++++++------ tests/components/lsm6ds/__init__.py | 6 ++++ tests/components/lsm6ds/test_chip_ids.cpp | 38 +++++++++++++++++++++++ 4 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 tests/components/lsm6ds/__init__.py create mode 100644 tests/components/lsm6ds/test_chip_ids.cpp diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index 0bd14bd0875..e34735a4160 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -27,17 +27,23 @@ static const LogString *gyro_range_to_str(LSM6DSGyroRange range) { } } -static const LogString *chip_name_from_who_am_i(uint8_t who_am_i) { +const LogString *chip_name_from_who_am_i(uint8_t who_am_i) { switch (who_am_i) { - case 0x69: - return LOG_STR("LSMDSO"); - case 0x6A: + case LSM6DS_WHO_AM_I_LSM6DS3: return LOG_STR("LSM6DS3"); + case LSM6DS_WHO_AM_I_LSM6DS3TR_C: + return LOG_STR("LSM6DS3TR-C"); + case LSM6DS_WHO_AM_I_LSM6DSO: + return LOG_STR("LSM6DSO"); default: return nullptr; } } +uint16_t temperature_sensitivity_from_who_am_i(uint8_t who_am_i) { + return who_am_i == LSM6DS_WHO_AM_I_LSM6DS3 ? 16 : 256; +} + void LSM6DSComponent::setup() { MotionComponent::setup(); uint8_t who_am_i = 0; @@ -54,6 +60,7 @@ void LSM6DSComponent::setup() { } ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", LOG_STR_ARG(chip_name), who_am_i); this->chip_name_ = chip_name; + this->temperature_sensitivity_ = temperature_sensitivity_from_who_am_i(who_am_i); // 2. Software reset — clears all registers to defaults if (this->write_register(LSM6DS_REG_CTRL3_C, &CTRL3_C_SW_RESET, 1) != i2c::ERROR_OK) { @@ -185,12 +192,12 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { // Temperature (lazy — only read if a listener is registered) // Kept as a separate 2-byte read to avoid extending the burst to 14 bytes when // temperature is not needed. - // Formula: T(°C) = (raw / 256.0) + 25.0 (datasheet Table 90, OUT_TEMP register) + // Formula: T(°C) = raw / sensitivity + 25.0, sensitivity in LSB/°C per chip (0 LSB at 25 °C) if (!this->temperature_callback_.empty()) { uint8_t raw_t[2]; if (this->read_bytes(LSM6DS_REG_OUT_TEMP_L, raw_t, 2)) { int16_t temp_raw = (int16_t) ((raw_t[1] << 8) | raw_t[0]); - float temperature = (temp_raw / 256.0f) + 25.0f; + float temperature = temp_raw / static_cast(this->temperature_sensitivity_) + 25.0f; this->temperature_callback_.call(temperature); } } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index 2cc282ba124..d754b08a252 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -9,15 +9,19 @@ namespace esphome::lsm6ds { // ── Register map (datasheet DocID030071 Rev 3, Table 19) ──────────────────── static const uint8_t LSM6DS_REG_WHO_AM_I = 0x0F; -static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS -static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS -static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC -static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 -static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable -static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA -static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB -static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) -static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB +// WHO_AM_I values; the LSM6DS3 (DocID026899) and LSM6DSO (DS12140) use the same registers as the TR-C here +static constexpr uint8_t LSM6DS_WHO_AM_I_LSM6DS3 = 0x69; +static constexpr uint8_t LSM6DS_WHO_AM_I_LSM6DS3TR_C = 0x6A; +static constexpr uint8_t LSM6DS_WHO_AM_I_LSM6DSO = 0x6C; // also the LSM6DSO32, whose accel ranges differ (unsupported) +static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS +static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS +static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC +static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 +static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable +static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA +static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB +static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) +static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB // Burst read from 0x22 to 0x2D inclusive: gyro XYZ (6 bytes) + accel XYZ (6 bytes) static const uint8_t LSM6DS_BURST_LEN = 12; @@ -83,6 +87,11 @@ enum LSM6DSGyroODR : uint8_t { }; // ── Main component class ───────────────────────────────────────────────────── +/// Chip name for a WHO_AM_I value, or nullptr when the chip is not supported. +const LogString *chip_name_from_who_am_i(uint8_t who_am_i); +/// Temperature sensitivity in LSB/°C for a supported WHO_AM_I value, from the chip's datasheet. +uint16_t temperature_sensitivity_from_who_am_i(uint8_t who_am_i); + class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDevice { public: void setup() override; @@ -105,6 +114,7 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev LSM6DSAccelODR accel_odr_{LSM6DS_ACCEL_ODR_104}; LSM6DSGyroRange gyro_range_{LSM6DS_GYRO_RANGE_2000}; LSM6DSGyroODR gyro_odr_{LSM6DS_GYRO_ODR_208}; + uint16_t temperature_sensitivity_{256}; // LSB/°C: 16 on the LSM6DS3, 256 on the others LazyCallbackManager temperature_callback_{}; }; diff --git a/tests/components/lsm6ds/__init__.py b/tests/components/lsm6ds/__init__.py new file mode 100644 index 00000000000..3b260b767b0 --- /dev/null +++ b/tests/components/lsm6ds/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # lsm6ds declares i2c on its motion platform, which the harness does not walk + manifest.dependencies = manifest.dependencies + ["i2c", "motion"] diff --git a/tests/components/lsm6ds/test_chip_ids.cpp b/tests/components/lsm6ds/test_chip_ids.cpp new file mode 100644 index 00000000000..4b990d69b90 --- /dev/null +++ b/tests/components/lsm6ds/test_chip_ids.cpp @@ -0,0 +1,38 @@ +#include + +#include + +#include "esphome/components/lsm6ds/lsm6ds.h" + +namespace esphome::lsm6ds::testing { + +// WHO_AM_I values and temperature sensitivities from ST's datasheets: +// LSM6DS3 DocID026899, LSM6DS3TR-C DocID030071, LSM6DSO DS12140 + +static const char *name_of(uint8_t who_am_i) { + const LogString *name = chip_name_from_who_am_i(who_am_i); + return name == nullptr ? nullptr : LOG_STR_ARG(name); +} + +TEST(LSM6DSChipIds, Lsm6ds3) { + EXPECT_STREQ(name_of(0x69), "LSM6DS3"); + EXPECT_EQ(temperature_sensitivity_from_who_am_i(0x69), 16); +} + +TEST(LSM6DSChipIds, Lsm6ds3trC) { + EXPECT_STREQ(name_of(0x6A), "LSM6DS3TR-C"); + EXPECT_EQ(temperature_sensitivity_from_who_am_i(0x6A), 256); +} + +TEST(LSM6DSChipIds, Lsm6dso) { + EXPECT_STREQ(name_of(0x6C), "LSM6DSO"); + EXPECT_EQ(temperature_sensitivity_from_who_am_i(0x6C), 256); +} + +TEST(LSM6DSChipIds, UnknownIsRejected) { + EXPECT_EQ(name_of(0x00), nullptr); + EXPECT_EQ(name_of(0x6B), nullptr); + EXPECT_EQ(name_of(0xFF), nullptr); +} + +} // namespace esphome::lsm6ds::testing From 8d963bde5b87bde4e4e2f7642a2226917d3cdde8 Mon Sep 17 00:00:00 2001 From: Pawel Puterla Date: Thu, 15 Oct 2026 08:02:33 +0200 Subject: [PATCH 416/651] [openthread] Add ESP32 OpenThread Border Router support (#18104) Co-authored-by: Copilot App <223556219+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 Co-authored-by: J. Nick Koston --- .github/workflows/ci.yml | 2 +- esphome/components/const/__init__.py | 1 + .../ethernet/ethernet_component_esp32.cpp | 10 +- esphome/components/mdns/__init__.py | 15 +- esphome/components/mdns/mdns_component.h | 4 +- esphome/components/mdns/mdns_esp32.cpp | 10 +- esphome/components/network/util.cpp | 30 ++ esphome/components/network/util.h | 11 +- esphome/components/openthread/__init__.py | 260 +++++++++-- esphome/components/openthread/const.py | 1 + esphome/components/openthread/openthread.cpp | 89 +++- esphome/components/openthread/openthread.h | 70 ++- .../components/openthread/openthread_esp.cpp | 438 +++++++++++++++--- .../wifi/wifi_component_esp_idf.cpp | 10 +- esphome/core/defines.h | 9 +- sdkconfig.defaults.esp32c6 | 1 + sdkconfig.defaults.esp32s3 | 25 + .../mbedtls_tls_openthread_border_router.yaml | 24 + tests/component_tests/esp32/test_esp32.py | 5 +- .../mdns/test_service_enable_disable.py | 13 + .../openthread/config/border_router.yaml | 24 + .../openthread/config/border_router_rcp.yaml | 27 ++ tests/component_tests/openthread/test_init.py | 6 +- .../openthread/test_openthread.py | 324 +++++++++++++ .../test-border-router.esp32-c5-idf.yaml | 20 + .../test-border-router.esp32-c6-idf.yaml | 20 + .../openthread/test-rcp.esp32-idf.yaml | 19 + .../openthread/test-rcp.esp32-s3-idf.yaml | 20 + 28 files changed, 1329 insertions(+), 159 deletions(-) create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml create mode 100644 tests/component_tests/openthread/config/border_router.yaml create mode 100644 tests/component_tests/openthread/config/border_router_rcp.yaml create mode 100644 tests/component_tests/openthread/test_openthread.py create mode 100644 tests/components/openthread/test-border-router.esp32-c5-idf.yaml create mode 100644 tests/components/openthread/test-border-router.esp32-c6-idf.yaml create mode 100644 tests/components/openthread/test-rcp.esp32-idf.yaml create mode 100644 tests/components/openthread/test-rcp.esp32-s3-idf.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33534f1f63a..d97352f65f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1003,7 +1003,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 # yamllint disable-line rule:line-length - options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM --grep USE_OPENTHREAD tidy_environment: esp32s3-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 49f55ab47f0..dcf40d2a07c 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BLUE_CHANNEL = "blue_channel" +CONF_BORDER_ROUTER = "border_router" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1568d37b114..d0df293fd4e 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ETHERNET) && defined(USE_ESP32) +#include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -731,14 +732,7 @@ network::IPAddresses EthernetComponent::get_ip_addresses() { addresses[0] = network::IPAddress(&ip.ip); } #if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } + network::fill_ip6_addresses(this->eth_netif_, addresses); #endif /* USE_NETWORK_IPV6 */ return addresses; diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index bc431599248..8c28603e78c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.const import CONF_BORDER_ROUTER from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv @@ -188,10 +189,16 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def _openthread_srp_registers_services() -> bool: + """Without border_router, the OpenThread SRP client registers the services instead of mDNS.""" + openthread_config = CORE.config.get(CONF_OPENTHREAD) + return openthread_config is not None and CONF_BORDER_ROUTER not in openthread_config + + def request_service_enable_disable() -> bool: """Request MDNSComponent::set_service_enabled() support. - ESP32 only, not with OpenThread. Returns True when the + ESP32 only, not with the OpenThread SRP client. Returns True when the USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. Public API for external components. Do not remove. @@ -201,7 +208,7 @@ def request_service_enable_disable() -> bool: mdns_config is None or mdns_config[CONF_DISABLED] or not CORE.is_esp32 - or CONF_OPENTHREAD in CORE.config + or _openthread_srp_registers_services() ): return False cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") @@ -288,13 +295,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) # With both wifi and ethernet, the _esphomelib "network" TXT value follows the - # connected interface. OpenThread hands its services to the SRP client once, + # connected interface. The OpenThread SRP client registers the services once, # so it keeps the fixed value. if ( CONF_API in CORE.config and CONF_WIFI in CORE.config and CONF_ETHERNET in CORE.config - and CONF_OPENTHREAD not in CORE.config + and not _openthread_srp_registers_services() ): from esphome.components import network diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 06d10d2d818..1ff92f07f34 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -126,8 +126,8 @@ class MDNSComponent final : public Component #ifndef USE_MDNS_STORE_SERVICES #error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" #endif -#ifdef USE_OPENTHREAD -#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#ifdef USE_OPENTHREAD_SRP +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with the OpenThread SRP client" #endif /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index d84a2551202..7953eea3091 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -12,7 +12,7 @@ namespace esphome::mdns { ESPHOME_LOG_TAG(TAG, "mdns"); -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP static esp_err_t add_service(const MDNSService &service) { // Stack buffer for up to 16 txt records, heap fallback for more SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); @@ -30,7 +30,7 @@ static esp_err_t add_service(const MDNSService &service) { #endif static void register_esp32(MDNSComponent *comp, StaticVector &services) { -#ifdef USE_OPENTHREAD +#ifdef USE_OPENTHREAD_SRP // OpenThread handles service registration via SRP client // Services are compiled by MDNSComponent::compile_records_() and consumed by OpenThreadSrpComponent #else @@ -62,7 +62,7 @@ static void register_esp32(MDNSComponent *comp, StaticVectoris_ready()) { @@ -87,7 +87,7 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr ESP_LOGW(TAG, "Service %s not found", service_type); return false; } -#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD_SRP #ifdef USE_MDNS_DYNAMIC_NETWORK_TXT bool MDNSComponent::publish_network_txt_(const char *network) { @@ -110,7 +110,7 @@ void MDNSComponent::setup() { } void MDNSComponent::on_shutdown() { -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP mdns_free(); delay(40); // Allow the mdns packets announcing service removal to be sent #endif diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 11485fdcf03..f0a4c59dca8 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -2,10 +2,40 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #ifdef USE_NETWORK namespace esphome::network { +#if defined(USE_ESP32) && USE_NETWORK_IPV6 +ESPHOME_LOG_TAG(TAG, "network"); + +void fill_ip6_addresses(esp_netif_t *netif, IPAddresses &addresses) { + esp_ip6_addr_t ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + size_t count = esp_netif_get_all_ip6(netif, ip6s); + // Slot 0 holds the IPv4 address + if (count > addresses.size() - 1) { + // Warn once per interface + static esp_netif_t *warned[3]{}; + for (auto &slot : warned) { + if (slot == netif) { + break; + } + if (slot == nullptr) { + slot = netif; + ESP_LOGW(TAG, "%s has %zu IPv6 addresses, only %zu are reported", esp_netif_get_desc(netif), count, + addresses.size() - 1); + break; + } + } + count = addresses.size() - 1; + } + for (size_t i = 0; i < count; i++) { + addresses[i + 1] = IPAddress(&ip6s[i]); + } +} +#endif + // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that uses a previous interface for NAT). diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 57c5a66833b..7b3cceaea21 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -18,9 +18,17 @@ #ifdef USE_OPENTHREAD #include "esphome/components/openthread/openthread.h" #endif +#ifdef USE_ESP32 +#include "esp_netif.h" +#endif namespace esphome::network { +#if defined(USE_ESP32) && USE_NETWORK_IPV6 +/// Copies the IPv6 addresses of an esp_netif into `addresses[1..]`, warning once when some do not fit. +void fill_ip6_addresses(esp_netif_t *netif, IPAddresses &addresses); +#endif + // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that uses a previous interface for NAT). @@ -44,7 +52,8 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { return true; #endif -#ifdef USE_OPENTHREAD +#if defined(USE_OPENTHREAD) && !defined(USE_OPENTHREAD_BORDER_ROUTER) + // A border router is reached over its Wi-Fi backbone, so the Thread link alone does not count if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) return true; #endif diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 2c278f12f49..181bcea1d55 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,22 +1,33 @@ import re from typing import Any -from esphome import automation +from esphome import automation, pins import esphome.codegen as cg +from esphome.components import wifi +from esphome.components.const import CONF_BORDER_ROUTER from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32H4, VARIANT_ESP32H21, + VARIANT_ESP32S2, + VARIANT_ESP32S3, VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, include_builtin_idf_component, only_on_variant, + request_software_coexistence, require_mbedtls_ecp, + require_mbedtls_tls, require_mbedtls_tls_extras, require_mbedtls_tls_server, + require_vfs_dir, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage @@ -26,13 +37,25 @@ from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( + CONF_AP, + CONF_BAUD_RATE, CONF_CHANNEL, CONF_ENABLE_IPV6, + CONF_ENABLE_ON_BOOT, CONF_FRAMEWORK, + CONF_HARDWARE_UART, CONF_ID, + CONF_INVERTED, CONF_LOG_LEVEL, + CONF_LOGGER, + CONF_NETWORKS, + CONF_NUMBER, CONF_OUTPUT_POWER, + CONF_RESET_PIN, + CONF_RX_PIN, + CONF_TX_PIN, CONF_USE_ADDRESS, + CONF_WIFI, KEY_CORE, KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, @@ -61,6 +84,7 @@ from .const import ( CONF_PAN_ID, CONF_POLL_PERIOD, CONF_PSKC, + CONF_RCP, CONF_SRP_ID, CONF_TLV, OPENTHREAD_CONFIG_PREFIX, @@ -71,10 +95,6 @@ DOMAIN = "openthread" AUTO_LOAD = ["network"] -# Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time -# TODO: Doesn't conflict with wifi if you're using another ESP as an RCP (radio coprocessor), but this isn't implemented yet -CONFLICTS_WITH = ["wifi"] - IDF_TO_OT_LOG_LEVEL = { "NONE": "NONE", "ERROR": "CRIT", @@ -89,6 +109,43 @@ CONF_DEVICE_TYPES = [ "MTD", ] +# Variants with both Wi-Fi and an 802.15.4 radio +NATIVE_BORDER_ROUTER_VARIANTS = { + VARIANT_ESP32C5, + VARIANT_ESP32C6, +} +# Variants with Wi-Fi, for an external RCP +RCP_BORDER_ROUTER_VARIANTS = { + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, +} +# ESP-IDF options a border router needs on top of the plain OpenThread ones +BORDER_ROUTER_SDKCONFIG_OPTIONS: dict[str, bool | int] = { + "CONFIG_OPENTHREAD_PLATFORM_NETIF": True, + "CONFIG_OPENTHREAD_BORDER_ROUTER": True, + # The advertising proxy registers the Thread devices' services with mDNS; the SDK default of 10 fills up fast + "CONFIG_MDNS_MAX_SERVICES": 20, + "CONFIG_LWIP_IPV6_FORWARD": True, + "CONFIG_LWIP_IPV6_NUM_ADDRESSES": 12, + "CONFIG_LWIP_MULTICAST_PING": True, + "CONFIG_LWIP_NETIF_STATUS_CALLBACK": True, + "CONFIG_LWIP_HOOK_IP6_ROUTE_DEFAULT": True, + "CONFIG_LWIP_HOOK_ND6_GET_GW_DEFAULT": True, + "CONFIG_LWIP_HOOK_IP6_INPUT_CUSTOM": True, + "CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_CUSTOM": True, + "CONFIG_MDNS_MULTIPLE_INSTANCE": True, + "CONFIG_MBEDTLS_CMAC_C": True, + "CONFIG_MBEDTLS_SSL_PROTO_DTLS": True, + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE": True, + "CONFIG_MBEDTLS_ECJPAKE_C": True, +} OT_HEADER_DIR = "openthread" OT_HEADER_FILE = "esphome_ot_config.h" OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+") @@ -108,10 +165,18 @@ def _validate_txpower(value: Any) -> int | float: return value # Unsupported, fail later with clear error +def _get_rcp(config: ConfigType) -> ConfigType | None: + return config.get(CONF_BORDER_ROUTER, {}).get(CONF_RCP) + + def set_sdkconfig_options(config: ConfigType) -> None: - # and expose options for using SPI/UART RCPs - add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", True) + border_router = CONF_BORDER_ROUTER in config + rcp = _get_rcp(config) is not None + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", not rcp) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_SPINEL_UART", rcp) + if not rcp: + add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) # There is a conflict if the logger's uart also uses the default UART, which is seen as a watchdog failure on "ot_cli" add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CLI", False) @@ -123,45 +188,55 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - # Commissioner/joiner Kconfigs default off, so no mbedtls_ssl_* is linked; - # setting one under sdkconfig_options keeps TLS in the build automatically. - # The crypto platform uses AES-CCM and deterministic ECDSA directly. - require_mbedtls_tls_server() + # OpenThread's SRP and border-router code uses these mbedTLS crypto features directly. require_mbedtls_tls_extras( ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") ) + # Widens the TLS role only where TLS is compiled in: a commissioner or border agent enabled + # through sdkconfig_options needs the DTLS server side too + require_mbedtls_tls_server() + if border_router: + require_mbedtls_tls() if not config.get(CONF_TLV): - if pan_id := config.get(CONF_PAN_ID): + if (pan_id := config.get(CONF_PAN_ID)) is not None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) if channel := config.get(CONF_CHANNEL): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_CHANNEL", channel) - if network_key := config.get(CONF_NETWORK_KEY): + if (network_key := config.get(CONF_NETWORK_KEY)) is not None: add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{network_key:X}".lower() + "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{network_key:032x}" ) - if network_name := config.get(CONF_NETWORK_NAME): + if (network_name := config.get(CONF_NETWORK_NAME)) is not None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_NAME", network_name) if (ext_pan_id := config.get(CONF_EXT_PAN_ID)) is not None: add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:X}".lower() + "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:016x}" ) if (mesh_local_prefix := config.get(CONF_MESH_LOCAL_PREFIX)) is not None: add_idf_sdkconfig_option( "CONFIG_OPENTHREAD_MESH_LOCAL_PREFIX", f"{mesh_local_prefix}".lower() ) if (pskc := config.get(CONF_PSKC)) is not None: - add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() - ) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:032x}") - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", not border_router) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", not border_router) + if not border_router: + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) + + if border_router: + # Border routing follows the Wi-Fi link + wifi.request_wifi_connect_state_listener() + for name, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items(): + add_idf_sdkconfig_option(name, value) + if not rcp: + # The native 802.15.4 radio shares the RF frontend with Wi-Fi + request_software_coexistence() # TODO: Add support for synchronized sleepy end devices (SSED) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) @@ -196,6 +271,30 @@ openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) + +def _validate_reset_pin(value: object) -> ConfigType: + value = {CONF_NUMBER: value} if not isinstance(value, dict) else dict(value) + value.setdefault(CONF_INVERTED, True) + return pins.internal_gpio_output_pin_schema(value) + + +# The pin registry rejects a pin used twice, so RX, TX and reset need no check of their own +_RCP_SCHEMA = cv.Schema( + { + cv.Required(CONF_RX_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_TX_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_BAUD_RATE, default=460800): cv.int_range(min=9600), + cv.Optional(CONF_RESET_PIN): _validate_reset_pin, + } +) + +_BORDER_ROUTER_SCHEMA = cv.Schema({cv.Optional(CONF_RCP): _RCP_SCHEMA}) + + +def _validate_border_router(value: object) -> ConfigType: + return _BORDER_ROUTER_SCHEMA({} if value is None else value) + + _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, @@ -227,21 +326,44 @@ def _validate(config: ConfigType) -> ConfigType: raise cv.Invalid( f"{CONF_POLL_PERIOD} can only be used with {CONF_DEVICE_TYPE}: MTD" ) + if CONF_BORDER_ROUTER in config and device_type != "FTD": + raise cv.Invalid("OpenThread Border Router requires 'device_type: FTD'.") return config -def _require_vfs_select(config: ConfigType) -> ConfigType: - """Register VFS select requirement during config validation.""" +def _require_vfs(config: ConfigType) -> ConfigType: + """Register VFS requirements during config validation.""" # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) if CORE.is_esp32: require_vfs_select() + if _get_rcp(config) is not None: + require_vfs_dir() return config def _validate_platform(config: ConfigType) -> ConfigType: if CORE.using_zephyr: return config + # Older 5.5 releases keep the first lock mutex when a timed wait for the second one runs out + cv.require_framework_version( + esp_idf=cv.Version(5, 5, 5), + extra_message="OpenThread requires ESP-IDF 5.5.5 or newer; older 5.5 releases can deadlock the OpenThread lock", + )(config) + if CONF_BORDER_ROUTER in config: + variant = get_esp32_variant() + if _get_rcp(config) is not None: + if variant not in RCP_BORDER_ROUTER_VARIANTS: + raise cv.Invalid( + "OpenThread Border Router RCP mode requires a Wi-Fi-capable ESP32 variant." + ) + elif variant not in NATIVE_BORDER_ROUTER_VARIANTS: + raise cv.Invalid( + "OpenThread Border Router with a native radio requires an ESP32 variant " + "with both Wi-Fi and IEEE 802.15.4 support. " + "Configure 'rcp:' to use an external OpenThread Radio Co-Processor." + ) + return config return only_on_variant( supported=[ VARIANT_ESP32C5, @@ -288,8 +410,8 @@ def _validate_tlv_hex(value: Any) -> str: raise cv.Invalid("TLV must have an even number of hex characters") try: raw = bytes.fromhex(s) - except ValueError as e: - raise cv.Invalid(f"TLV must be valid hex: {e}") from e + except ValueError as err: + raise cv.Invalid(f"TLV must be valid hex: {err}") from err if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs) raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)") return s @@ -301,6 +423,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(OpenThreadComponent), cv.GenerateID(CONF_SRP_ID): cv.declare_id(OpenThreadSrpComponent), cv.GenerateID(CONF_MDNS_ID): cv.use_id(MDNSComponent), + cv.Optional(CONF_BORDER_ROUTER): _validate_border_router, cv.Optional(CONF_DEVICE_TYPE, default="FTD"): cv.one_of( *CONF_DEVICE_TYPES, upper=True ), @@ -324,11 +447,11 @@ CONFIG_SCHEMA = cv.All( cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), _validate_platform, _validate, - _require_vfs_select, + _require_vfs, ) -def _final_validate(_: ConfigType) -> None: +def _final_validate(config: ConfigType) -> ConfigType: full_config = fv.full_config.get() network_config = full_config.get(NETWORK_DOMAIN, {}) if not network_config.get(CONF_ENABLE_IPV6, False): @@ -337,6 +460,43 @@ def _final_validate(_: ConfigType) -> None: "Please set `enable_ipv6: true` in the `network` configuration." ) + wifi_config = full_config.get(CONF_WIFI) + if CONF_BORDER_ROUTER not in config: + if wifi_config is not None: + raise cv.Invalid( + "OpenThread can only be used with Wi-Fi when 'border_router:' is configured." + ) + else: + if wifi_config is None: + raise cv.Invalid("OpenThread Border Router requires a Wi-Fi STA backbone.") + if CONF_AP in wifi_config: + raise cv.Invalid( + "OpenThread Border Router does not support Wi-Fi AP or fallback AP mode." + ) + if not wifi_config.get(CONF_NETWORKS): + raise cv.Invalid( + "OpenThread Border Router requires at least one configured Wi-Fi STA network." + ) + if not wifi_config[CONF_ENABLE_ON_BOOT]: + raise cv.Invalid( + "OpenThread Border Router requires Wi-Fi 'enable_on_boot: true'." + ) + if _get_rcp(config) is not None: + if full_config.get("uart") is not None: + raise cv.Invalid( + "OpenThread RCP uses ESP-IDF's dedicated Spinel UART driver and " + "cannot currently be combined with the ESPHome UART component." + ) + if ( + (logger_config := full_config.get(CONF_LOGGER)) is not None + and logger_config.get(CONF_HARDWARE_UART) == "UART1" + and logger_config.get(CONF_BAUD_RATE, 0) > 0 + ): + raise cv.Invalid( + "OpenThread RCP reserves UART1; configure the logger to use USB " + "or another UART." + ) + if ( (esp32_config := full_config.get(PLATFORM_ESP32)) is not None and (fw_config := esp32_config.get(CONF_FRAMEWORK)) is not None @@ -346,6 +506,8 @@ def _final_validate(_: ConfigType) -> None: ot_log_level = IDF_TO_OT_LOG_LEVEL.get(log_level, log_level) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_LOG_LEVEL_{ot_log_level}", True) + return config + FINAL_VALIDATE_SCHEMA = _final_validate @@ -361,6 +523,9 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config: ConfigType) -> None: + border_router = CONF_BORDER_ROUTER in config + rcp = _get_rcp(config) + # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") @@ -368,24 +533,47 @@ async def to_code(config: ConfigType) -> None: require_mbedtls_ecp() cg.add_define("USE_OPENTHREAD") + if border_router: + cg.add_define("USE_OPENTHREAD_BORDER_ROUTER") + else: + # The SRP client registers the mDNS services with the Thread network + cg.add_define("USE_OPENTHREAD_SRP") + if rcp is not None: + cg.add_define("USE_OPENTHREAD_RCP_UART") if config.get(CONF_FORCE_DATASET): cg.add_define("USE_OPENTHREAD_FORCE_DATASET") if tlv := config.get(CONF_TLV): cg.add_define("USE_OPENTHREAD_TLVS", tlv) - # OpenThread SRP needs access to mDNS services after setup - enable_mdns_storage() + if not border_router: + # OpenThread SRP needs access to mDNS services after setup + enable_mdns_storage() - ot = cg.new_Pvariable(config[CONF_ID]) + if rcp is None: + ot = cg.new_Pvariable(config[CONF_ID]) + else: + reset_pin = ( + await cg.gpio_pin_expression(rcp[CONF_RESET_PIN]) + if CONF_RESET_PIN in rcp + else cg.nullptr + ) + ot = cg.new_Pvariable( + config[CONF_ID], + rcp[CONF_BAUD_RATE], + rcp[CONF_RX_PIN], + rcp[CONF_TX_PIN], + reset_pin, + ) add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: cg.add(ot.set_poll_period(poll_period)) - srp = cg.new_Pvariable(config[CONF_SRP_ID]) - mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) - cg.add(srp.set_mdns(mdns_component)) - await cg.register_component(srp, config) + if not border_router: + mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) + srp = cg.new_Pvariable(config[CONF_SRP_ID]) + cg.add(srp.set_mdns(mdns_component)) + await cg.register_component(srp, config) if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: cg.add(ot.set_output_power(output_power)) diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py index bd9c8cfa1c7..b5481e7b7aa 100644 --- a/esphome/components/openthread/const.py +++ b/esphome/components/openthread/const.py @@ -9,6 +9,7 @@ CONF_NETWORK_KEY = "network_key" CONF_PAN_ID = "pan_id" CONF_POLL_PERIOD = "poll_period" CONF_PSKC = "pskc" +CONF_RCP = "rcp" CONF_SRP_ID = "srp_id" CONF_TLV = "tlv" diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index c4ab27689e7..92599603bef 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -16,6 +16,11 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esp_err.h" +#include "esp_openthread_border_router.h" +#endif + ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { @@ -25,6 +30,14 @@ OpenThreadComponent *global_openthread_component = // NOLINT(cppcoreguidelines- OpenThreadComponent::OpenThreadComponent() { global_openthread_component = this; } +#ifdef USE_OPENTHREAD_RCP_UART +OpenThreadComponent::OpenThreadComponent(uint32_t rcp_baud_rate, uint8_t rcp_rx_pin, uint8_t rcp_tx_pin, + GPIOPin *rcp_reset_pin) + : rcp_baud_rate_(rcp_baud_rate), rcp_rx_pin_(rcp_rx_pin), rcp_tx_pin_(rcp_tx_pin), rcp_reset_pin_(rcp_reset_pin) { + global_openthread_component = this; +} +#endif + void OpenThreadComponent::dump_config() { ESP_LOGCONFIG(TAG, "Open Thread:"); #if CONFIG_OPENTHREAD_FTD @@ -43,6 +56,24 @@ void OpenThreadComponent::dump_config() { if (this->output_power_.has_value()) { ESP_LOGCONFIG(TAG, " Output power: %" PRId8 "dBm", *this->output_power_); } +#ifdef USE_OPENTHREAD_RCP_UART + ESP_LOGCONFIG(TAG, + " Radio: External RCP\n" + " Transport: UART1 at %" PRIu32 " baud\n" + " RX Pin: GPIO%u\n" + " TX Pin: GPIO%u", + this->rcp_baud_rate_, this->rcp_rx_pin_, this->rcp_tx_pin_); + if (this->rcp_reset_pin_ != nullptr) { + LOG_PIN(" RCP Reset Pin: ", this->rcp_reset_pin_); + } +#else + ESP_LOGCONFIG(TAG, " Radio: Native 802.15.4"); +#endif +#ifdef USE_OPENTHREAD_BORDER_ROUTER + ESP_LOGCONFIG(TAG, " Border Router: ENABLED\n" + " Backbone: Wi-Fi STA\n" + " Experimental: YES"); +#endif } void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) { @@ -87,6 +118,7 @@ std::optional OpenThreadComponent::get_omr_address_(InstanceLock & return {}; } +#ifdef USE_OPENTHREAD_SRP void OpenThreadComponent::defer_factory_reset_external_callback() { ESP_LOGD(TAG, "Defer factory_reset_external_callback_"); this->defer([this]() { this->factory_reset_external_callback_(); }); @@ -125,7 +157,13 @@ void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrp } void OpenThreadSrpComponent::setup() { - if (global_openthread_component->is_failed()) { + // A start-up failure on the OpenThread task reaches the component as a deferred mark_failed(); + // the task itself may already be gone, and acquire() would abort instead of waiting + if (global_openthread_component->is_failed() +#ifdef USE_ESP32 + || global_openthread_component->is_task_exited() +#endif + ) { this->mark_failed(); return; } @@ -234,10 +272,11 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { return ptr; } +#endif + bool OpenThreadComponent::teardown() { - // Nothing was started - if (this->is_failed()) - return true; + // A setup() failure marks the stage complete itself; a failure with the task running, such as a + // border router that did not come up or an RCP that stays silent, still needs the stop below switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { { @@ -248,31 +287,48 @@ bool OpenThreadComponent::teardown() { ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown"); return false; } - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS; - ESP_LOGV(TAG, "Clear SRP"); + // The OpenThread task may have completed on its own in the meantime; never move back from there + TeardownStage expected = TeardownStage::TEARDOWN_STAGE_NOT_STARTED; + if (!this->teardown_stage_.compare_exchange_strong(expected, TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS)) { + break; + } otInstance *instance = lock.get_instance(); + // Stop mirroring address changes onto the netif. Every multicast group and address the + // stack drops from here on would otherwise become an MLD message that is sent, through + // the event loop, after the clean-up has deleted the glue's packet queue, which aborts. + otIp6SetAddressCallback(instance, nullptr, nullptr); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + if (this->border_router_started_) { + if (const esp_err_t err = esp_openthread_border_router_deinit(); err != ESP_OK) { + ESP_LOGW(TAG, "Failed to deinitialize OpenThread Border Router: %s", esp_err_to_name(err)); + } + this->border_router_started_ = false; + } +#endif +#ifdef USE_OPENTHREAD_SRP + ESP_LOGV(TAG, "Clear SRP"); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); +#endif if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) { ESP_LOGW(TAG, "Failed to disable Thread during teardown"); } if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) { ESP_LOGW(TAG, "Failed to disable IPv6 during teardown"); } - // Release the lock before stopping -- openthread_stop_() (esp_openthread_stop() on - // ESP32) acquires it internally, and the lock is not recursive. } // Stop OpenThread global_openthread_component = nullptr; ESP_LOGV(TAG, "Stop OpenThread"); - int error = this->openthread_stop_(); - if (error != 0) { + if (int error = this->openthread_stop_(); error != 0) { + // Nothing will mark the stage complete for us; do not wait out the teardown timeout ESP_LOGW(TAG, "Failed attempt to stop OpenThread %d", error); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } } break; case TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS: - // Unreachable today; teardown is synchronous on both platforms. Kept for a future - // graceful-exit path, or a platform whose teardown() cannot be made synchronous. + // On ESP32 the OpenThread task is leaving its main loop and cleaning up; it marks the + // stage complete when done. Zephyr completes synchronously in openthread_stop_(). break; case TeardownStage::TEARDOWN_STAGE_COMPLETED: ESP_LOGV(TAG, "OpenThreadComponent Teardown Complete"); @@ -281,13 +337,17 @@ bool OpenThreadComponent::teardown() { return this->teardown_stage_ == TeardownStage::TEARDOWN_STAGE_COMPLETED; } -void OpenThreadComponent::on_factory_reset(std::function callback) { +void OpenThreadComponent::on_factory_reset(const std::function &callback) { +#ifndef USE_OPENTHREAD_SRP + // Nothing registered with an SRP server to remove first + callback(); +#else if (this->is_failed()) { // No SRP registration to remove; reset right away callback(); return; } - this->factory_reset_external_callback_ = std::move(callback); + this->factory_reset_external_callback_ = callback; ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; InstanceLock lock = InstanceLock::acquire(); @@ -299,6 +359,7 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { return; } ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); +#endif } void OpenThreadComponent::apply_poll_period(uint32_t poll_period) { diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 8599304c89a..4041e21894e 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -4,7 +4,11 @@ #include "esphome/components/mdns/mdns_component.h" #include "esphome/components/network/ip_address.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esphome/components/wifi/wifi_component.h" +#endif #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include #include @@ -25,22 +29,47 @@ enum class TeardownStage : uint8_t { TEARDOWN_STAGE_COMPLETED, }; +#ifdef USE_OPENTHREAD_BORDER_ROUTER +class OpenThreadComponent final : public Component, public wifi::WiFiConnectStateListener { +#else class OpenThreadComponent final : public Component { +#endif public: OpenThreadComponent(); +#ifdef USE_OPENTHREAD_RCP_UART + OpenThreadComponent(uint32_t rcp_baud_rate, uint8_t rcp_rx_pin, uint8_t rcp_tx_pin, GPIOPin *rcp_reset_pin); +#endif ~OpenThreadComponent(); void dump_config() override; void setup() override; +#ifdef USE_OPENTHREAD_BORDER_ROUTER + void loop() override; + /// Border routing follows the Wi-Fi backbone; loop() does the work and sleeps in between + void on_wifi_connect_state(StringRef ssid, std::span bssid) override { this->enable_loop(); } +#endif bool teardown() override; - float get_setup_priority() const override { return setup_priority::WIFI; } + float get_setup_priority() const override { +#ifdef USE_OPENTHREAD_BORDER_ROUTER + // After every network interface, whichever order network.priority gives them: the backbone netif has to exist + return setup_priority::BEFORE_CONNECTION; +#else + return setup_priority::WIFI; +#endif + } bool is_connected() const { return this->connected_; } - /// Returns true once esp_openthread_start() has completed and the OT lock is usable. + /// Returns true once the OpenThread stack is initialized and the OT lock is usable. bool is_lock_initialized() const { return this->lock_initialized_; } +#ifdef USE_ESP32 + /// True once the OpenThread task has torn the stack down; its lock never comes back + bool is_task_exited() const { return this->task_exited_; } +#endif network::IPAddresses get_ip_addresses(); std::optional get_omr_address(); - void on_factory_reset(std::function callback); + void on_factory_reset(const std::function &callback); +#ifdef USE_OPENTHREAD_SRP void defer_factory_reset_external_callback(); +#endif /// Returns nullptr when no explicit use_address is configured and the address is /// derived at runtime from the device name (see network::get_use_address_to()). @@ -55,25 +84,54 @@ class OpenThreadComponent final : public Component { void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); +#ifdef USE_OPENTHREAD_RCP_UART + static void rcp_failure_handler(); +#endif protected: /** Apply Link Mode settings (incl poll period). * Callers running outside the OpenThread task must hold InstanceLock. */ void apply_linkmode_(otInstance *instance); +#ifdef USE_ESP32 + void ot_main_(); + void mark_task_failed_(const LogString *reason); +#endif +#ifdef USE_OPENTHREAD_RCP_UART + void reset_rcp_(bool recovery_attempt); +#endif std::optional get_omr_address_(InstanceLock &lock); otInstance *get_openthread_instance_(); int openthread_stop_(); +#ifdef USE_OPENTHREAD_SRP std::function factory_reset_external_callback_; +#endif #if CONFIG_OPENTHREAD_MTD uint32_t poll_period_{0}; #endif std::optional output_power_{}; std::atomic lock_initialized_{false}; - // Only ever written from teardown(), on the main task -- no atomic needed. - TeardownStage teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; + std::atomic teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; std::atomic connected_{false}; +#ifdef USE_ESP32 + std::atomic task_exited_{false}; +#endif +#ifdef USE_OPENTHREAD_RCP_UART + uint32_t rcp_baud_rate_{0}; + uint8_t rcp_rx_pin_{0}; + uint8_t rcp_tx_pin_{0}; + GPIOPin *rcp_reset_pin_{nullptr}; + // Both only touched on the OpenThread task, by the failure handler + uint8_t rcp_reset_attempts_{0}; + uint32_t last_rcp_reset_ms_{0}; + static constexpr uint32_t RCP_RESET_FORGET_MS = 10 * 60 * 1000; +#endif +#ifdef USE_OPENTHREAD_BORDER_ROUTER + std::atomic mainloop_running_{false}; + bool border_router_started_{false}; + bool backbone_up_{false}; // what the stack was told about the infra interface last +#endif private: // Stores a pointer to a string literal (static storage duration). @@ -83,6 +141,7 @@ class OpenThreadComponent final : public Component { extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_OPENTHREAD_SRP class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } @@ -101,6 +160,7 @@ class OpenThreadSrpComponent final : public Component { std::vector> memory_pool_; void *pool_alloc_(size_t size); }; +#endif // RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the // guard may or may not own the lock (try_acquire can fail), so check it with diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index de1295fa26a..e1194272973 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -1,12 +1,29 @@ #include "esphome/core/defines.h" #if defined(USE_OPENTHREAD) && defined(USE_ESP32) +#include +#include +#include +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include +#endif #include "openthread.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esp_openthread_border_router.h" +#include "esphome/components/wifi/wifi_component.h" +#ifndef USE_OPENTHREAD_RCP_UART +#include "esp_coexist.h" +#endif +#endif +#ifdef USE_OPENTHREAD_RCP_UART +#include "esp_openthread_spinel.h" +#endif #include "esp_log.h" #include "esp_openthread.h" #include "esp_openthread_lock.h" - +#include "esp_openthread_types.h" #include "esp_task_wdt.h" +#include "esphome/components/network/util.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -16,77 +33,170 @@ #include "esp_netif.h" #include "esp_openthread_netif_glue.h" #include "esp_vfs_eventfd.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { void OpenThreadComponent::setup() { - // Used eventfds: - // * netif - // * ot task queue - // * radio driver +#ifdef USE_OPENTHREAD_BORDER_ROUTER + esp_netif_t *backbone_netif = wifi::global_wifi_component->get_esp_netif_sta(); + if (backbone_netif == nullptr) { + ESP_LOGE(TAG, "Wi-Fi STA backbone netif is not initialized"); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + this->mark_failed(); + return; + } + esp_openthread_set_backbone_netif(backbone_netif); + wifi::global_wifi_component->add_connect_state_listener(this); +#endif + + // Used eventfds: netif, ot task queue, radio driver, and one more for a border router on the + // native radio (the UART Spinel transport has none) +#if defined(USE_OPENTHREAD_BORDER_ROUTER) && !defined(USE_OPENTHREAD_RCP_UART) + constexpr size_t max_eventfds = 4; +#else + constexpr size_t max_eventfds = 3; +#endif esp_vfs_eventfd_config_t eventfd_config = { - .max_fds = 3, + .max_fds = max_eventfds, }; // Network interface setup handled by network component. - // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_start() + // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_init() // aborts without it, so fail this component instead. if (global_preferences->nvs_handle == 0) { ESP_LOGE(TAG, "NVS unavailable"); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; this->mark_failed(); return; } ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); - esp_openthread_config_t config = {.netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(), - .platform_config = { - .radio_config = - { - .radio_mode = RADIO_MODE_NATIVE, - .radio_uart_config = {}, - }, - .host_config = - { - // There is a conflict between esphome's logger which also - // claims the usb serial jtag device. - // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, - // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), - }, - .port_config = - { - .storage_partition_name = "nvs", - .netif_queue_size = 10, - .task_queue_size = 10, - }, - }}; + // Own task instead of esp_openthread_start(): the dataset activation has to run on the task + // that holds the OpenThread task-switching lock, and a border router must not become the + // default netif + if (xTaskCreate( + [](void *arg) { + static_cast(arg)->ot_main_(); + vTaskDelete(nullptr); + }, + CONFIG_OPENTHREAD_TASK_NAME, CONFIG_OPENTHREAD_TASK_SIZE, this, CONFIG_OPENTHREAD_TASK_PRIORITY, + nullptr) != pdPASS) { + ESP_LOGE(TAG, "Failed to create OpenThread task"); + esp_vfs_eventfd_unregister(); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + this->mark_failed(); + } +} + +void OpenThreadComponent::ot_main_() { + esp_openthread_platform_config_t config = { + .radio_config = + { +#ifdef USE_OPENTHREAD_RCP_UART + .radio_mode = RADIO_MODE_UART_RCP, + .radio_uart_config = + { + .port = UART_NUM_1, + .uart_config = + { + .baud_rate = static_cast(this->rcp_baud_rate_), + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .rx_flow_ctrl_thresh = 0, + .source_clk = UART_SCLK_DEFAULT, + }, + .rx_pin = static_cast(this->rcp_rx_pin_), + .tx_pin = static_cast(this->rcp_tx_pin_), + }, +#else + .radio_mode = RADIO_MODE_NATIVE, + .radio_uart_config = {}, +#endif + }, + .host_config = + { + // There is a conflict between esphome's logger which also + // claims the usb serial jtag device. + // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, + // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), + }, + .port_config = + { + .storage_partition_name = "nvs", + .netif_queue_size = 10, + .task_queue_size = 10, + }, + }; + +#ifdef USE_OPENTHREAD_RCP_UART + // Without a reset pin there is nothing to do on a failure beyond the Spinel driver's own recovery + if (this->rcp_reset_pin_ != nullptr) { + this->rcp_reset_pin_->setup(); + esp_openthread_register_rcp_failure_handler(OpenThreadComponent::rcp_failure_handler); + esp_openthread_set_coprocessor_reset_failure_callback(OpenThreadComponent::rcp_failure_handler); + this->reset_rcp_(false); + } +#endif + + ESP_ERROR_CHECK(esp_openthread_init(&config)); + + // Initialize the esp_netif bindings + esp_netif_config_t netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(); + esp_netif_t *netif = esp_netif_new(&netif_config); + assert(netif != nullptr); + ESP_ERROR_CHECK(esp_netif_attach(netif, esp_openthread_netif_glue_init(&config))); +#ifndef USE_OPENTHREAD_BORDER_ROUTER + esp_netif_set_default_netif(netif); +#endif + + ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); - ESP_ERROR_CHECK(esp_openthread_start(&config)); - // Mark lock as initialized so InstanceLock callers know it's safe to acquire. - // Must be set after esp_openthread_start() which creates the internal semaphore. - this->lock_initialized_ = true; - // Fetch OT instance once to avoid repeated call into OT stack otInstance *instance = esp_openthread_get_instance(); - { - InstanceLock lock = InstanceLock::acquire(); + bool launch_mainloop = true; +#if defined(USE_OPENTHREAD_BORDER_ROUTER) && !defined(USE_OPENTHREAD_RCP_UART) + // The native 802.15.4 radio shares the RF frontend with Wi-Fi. Coexistence has to be on before + // the Thread network starts below, or Wi-Fi scanning and association can fail outright. + if (const esp_err_t err = esp_coex_wifi_i154_enable(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable Wi-Fi/802.15.4 coexistence: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Coexistence could not be enabled")); + launch_mainloop = false; + } +#endif + { + // One lock for the whole bring-up, so the main loop (actions, teardown, the SRP component) + // cannot call into the stack half way through. The lock is recursive: hold it before + // telling the other tasks that it exists, then let the guard take over. + esp_openthread_lock_acquire(portMAX_DELAY); + this->lock_initialized_ = true; + InstanceLock lock = InstanceLock::acquire(); + esp_openthread_lock_release(); + +#ifndef USE_OPENTHREAD_BORDER_ROUTER this->apply_linkmode_(instance); +#endif if (this->output_power_.has_value()) { if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { ESP_LOGE(TAG, "Failed to set power: %s", otThreadErrorToString(err)); } } + ESP_LOGI(TAG, "Activating dataset..."); otOperationalDatasetTlvs dataset = {}; - + bool stored_dataset = false; #ifndef USE_OPENTHREAD_FORCE_DATASET // Check if openthread has a valid dataset from a previous execution - otError error = otDatasetGetActiveTlvs(instance, &dataset); - if (error != OT_ERROR_NONE) { + if (otDatasetGetActiveTlvs(instance, &dataset) != OT_ERROR_NONE) { // Make sure the length is 0 so we fallback to the configuration dataset.mLength = 0; } else { + stored_dataset = true; ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); } #endif @@ -94,54 +204,219 @@ void OpenThreadComponent::setup() { #ifdef USE_OPENTHREAD_TLVS if (dataset.mLength == 0) { // If we didn't have an active dataset, and we have tlvs, parse it and pass it to esp_openthread_auto_start - size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; - if (len > sizeof(dataset.mTlvs)) { - ESP_LOGW(TAG, "TLV buffer too small, truncating"); - len = sizeof(dataset.mTlvs); - } + constexpr size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; + static_assert(len <= sizeof(dataset.mTlvs), "OpenThread dataset TLV is too long"); parse_hex(USE_OPENTHREAD_TLVS, sizeof(USE_OPENTHREAD_TLVS) - 1, dataset.mTlvs, len); dataset.mLength = len; } #endif - // Pass the existing dataset, or NULL which will use the preprocessor definitions - ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); + if (dataset.mLength > 0 && !otDatasetIsValid(&dataset, true)) { + if (stored_dataset) { + ESP_LOGE(TAG, "Stored OpenThread Active Operational Dataset is invalid; set force_dataset: true to replace it"); + } else { + ESP_LOGE(TAG, "Configured OpenThread Active Operational Dataset TLV is invalid"); + } + this->mark_task_failed_(LOG_STR("Invalid dataset")); + launch_mainloop = false; + } else if (launch_mainloop) { + // esp_openthread_auto_start() enables IPv6, which (for Border Router builds) opens a UDP socket + // via otPlatUdpSocket(). That path releases and re-acquires the OpenThread task-switching lock, + // which asserts unless this task holds the OpenThread lock, as it does here. + if (const esp_err_t err = esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to start OpenThread: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Start failed")); + launch_mainloop = false; + } + } - // Register state change callback to update connected_ reactively instead of polling - otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); - if (ot_err != OT_ERROR_NONE) { - ESP_LOGW(TAG, "Failed to register state change callback: %d", ot_err); + if (launch_mainloop) { + // Register state change callback to update connected_ reactively instead of polling + otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); + if (ot_err != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to register state change callback: %d", ot_err); + // Without this callback connected_ would never update, so the component would look + // healthy forever while never actually reporting the device as connected. + this->mark_task_failed_(LOG_STR("State callback could not be registered")); + launch_mainloop = false; + } } } - ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); + if (launch_mainloop) { +#ifdef USE_OPENTHREAD_BORDER_ROUTER + this->mainloop_running_ = true; +#endif + const esp_err_t err = esp_openthread_launch_mainloop(); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + this->mainloop_running_ = false; +#endif + if (err != ESP_OK) { + ESP_LOGE(TAG, "OpenThread main loop failed: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Main loop failed")); + } + } + + // Clean up. Taking the lock once more lets every guard finish; the stack is stopped and the + // lock withdrawn while it is held. + esp_openthread_lock_acquire(portMAX_DELAY); + // Best effort: after a normal teardown all of this is done already + otIp6SetAddressCallback(instance, nullptr, nullptr); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + if (this->border_router_started_) { + (void) esp_openthread_border_router_deinit(); + this->border_router_started_ = false; + } +#endif + (void) otThreadSetEnabled(instance, false); + (void) otIp6SetEnabled(instance, false); + // Nothing runs the main loop any more, so the stack's reaction to the disables (the border + // agent closing its sockets, for one) is run here, with the lock held + for (uint8_t i = 0; i < 16 && otTaskletsArePending(instance); i++) { + otTaskletsProcess(instance); + } + this->lock_initialized_ = false; + this->connected_ = false; + this->task_exited_ = true; + esp_openthread_lock_release(); + esp_openthread_netif_glue_deinit(); + esp_netif_destroy(netif); + // Finalizing the instance closes the sockets the stack still holds, which hands the task + // switching lock back and forth, so it has to be held here; deinit destroys it at the end + esp_openthread_lock_acquire(portMAX_DELAY); + esp_openthread_deinit(); + esp_vfs_eventfd_unregister(); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } -int OpenThreadComponent::openthread_stop_() { - // Clean up - reset lock flag before deinit destroys the semaphore - this->lock_initialized_ = false; - int error = esp_openthread_stop(); - // Mark complete even on failure: we're already mid-shutdown/reboot, so there's no - // recovery path to retry into -- leaving the stage stuck would only burn the full - // teardown timeout for no benefit. - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; - return error; +// Runs on the OpenThread task; the component state belongs to the main loop, and the log goes +// out from there too, where an abort that follows on the task cannot swallow it +void OpenThreadComponent::mark_task_failed_(const LogString *reason) { + this->defer([this, reason]() { + ESP_LOGE(TAG, "OpenThread task failed: %s", LOG_STR_ARG(reason)); + this->mark_failed(); + }); } +#ifdef USE_OPENTHREAD_RCP_UART +void OpenThreadComponent::rcp_failure_handler() { + if (global_openthread_component == nullptr) { + ESP_LOGE(TAG, "RCP failure reported after OpenThread component was torn down"); + return; + } + global_openthread_component->reset_rcp_(true); +} + +void OpenThreadComponent::reset_rcp_(bool recovery_attempt) { + if (recovery_attempt) { + static constexpr uint8_t MAX_RESET_ATTEMPTS = 5; + uint8_t attempt = this->rcp_reset_attempts_; + if (attempt > MAX_RESET_ATTEMPTS) { + return; // Gave up earlier; the driver keeps reporting the failure + } + // An RCP that ran long enough since its last reset gets its attempts back; one that keeps failing does not + const uint32_t now = millis(); + if (attempt != 0 && now - this->last_rcp_reset_ms_ > RCP_RESET_FORGET_MS) { + attempt = 0; + } + this->rcp_reset_attempts_ = ++attempt; + if (attempt > MAX_RESET_ATTEMPTS) { + ESP_LOGE(TAG, "RCP failed to recover after %u reset attempts", MAX_RESET_ATTEMPTS); + this->mark_task_failed_(LOG_STR("RCP does not recover")); + return; + } + ESP_LOGW(TAG, "RCP failure detected, reset attempt %u of %u", attempt, MAX_RESET_ATTEMPTS); + this->last_rcp_reset_ms_ = now; + } + this->rcp_reset_pin_->digital_write(true); + vTaskDelay(pdMS_TO_TICKS(10)); + this->rcp_reset_pin_->digital_write(false); + vTaskDelay(pdMS_TO_TICKS(30)); +} +#endif + +int OpenThreadComponent::openthread_stop_() { return esp_openthread_mainloop_exit(); } + network::IPAddresses OpenThreadComponent::get_ip_addresses() { network::IPAddresses addresses; - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - esp_netif_t *netif = esp_netif_get_default_netif(); - count = esp_netif_get_all_ip6(netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); +#if USE_NETWORK_IPV6 + // The task's clean-up destroys the netif under the lock, so the netif is only read while it is held; + // the OpenThread task releases the lock between events, so the wait is short + auto lock = InstanceLock::try_acquire(10); + if (!lock) { + ESP_LOGV(TAG, "Instance lock busy, no addresses this time"); + return addresses; } + if (esp_netif_t *netif = esp_openthread_get_netif(); netif != nullptr) { + network::fill_ip6_addresses(netif, addresses); + } +#endif return addresses; } +#ifdef USE_OPENTHREAD_BORDER_ROUTER +static constexpr uint32_t BORDER_ROUTER_RETRY_MS = 10000; + +void OpenThreadComponent::loop() { + // Thread starts on its own. Border routing starts once the Wi-Fi backbone has an address. When the + // backbone goes away the stack is told so it pauses routing on that interface; when it comes back + // the border router is created afresh, since the port's infra interface keeps the socket and the + // addresses of the backbone as it was. The Wi-Fi listener enables the loop on every link change + if (!this->mainloop_running_) { + return; + } + const bool backbone_up = wifi::global_wifi_component->is_connected(); + if (backbone_up == this->backbone_up_) { + this->disable_loop(); + return; + } + // Never wait for the lock on the main loop; the OpenThread task releases it between events + auto lock = InstanceLock::try_acquire(0); + if (!lock) { + return; + } + auto retry_later = [this](const LogString *reason) { + this->status_set_error(reason); + this->disable_loop(); + this->set_timeout(BORDER_ROUTER_RETRY_MS, [this]() { this->enable_loop(); }); + }; + if (!backbone_up) { + if (this->border_router_started_) { + ESP_LOGI(TAG, "Wi-Fi backbone lost, border routing pauses"); + esp_netif_t *backbone = esp_openthread_get_backbone_netif(); + // A refused report is not retried: the border router is created afresh when the backbone returns + if (otPlatInfraIfStateChanged(lock.get_instance(), esp_netif_get_netif_impl_index(backbone), false) != + OT_ERROR_NONE) { + ESP_LOGW(TAG, "Stack did not take the backbone loss, routes stay until Wi-Fi returns"); + } + } + this->backbone_up_ = false; + this->disable_loop(); + return; + } + if (this->border_router_started_) { + ESP_LOGI(TAG, "Wi-Fi backbone back, border routing restarts"); + // The old router stays marked as started until it is gone, so a retry tears it down first + if (const esp_err_t err = esp_openthread_border_router_deinit(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to deinitialize OpenThread Border Router: %s", esp_err_to_name(err)); + retry_later(LOG_STR("Border router deinit failed")); + return; + } + this->border_router_started_ = false; + } + if (const esp_err_t err = esp_openthread_border_router_init(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize OpenThread Border Router: %s", esp_err_to_name(err)); + retry_later(LOG_STR("Border router init failed")); + return; + } + this->status_clear_error(); + this->border_router_started_ = true; + this->backbone_up_ = true; + ESP_LOGI(TAG, "Border routing started"); + this->disable_loop(); +} +#endif + // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } @@ -149,7 +424,23 @@ InstanceLock InstanceLock::try_acquire(int delay) { if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { return InstanceLock(false); } - return InstanceLock(esp_openthread_lock_acquire(delay)); + if (!esp_openthread_lock_acquire(delay)) { + return InstanceLock(false); + } + // The clean-up may have released the lock to a waiter just before deinit destroys it + if (global_openthread_component->is_task_exited()) { + esp_openthread_lock_release(); + return InstanceLock(false); + } + return InstanceLock(true); +} + +// The lock went away with the task, so waiting for it could never end +static void abort_if_task_exited() { + if (global_openthread_component->is_task_exited()) { + ESP_LOGE(TAG, "OpenThread task has exited, cannot acquire instance lock"); + abort(); + } } InstanceLock InstanceLock::acquire() { @@ -160,15 +451,16 @@ InstanceLock InstanceLock::acquire() { abort(); } // A failed setup() never creates the lock; say so now instead of after the wait below - if (global_openthread_component->is_failed()) { + if (global_openthread_component->is_failed() && !global_openthread_component->is_lock_initialized()) { ESP_LOGE(TAG, "Setup failed, cannot acquire instance lock"); abort(); } // Wait for the lock to be created before attempting to acquire it. - // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_start(). + // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_init(). constexpr uint32_t lock_init_timeout_ms = 10000; uint32_t start = millis(); while (!global_openthread_component->is_lock_initialized()) { + abort_if_task_exited(); if (millis() - start > lock_init_timeout_ms) { ESP_LOGE(TAG, "OpenThread lock not initialized after %" PRIu32 "ms, aborting", lock_init_timeout_ms); abort(); @@ -177,8 +469,14 @@ InstanceLock InstanceLock::acquire() { esp_task_wdt_reset(); } while (!esp_openthread_lock_acquire(100)) { + abort_if_task_exited(); esp_task_wdt_reset(); } + // The clean-up may have released the lock to this waiter just before deinit destroys it + if (global_openthread_component->is_task_exited()) { + esp_openthread_lock_release(); + abort_if_task_exited(); + } return InstanceLock(true); } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d308ad2b977..c8e1df12f31 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1,4 +1,5 @@ #include "wifi_component.h" +#include "esphome/components/network/util.h" #ifdef USE_WIFI #ifdef USE_ESP32 @@ -863,14 +864,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { addresses[0] = network::IPAddress(&ip.ip); } #if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(s_sta_netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } + network::fill_ip6_addresses(s_sta_netif, addresses); #endif /* USE_NETWORK_IPV6 */ return addresses; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0e020975e84..79ea459bad8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -574,13 +574,20 @@ #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD +#define USE_OPENTHREAD_SRP #define USE_ZIGBEE #endif +#if defined(USE_ESP32_VARIANT_ESP32S3) +// Border router with a UART RCP; a native-radio border router (C5/C6) adds only the coexistence call +#define USE_OPENTHREAD +#define USE_OPENTHREAD_BORDER_ROUTER +#define USE_OPENTHREAD_RCP_UART +#endif #if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #define USE_ESPECTRE #endif -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP #define USE_MDNS_SUPPORTS_ENABLE_DISABLE #ifdef USE_ETHERNET #define USE_MDNS_ACTIVE_INTERFACE_LISTENER diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 index 63dbeffd772..8d5ddfc39f8 100644 --- a/sdkconfig.defaults.esp32c6 +++ b/sdkconfig.defaults.esp32c6 @@ -7,6 +7,7 @@ CONFIG_IEEE802154_ENABLED=y CONFIG_OPENTHREAD_ENABLED=y CONFIG_OPENTHREAD_RADIO_NATIVE=y +CONFIG_OPENTHREAD_FTD=y # zigbee CONFIG_ZB_ENABLED=y diff --git a/sdkconfig.defaults.esp32s3 b/sdkconfig.defaults.esp32s3 index 15b97eb1b41..5204d95db52 100644 --- a/sdkconfig.defaults.esp32s3 +++ b/sdkconfig.defaults.esp32s3 @@ -10,3 +10,28 @@ CONFIG_TINYUSB_CDC_ENABLED=y CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 + +# OpenThread Border Router with external UART RCP. +CONFIG_OPENTHREAD_ENABLED=y +CONFIG_OPENTHREAD_FTD=y +CONFIG_OPENTHREAD_PLATFORM_NETIF=y +CONFIG_OPENTHREAD_BORDER_ROUTER=y +CONFIG_OPENTHREAD_RADIO_SPINEL_UART=y +CONFIG_OPENTHREAD_CLI=n +CONFIG_OPENTHREAD_CONSOLE_ENABLE=n +CONFIG_OPENTHREAD_DIAG=n +CONFIG_OPENTHREAD_DNS64_CLIENT=n +CONFIG_OPENTHREAD_SRP_CLIENT=n +CONFIG_LWIP_IPV6_FORWARD=y +CONFIG_LWIP_IPV6_NUM_ADDRESSES=12 +CONFIG_LWIP_MULTICAST_PING=y +CONFIG_LWIP_NETIF_STATUS_CALLBACK=y +CONFIG_LWIP_HOOK_IP6_ROUTE_DEFAULT=y +CONFIG_LWIP_HOOK_ND6_GET_GW_DEFAULT=y +CONFIG_LWIP_HOOK_IP6_INPUT_CUSTOM=y +CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_CUSTOM=y +CONFIG_MDNS_MULTIPLE_INSTANCE=y +CONFIG_MBEDTLS_CMAC_C=y +CONFIG_MBEDTLS_SSL_PROTO_DTLS=y +CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE=y +CONFIG_MBEDTLS_ECJPAKE_C=y diff --git a/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml b/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml new file mode 100644 index 00000000000..a6f8877e31b --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: {} + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + mesh_local_prefix: fd53:145f:ed22:ad81::/64 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd8c099c09f..3cde877f7ad 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2240,9 +2240,10 @@ def test_mbedtls_tls_openthread_requires_server_and_extras( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" - generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + """The Border Router needs TLS/DTLS server support and OpenThread crypto extras.""" + generate_main(component_config_path("mbedtls_tls_openthread_border_router.yaml")) mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] + assert mbedtls.tls_required is True assert mbedtls.tls_server_required is True assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS diff --git a/tests/component_tests/mdns/test_service_enable_disable.py b/tests/component_tests/mdns/test_service_enable_disable.py index eacf722f177..ecd3cdeb92a 100644 --- a/tests/component_tests/mdns/test_service_enable_disable.py +++ b/tests/component_tests/mdns/test_service_enable_disable.py @@ -38,6 +38,19 @@ def test_esp32_adds_define_and_keeps_services_stored( assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() +def test_esp32_border_router_adds_define( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_config( + set_core_config, + PlatformFramework.ESP32_IDF, + {"mdns": {CONF_DISABLED: False}, "openthread": {"border_router": {}}}, + ) + + assert mdns.request_service_enable_disable() is True + assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() + + @pytest.mark.parametrize( "platform_framework", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO], diff --git a/tests/component_tests/openthread/config/border_router.yaml b/tests/component_tests/openthread/config/border_router.yaml new file mode 100644 index 00000000000..04e033f55ab --- /dev/null +++ b/tests/component_tests/openthread/config/border_router.yaml @@ -0,0 +1,24 @@ +esphome: + name: openthread-border-router + +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: {} + network_name: TestNetwork + channel: 15 + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x0123 + ext_pan_id: 0x00000000000000AB + pskc: 0x000000000000000000000000000000CD + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/component_tests/openthread/config/border_router_rcp.yaml b/tests/component_tests/openthread/config/border_router_rcp.yaml new file mode 100644 index 00000000000..26204f9719b --- /dev/null +++ b/tests/component_tests/openthread/config/border_router_rcp.yaml @@ -0,0 +1,27 @@ +esphome: + name: openthread-border-router-rcp + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + network_name: TestNetwork + channel: 15 + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x0123 + ext_pan_id: 0x00000000000000AB + pskc: 0x000000000000000000000000000000CD + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py index c0e155ec7e3..96aeb92ffaa 100644 --- a/tests/component_tests/openthread/test_init.py +++ b/tests/component_tests/openthread/test_init.py @@ -15,7 +15,7 @@ from esphome.components.esp32 import ( from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX import esphome.config_validation as cv -from esphome.const import PlatformFramework, Toolchain +from esphome.const import KEY_FRAMEWORK_VERSION, PlatformFramework, Toolchain from esphome.core import CORE, HexInt from esphome.yaml_util import Sensitive from tests.component_tests.types import SetCoreConfigCallable @@ -42,7 +42,9 @@ def test_credentials_are_sensitive( """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} + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)}, + platform_data={KEY_VARIANT: VARIANT_ESP32C6}, ) validated = CONFIG_SCHEMA(config) assert isinstance(validated[field], Sensitive) diff --git a/tests/component_tests/openthread/test_openthread.py b/tests/component_tests/openthread/test_openthread.py new file mode 100644 index 00000000000..11b6854b5a9 --- /dev/null +++ b/tests/component_tests/openthread/test_openthread.py @@ -0,0 +1,324 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from esphome.components.const import CONF_BORDER_ROUTER +from esphome.components.esp32 import ( + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32S3, +) +from esphome.components.esp32.const import ( + KEY_BOARD, + KEY_ESP32, + KEY_IDF_VERSION, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, +) +from esphome.components.openthread import ( + _RCP_SCHEMA, + BORDER_ROUTER_SDKCONFIG_OPTIONS, + RCP_BORDER_ROUTER_VARIANTS, + _final_validate, + _validate, + _validate_platform, +) +from esphome.components.openthread.const import CONF_DEVICE_TYPE, CONF_RCP +import esphome.config_validation as cv +from esphome.const import ( + CONF_AP, + CONF_BAUD_RATE, + CONF_ENABLE_IPV6, + CONF_ENABLE_ON_BOOT, + CONF_ID, + CONF_INVERTED, + CONF_LOGGER, + CONF_NETWORKS, + CONF_NUMBER, + CONF_OPENTHREAD, + CONF_RX_PIN, + CONF_TX_PIN, + CONF_USE_ADDRESS, + CONF_WIFI, + KEY_FRAMEWORK_VERSION, + PlatformFramework, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONFIG_DIR = Path(__file__).parent / "config" +CONF_NETWORK = "network" +DEVICE_TYPE_FTD = "FTD" +DEVICE_TYPE_MTD = "MTD" +RCP_PINS = {CONF_RX_PIN: 18, CONF_TX_PIN: 17} + + +@pytest.fixture +def otbr_config() -> ConfigType: + return { + CONF_ID: "openthread_component", + CONF_BORDER_ROUTER: {}, + CONF_DEVICE_TYPE: DEVICE_TYPE_FTD, + } + + +@pytest.fixture +def full_config(otbr_config: ConfigType) -> ConfigType: + return { + CONF_OPENTHREAD: otbr_config, + CONF_NETWORK: {CONF_ENABLE_IPV6: True}, + CONF_WIFI: { + CONF_ENABLE_ON_BOOT: True, + CONF_NETWORKS: [{"ssid": "test"}], + }, + } + + +def _set_esp32_idf_core( + set_core_config: SetCoreConfigCallable, + variant: str = VARIANT_ESP32C6, + idf_version: cv.Version | None = None, +) -> None: + if idf_version is None: + idf_version = cv.Version(5, 5, 5) + set_core_config( + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: idf_version}, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_IDF_VERSION: idf_version, + KEY_VARIANT: variant, + KEY_SDKCONFIG_OPTIONS: {}, + }, + ) + + +def _run_final_validation( + otbr_config: ConfigType, + full_config: ConfigType, +) -> None: + token = fv.full_config.set(full_config) + try: + _final_validate(otbr_config) + finally: + fv.full_config.reset(token) + + +def test_plain_openthread_uses_the_srp_client( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(CONFIG_DIR / "no_custom_header.yaml") + + defines = {define.name for define in CORE.defines} + assert "USE_OPENTHREAD_SRP" in defines + assert "USE_OPENTHREAD_BORDER_ROUTER" not in defines + assert ( + CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_OPENTHREAD_SRP_CLIENT"] + is True + ) + + +def test_border_router_codegen( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(CONFIG_DIR / "border_router.yaml") + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + for option, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items(): + assert sdkconfig[option] == value + assert sdkconfig["CONFIG_SW_COEXIST_ENABLE"] is True + assert sdkconfig["CONFIG_OPENTHREAD_SRP_CLIENT"] is False + assert sdkconfig["CONFIG_OPENTHREAD_DNS64_CLIENT"] is False + assert ( + sdkconfig["CONFIG_OPENTHREAD_NETWORK_MASTERKEY"] + == "00112233445566778899aabbccddeeff" + ) + assert sdkconfig["CONFIG_OPENTHREAD_NETWORK_EXTPANID"] == "00000000000000ab" + assert ( + sdkconfig["CONFIG_OPENTHREAD_NETWORK_PSKC"] + == "000000000000000000000000000000cd" + ) + defines = {define.name for define in CORE.defines} + assert "USE_OPENTHREAD_BORDER_ROUTER" in defines + assert "USE_OPENTHREAD_SRP" not in defines + + +def test_border_router_rcp_codegen( + generate_main: Callable[[str | Path], str], +) -> None: + cpp_main = generate_main(CONFIG_DIR / "border_router_rcp.yaml") + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_OPENTHREAD_RADIO_NATIVE"] is False + assert sdkconfig["CONFIG_OPENTHREAD_RADIO_SPINEL_UART"] is True + assert sdkconfig["CONFIG_VFS_SUPPORT_DIR"] is True + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is not True + assert any(define.name == "USE_OPENTHREAD_RCP_UART" for define in CORE.defines) + assert "OpenThreadComponent(460800, 18, 17, nullptr)" in cpp_main + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda config, _: config.pop(CONF_WIFI), + "requires a Wi-Fi STA backbone", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_NETWORKS: []}), + "requires at least one configured", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_ENABLE_ON_BOOT: False}), + "enable_on_boot: true", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_AP: {}}), + "does not support Wi-Fi AP", + ), + ( + lambda config, _: config[CONF_NETWORK].update({CONF_ENABLE_IPV6: False}), + "requires IPv6 to be enabled", + ), + ], +) +def test_border_router_rejects_incompatible_config( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, + mutate: Any, + message: str, +) -> None: + _set_esp32_idf_core(set_core_config) + mutate(full_config, otbr_config) + + with pytest.raises(cv.Invalid, match=message): + _run_final_validation(otbr_config, full_config) + + +def test_border_router_requires_ftd( + set_core_config: SetCoreConfigCallable, otbr_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config) + otbr_config[CONF_DEVICE_TYPE] = DEVICE_TYPE_MTD + otbr_config[CONF_USE_ADDRESS] = "test.local" + + with pytest.raises(cv.Invalid, match="device_type: FTD"): + _validate(otbr_config) + + +def test_native_border_router_rejects_variant_without_ieee802154( + set_core_config: SetCoreConfigCallable, otbr_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + + with pytest.raises(cv.Invalid, match="both Wi-Fi and IEEE 802.15.4"): + _validate_platform(otbr_config) + + +def test_requires_idf_5_5_5(set_core_config: SetCoreConfigCallable) -> None: + _set_esp32_idf_core(set_core_config, idf_version=cv.Version(5, 5, 2)) + + with pytest.raises(cv.Invalid, match="at least framework version 5.5.5"): + _validate_platform({CONF_DEVICE_TYPE: DEVICE_TYPE_FTD}) + + +def test_rejects_arduino(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_ARDUINO) + + with pytest.raises(cv.Invalid, match="incompatible with ESP32 using arduino"): + _validate_platform({CONF_DEVICE_TYPE: DEVICE_TYPE_FTD}) + + +@pytest.mark.parametrize("variant", sorted(RCP_BORDER_ROUTER_VARIANTS)) +def test_border_router_rcp_accepts_wifi_variants( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + + assert _validate_platform(otbr_config) is otbr_config + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32H2, VARIANT_ESP32H4]) +def test_border_router_rcp_rejects_variants_without_wifi( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + + with pytest.raises(cv.Invalid, match="requires a Wi-Fi-capable ESP32 variant"): + _validate_platform(otbr_config) + + +@pytest.mark.parametrize( + ("extra_config", "message"), + [ + ({"uart": [{}]}, "cannot currently be combined"), + ( + {CONF_LOGGER: {"hardware_uart": "UART1", "baud_rate": 115200}}, + "reserves UART1", + ), + ], +) +def test_border_router_rcp_rejects_uart_conflicts( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, + extra_config: ConfigType, + message: str, +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + full_config.update(extra_config) + + with pytest.raises(cv.Invalid, match=message): + _run_final_validation(otbr_config, full_config) + + +@pytest.mark.parametrize( + "rcp_config", + [ + {**RCP_PINS, CONF_BAUD_RATE: 0}, + {**RCP_PINS, CONF_BAUD_RATE: 9599}, + {CONF_RX_PIN: {CONF_NUMBER: 18, CONF_INVERTED: True}, CONF_TX_PIN: 17}, + ], +) +def test_rcp_schema_rejects_bad_options( + set_core_config: SetCoreConfigCallable, rcp_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + + with pytest.raises(cv.Invalid): + _RCP_SCHEMA(rcp_config) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C5, VARIANT_ESP32C6]) +def test_native_border_router_accepts_wifi_ieee802154_variants( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + + assert _validate_platform(otbr_config) is otbr_config + + +def test_border_router_accepts_supported_config( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, +) -> None: + _set_esp32_idf_core(set_core_config) + + _run_final_validation(otbr_config, full_config) diff --git a/tests/components/openthread/test-border-router.esp32-c5-idf.yaml b/tests/components/openthread/test-border-router.esp32-c5-idf.yaml new file mode 100644 index 00000000000..c3558b16f9d --- /dev/null +++ b/tests/components/openthread/test-border-router.esp32-c5-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR Test" + password: "test-password" + +openthread: + border_router: {} + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 + +button: + - platform: factory_reset + name: Factory Reset diff --git a/tests/components/openthread/test-border-router.esp32-c6-idf.yaml b/tests/components/openthread/test-border-router.esp32-c6-idf.yaml new file mode 100644 index 00000000000..c3558b16f9d --- /dev/null +++ b/tests/components/openthread/test-border-router.esp32-c6-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR Test" + password: "test-password" + +openthread: + border_router: {} + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 + +button: + - platform: factory_reset + name: Factory Reset diff --git a/tests/components/openthread/test-rcp.esp32-idf.yaml b/tests/components/openthread/test-rcp.esp32-idf.yaml new file mode 100644 index 00000000000..b784f54ff9d --- /dev/null +++ b/tests/components/openthread/test-rcp.esp32-idf.yaml @@ -0,0 +1,19 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR RCP Test" + password: "test-password" + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/components/openthread/test-rcp.esp32-s3-idf.yaml b/tests/components/openthread/test-rcp.esp32-s3-idf.yaml new file mode 100644 index 00000000000..6d643ff91e6 --- /dev/null +++ b/tests/components/openthread/test-rcp.esp32-s3-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR RCP Test" + password: "test-password" + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + reset_pin: GPIO16 + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 From ecd5980c5d47acfc5ebdfb2d453101a96bb5506f Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Thu, 15 Oct 2026 01:41:37 -0500 Subject: [PATCH 417/651] [epaper_spi] Pass bits per pixel and plane count to the base constructor (#20683) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/epaper_spi.cpp | 2 +- esphome/components/epaper_spi/epaper_spi.h | 21 +++++--- .../components/epaper_spi/epaper_spi_4bpp.h | 4 +- .../epaper_spi/epaper_spi_inkplate2.h | 5 +- .../epaper_spi/epaper_spi_jd79660.h | 7 +-- .../components/epaper_spi/epaper_spi_mono.h | 7 +-- .../epaper_spi/epaper_spi_ssd1677.cpp | 6 +-- .../epaper_spi/epaper_spi_ssd1677.h | 4 +- .../epaper_spi/epaper_spi_ssd1677_gray4.h | 6 +-- .../epaper_spi/epaper_spi_t133a01.h | 4 +- .../components/epaper_spi/epaper_spi_uc8179.h | 5 +- .../epaper_spi/epaper_waveshare_bwr.h | 4 +- .../components/epaper_spi/epaper_weact_3c.h | 4 +- .../epaper_spi/display/test_buffer_layout.cpp | 49 +++++++++++++++++++ 14 files changed, 79 insertions(+), 49 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_buffer_layout.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 7b962d59ea8..d8ead388183 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -468,7 +468,7 @@ void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, cons ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { size_t contiguous_length; uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length); - if (!this->buffer_is_mono() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || + if (!this->buffer_is_mono_() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || contiguous_length != this->buffer_length_) { Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 384b9cb0181..37f9a1449a2 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -38,15 +38,18 @@ class EPaperBase : public Display, spi::DATA_RATE_2MHZ> { public: EPaperBase(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence = nullptr, - size_t init_sequence_length = 0, DisplayType display_type = DISPLAY_TYPE_BINARY) + size_t init_sequence_length = 0, DisplayType display_type = DISPLAY_TYPE_BINARY, + uint8_t bits_per_pixel = 1, uint8_t planes = 1) : name_(name), width_(width), + row_width_((width * bits_per_pixel + 7) / 8), height_(height), + bits_per_pixel_(bits_per_pixel), + planes_(planes), init_sequence_(init_sequence), init_sequence_length_(init_sequence_length), - display_type_(display_type) { - this->row_width_ = (this->width_ + 7) / 8; // width of a row in bytes - } + display_type_(display_type), + buffer_length_((size_t) this->row_width_ * height * planes) {} void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } float get_setup_priority() const override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } @@ -134,8 +137,8 @@ class EPaperBase : public Display, bool rotate_coordinates_(int &x, int &y); void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad); - // Whether the buffer holds 1 bit per pixel as draw_pixel_at() writes it, so draw_pixels_at() can write it directly - virtual bool buffer_is_mono() const { return false; } + // Whether the buffer is a single plane of 1 bit per pixel, as draw_pixel_at() writes it + bool buffer_is_mono_() const { return this->bits_per_pixel_ == 1 && this->planes_ == 1; } /** * Methods that must be implemented by concrete classes to control the display @@ -173,13 +176,15 @@ class EPaperBase : public Display, // properties initialised in the constructor const char *name_; uint16_t width_; - uint16_t row_width_; // width of a row in bytes + uint16_t row_width_; // width of a row of one plane in bytes uint16_t height_; + uint8_t bits_per_pixel_; + uint8_t planes_; // the buffer holds this many planes of row_width_ * height_ bytes, one after another const uint8_t *init_sequence_; size_t init_sequence_length_; DisplayType display_type_; - size_t buffer_length_{}; + size_t buffer_length_; size_t current_data_index_{}; // used by data transfer to track progress split_buffer::SplitBuffer buffer_{}; GPIOPin *dc_pin_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.h b/esphome/components/epaper_spi/epaper_spi_4bpp.h index 6eebe24c91f..3843752f858 100644 --- a/esphome/components/epaper_spi/epaper_spi_4bpp.h +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.h @@ -15,9 +15,7 @@ class EPaper4bpp : public EPaperBase { public: EPaper4bpp(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - this->buffer_length_ = width * height / 2; // 2 pixels per byte - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 4) {} void fill(Color color) override; void clear() override; diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.h b/esphome/components/epaper_spi/epaper_spi_inkplate2.h index 657eca47590..09926db4ad8 100644 --- a/esphome/components/epaper_spi/epaper_spi_inkplate2.h +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.h @@ -9,10 +9,7 @@ class EPaperInkplate2 final : public EPaperBase { public: EPaperInkplate2(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - // Dual-plane buffer: black/white plane followed by red plane, 1 bit per pixel each. - this->buffer_length_ = this->row_width_ * this->height_ * 2; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 1, 2) {} void fill(Color color) override; void clear() override; diff --git a/esphome/components/epaper_spi/epaper_spi_jd79660.h b/esphome/components/epaper_spi/epaper_spi_jd79660.h index 4e488fe93ec..522950dc77b 100644 --- a/esphome/components/epaper_spi/epaper_spi_jd79660.h +++ b/esphome/components/epaper_spi/epaper_spi_jd79660.h @@ -27,12 +27,9 @@ class EPaperJD79660 final : public EPaperBase { public: EPaperJD79660(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length, const uint8_t *fast_update, uint16_t fast_update_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR), + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 2), fast_update_(fast_update), - fast_update_length_(fast_update_length) { - this->row_width_ = (width + 3) / 4; // Fix base class calc (2bpp instead of 1bpp) - this->buffer_length_ = this->row_width_ * height; - } + fast_update_length_(fast_update_length) {} void fill(Color color) override; diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index 0f570149de4..4dc54ae60ba 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -9,13 +9,10 @@ namespace esphome::epaper_spi { class EPaperMono : public EPaperBase { public: EPaperMono(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type) { - this->buffer_length_ = (width + 7) / 8 * height; // 8 pixels per byte, rounded up - } + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {} protected: - bool buffer_is_mono() const override { return true; } void refresh_screen(bool partial) override; void power_on() override {} void power_off() override{}; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp index a79bbc3dc91..ffc5d65a1ed 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -58,9 +58,9 @@ bool HOT EPaperSSD1677::transfer_data() { // A full update ignores 0x26, and the copy may not hold a real frame yet (first update after // boot): send the new frame to both planes. const bool send_copy = this->plane_ == 0 && this->update_count_ != 0; - // A mono buffer already holds the frame as a RAM plane does, so it can be sent as it is; a deeper - // buffer (the four-level model) returns false and packs the plane in plane_row(). - const bool direct = send_copy || this->buffer_is_mono(); + // A 1 bit per pixel buffer already holds the frame as a RAM plane does, so it can be sent as it is; a + // deeper buffer (the four-level model, 2 bits per pixel) packs the plane in plane_row() instead. + const bool direct = send_copy || this->buffer_is_mono_(); const auto &source = send_copy ? this->sent_ : this->buffer_; const size_t row_length = this->plane_row_length_(); const size_t plane_length = row_length * this->height_; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h index b8c93a1ecb8..d8481b712c7 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -23,8 +23,8 @@ namespace esphome::epaper_spi { class EPaperSSD1677 : public EPaperMono { public: EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) - : EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type) {} + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1) + : EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {} void setup() override; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h index f21ab3eb069..e44fda37c2a 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -29,16 +29,12 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 { public: EPaperSSD1677Gray4(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE) { - this->row_width_ = (width + 3) / 4; // 4 pixels per byte - this->buffer_length_ = (size_t) this->row_width_ * height; - } + : EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE, 2) {} void fill(Color color) override; void draw_pixel_at(int x, int y, Color color) override; protected: - bool buffer_is_mono() const override { return false; } // A partial update if partial updates are enabled and this is not a full one. bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); } void plane_row(size_t y, uint8_t *out) override; diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h index 5d356f90b01..7c14876bafd 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.h +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -22,9 +22,7 @@ class EPaperT133A01 : public EPaperBase { public: EPaperT133A01(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - this->buffer_length_ = (size_t) width * height / 2; // 2 pixels per byte at 4bpp - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 4) {} void set_cs_pins(GPIOPin *cs, GPIOPin *cs1) { this->cs_pin_ = cs; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index b12ed54ef6d..fda4297802b 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -32,12 +32,9 @@ class EPaperUC8179 final : public EPaperBase { public: EPaperUC8179(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = this->row_width_ * height; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} protected: - bool buffer_is_mono() const override { return true; } bool initialise(bool partial) override; bool transfer_data() override; void refresh_screen(bool partial) override; diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h index a090faa14d2..d34f05cbb2b 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.h +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -22,9 +22,7 @@ class EPaperWaveshareBWR : public EPaperBase { public: EPaperWaveshareBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = this->row_width_ * height * 2; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY, 1, 2) {} void fill(Color color) override; diff --git a/esphome/components/epaper_spi/epaper_weact_3c.h b/esphome/components/epaper_spi/epaper_weact_3c.h index a31c2be8176..d3c89851962 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.h +++ b/esphome/components/epaper_spi/epaper_weact_3c.h @@ -18,9 +18,7 @@ class EPaperWeAct3C : public EPaperBase { public: EPaperWeAct3C(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = this->row_width_ * height * 2; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY, 1, 2) {} void fill(Color color) override; void clear() override; diff --git a/tests/components/epaper_spi/display/test_buffer_layout.cpp b/tests/components/epaper_spi/display/test_buffer_layout.cpp new file mode 100644 index 00000000000..34ffecc28e5 --- /dev/null +++ b/tests/components/epaper_spi/display/test_buffer_layout.cpp @@ -0,0 +1,49 @@ +#include + +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" +#include "esphome/components/epaper_spi/epaper_waveshare_bwr.h" +#include "esphome/components/epaper_spi/epaper_weact_3c.h" + +namespace esphome::epaper_spi::testing { +namespace { + +template class Layout : public Driver { + public: + Layout(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) {} + uint16_t row_width() const { return this->row_width_; } + size_t buffer_length() const { return this->buffer_length_; } + bool is_mono() const { return this->buffer_is_mono_(); } +}; + +/// Each driver's bits per pixel and plane count give the row width and buffer size its drawing code expects, and only +/// a single 1 bit per pixel plane takes the direct drawing paths. +TEST(EPaperBufferLayout, SizesFollowBitsPerPixelAndPlanes) { + Layout mono(21, 3); + EXPECT_EQ(mono.row_width(), 3); + EXPECT_EQ(mono.buffer_length(), 9u); + EXPECT_TRUE(mono.is_mono()); + + Layout gray(21, 3); + EXPECT_EQ(gray.row_width(), 6); + EXPECT_EQ(gray.buffer_length(), 18u); + EXPECT_FALSE(gray.is_mono()); + + Layout t133(20, 3); + EXPECT_EQ(t133.row_width(), 10); + EXPECT_EQ(t133.buffer_length(), 30u); + EXPECT_FALSE(t133.is_mono()); + + Layout weact(21, 3); + EXPECT_EQ(weact.row_width(), 3); + EXPECT_EQ(weact.buffer_length(), 18u); + EXPECT_FALSE(weact.is_mono()); + + Layout bwr(21, 3); + EXPECT_EQ(bwr.buffer_length(), 18u); + EXPECT_FALSE(bwr.is_mono()); +} + +} // namespace +} // namespace esphome::epaper_spi::testing From bb0837d814a04769b7b7ff7c9634c970a4d3e299 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:44:23 +1100 Subject: [PATCH 418/651] [lvgl] Implement menu widget (#18022) --- esphome/components/lvgl/__init__.py | 3 + esphome/components/lvgl/defines.py | 18 + esphome/components/lvgl/lvgl_esphome.cpp | 7 + esphome/components/lvgl/lvgl_esphome.h | 6 + esphome/components/lvgl/widgets/menu.py | 326 ++++++++++++++ .../lvgl/config/menu_test.yaml | 81 ++++ tests/component_tests/lvgl/test_menu.py | 407 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 50 +++ tests/components/lvgl/validate.host.yaml | 42 +- 9 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 esphome/components/lvgl/widgets/menu.py create mode 100644 tests/component_tests/lvgl/config/menu_test.yaml create mode 100644 tests/component_tests/lvgl/test_menu.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6806eab0cf3..810d15f7005 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -112,6 +112,7 @@ from .widgets import ( set_obj_properties, ) from .widgets.keyboard import attach_textareas +from .widgets.menu import add_root_back_button_triggers, validate_menu_pages # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -333,6 +334,7 @@ def final_validation(config_list): path = global_config.get_path_for_id(name) widget_conf = global_config.get_config_for_path(path[:-1]) widget_type.final_validate(name, conf, widget_conf, path[1:]) + validate_menu_pages(global_config) def lv_conf_path_flag(lv_conf_h_file: Path) -> str: @@ -513,6 +515,7 @@ async def to_code(configs): await finish_list_triggers() await generate_triggers() + await add_root_back_button_triggers() await generate_align_tos(configs[0]) await attach_textareas() for config in configs: diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 69e15bdeca6..203a75b0c8e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -25,6 +25,7 @@ KEY_LV_DEFINES = "lv_defines" KEY_LV_FONTS_USED = "lv_fonts_used" KEY_LV_IMAGES_USED = "lv_images_used" KEY_LV_USES = "lv_uses" +KEY_MENU_SET_PAGE_ACTIONS = "menu_set_page_actions" KEY_NAMED_STYLES = "named_styles" KEY_OPTIONS = "options" KEY_REFRESHED_WIDGETS = "refreshed_widgets" @@ -172,6 +173,10 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) +def get_menu_set_page_actions() -> list: + return _get_data(KEY_MENU_SET_PAGE_ACTIONS, []) + + def get_list_triggers() -> dict: return _get_data(KEY_LIST_TRIGGERS, {}) @@ -682,6 +687,12 @@ LV_MENU_MODES = LvConstant( "BOTTOM_FIXED", ) +LV_MENU_ROOT_BACK_BUTTON_MODES = LvConstant( + "LV_MENU_ROOT_BACK_BUTTON_", + "DISABLED", + "ENABLED", +) + LV_CHART_TYPES = ( "NONE", "LINE", @@ -762,6 +773,7 @@ CONF_GRID_ROW_ALIGN = "grid_row_align" CONF_GRID_ROWS = "grid_rows" CONF_HEADER_BUTTONS = "header_buttons" CONF_HEADER_MODE = "header_mode" +CONF_HEADER_STYLE = "header_style" CONF_HOME = "home" CONF_IMAGE = "image" CONF_INDICATORS = "indicators" @@ -778,6 +790,7 @@ CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" CONF_MAJOR_TICKS_STYLE = "major_ticks_style" CONF_MAPPING = "mapping" +CONF_MENU = "menu" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" @@ -788,6 +801,7 @@ CONF_ON_LANDSCAPE = "on_landscape" CONF_ON_PAUSE = "on_pause" CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" +CONF_ON_ROOT_BACK_BUTTON_CLICK = "on_root_back_button_click" CONF_ON_SELECT = "on_select" CONF_OPA = "opa" CONF_NEXT = "next" @@ -810,6 +824,7 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" +CONF_ROOT_PAGE = "root_page" CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" @@ -817,9 +832,12 @@ CONF_SCROLL_DIR = "scroll_dir" CONF_SCROLLABLE = "scrollable" CONF_SCROLL_SNAP_X = "scroll_snap_x" CONF_SCROLL_SNAP_Y = "scroll_snap_y" +CONF_SECTIONS = "sections" CONF_SELECTED_INDEX = "selected_index" CONF_SELECTED_TEXT = "selected_text" CONF_SHOW_SNOW = "show_snow" +CONF_SIDEBAR_PAGE = "sidebar_page" +CONF_SIDEBAR_HEADER_STYLE = "sidebar_header_style" CONF_SPIN_TIME = "spin_time" CONF_SRC = "src" CONF_START_ANGLE = "start_angle" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 0b375404d74..9bc83800051 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -1009,6 +1009,13 @@ lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos) { } #endif // USE_LVGL_GRADIENT +#ifdef USE_LVGL_MENU +bool lv_menu_back_button_is_root_click(lv_obj_t *menu, lv_event_t *e) { + return lv_event_get_code(e) == LV_EVENT_CLICKED && + lv_menu_back_button_is_root(menu, static_cast(lv_event_get_target(e))); +} +#endif // USE_LVGL_MENU + lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { auto *indev = lv_indev_get_act(); if (indev == nullptr) { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 75f926d865e..35e5335c10e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -143,6 +143,12 @@ lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index); lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos); #endif // USE_LVGL_GRADIENT +#ifdef USE_LVGL_MENU +// True if `e` is a click on `menu`'s back button while already at the root page -- the one +// case LVGL's own docs say the app must handle itself (there is no default behavior). +bool lv_menu_back_button_is_root_click(lv_obj_t *menu, lv_event_t *e); +#endif // USE_LVGL_MENU + // Parent class for things that wrap an LVGL object class LvCompound { public: diff --git a/esphome/components/lvgl/widgets/menu.py b/esphome/components/lvgl/widgets/menu.py new file mode 100644 index 00000000000..e32a7e9ba35 --- /dev/null +++ b/esphome/components/lvgl/widgets/menu.py @@ -0,0 +1,326 @@ +from esphome import automation +from esphome.automation import Trigger +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_BUTTON, CONF_ID, CONF_PAGES, CONF_TRIGGER_ID +from esphome.final_validate import FinalValidateConfig +from esphome.types import ConfigType + +from ..automation import action_to_code +from ..defines import ( + CONF_HEADER_MODE, + CONF_HEADER_STYLE, + CONF_IMAGE, + CONF_MAIN, + CONF_MENU, + CONF_ON_ROOT_BACK_BUTTON_CLICK, + CONF_PAGE, + CONF_ROOT_BACK_BTN, + CONF_ROOT_PAGE, + CONF_SECTIONS, + CONF_SIDEBAR_HEADER_STYLE, + CONF_SIDEBAR_PAGE, + CONF_TITLE, + LV_MENU_MODES, + LV_MENU_ROOT_BACK_BUTTON_MODES, + TYPE_FLEX, + get_menu_set_page_actions, + get_widget_map, + literal, +) +from ..lv_validation import lv_text +from ..lvcode import ( + EVENT_ARG, + LambdaContext, + LocalVariable, + LvConditional, + lv, + lv_add, + lv_expr, + lv_obj, +) +from ..schemas import container_schema, part_schema +from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr, lvgl_ns +from . import ( + Widget, + WidgetType, + get_widget_, + get_widgets, + set_obj_properties, + widget_to_code, +) +from .obj import obj_spec + +# Local to the "menu" widget only -- not a shared config concept. +CONF_SIDEBAR = "sidebar" + +# Tag types, only used to distinguish declare_id()/use_id() checks for pages and +# sections -- like tileview's lv_tileview_tile_t they carry no extra behaviour, but +# they must have their own names: id type checks compare the type name, so reusing +# `lv_obj_t` here would let any object be passed where a page is required. The +# generated code always declares the variable as `lv_obj_t *`. +lv_menu_page_t = LvType("lv_menu_page_t") +lv_menu_section_t = LvType("lv_menu_section_t") + +lv_menu_t = LvType( + "lv_menu_t", + largs=[(lv_obj_t_ptr, "page")], + lvalue=lambda w: lv_expr.menu_get_cur_main_page(w.obj), + has_on_value=True, +) + + +class MenuSectionType(WidgetType): + """ + A grouped, bordered set of rows inside a menu page (lv_menu_section_create). + Only valid as a direct child of a menu page, so it's not exposed in the + generic `widgets:` menu -- reached only via a page's `sections:` key. + """ + + def __init__(self): + super().__init__( + "menu_section", lv_menu_section_t, (CONF_MAIN,), {}, is_mock=True + ) + + async def obj_creator(self, parent: cg.MockObj, config: dict): + return lv_expr.menu_section_create(parent) + + +menu_section_spec = MenuSectionType() + +MENU_PAGE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TITLE): lv_text, + cv.Optional(CONF_SECTIONS): cv.ensure_list( + container_schema( + menu_section_spec, + {cv.GenerateID(): cv.declare_id(lv_menu_section_t)}, + ) + ), + } +) + + +class MenuPageType(WidgetType): + """ + A page of the menu (lv_menu_page_create), shown one at a time via + `lvgl.menu.set_page`. Like tabview's tabs/tileview's tiles, pages are only + ever declared via the menu's own `pages:` key, not the generic `widgets:` + menu -- reachable content, not a freestanding widget type. + """ + + def __init__(self): + super().__init__( + "menu_page", lv_menu_page_t, (CONF_MAIN,), MENU_PAGE_SCHEMA, is_mock=True + ) + + async def obj_creator(self, parent: cg.MockObj, config: dict): + title = config.get(CONF_TITLE) + return lv_expr.menu_page_create( + parent, await lv_text.process(title) if title is not None else cg.nullptr + ) + + async def on_create(self, var: cg.MockObj, config: dict): + # Runs before the page's ungrouped `widgets:` are added, so sections come first. + for section_conf in config.get(CONF_SECTIONS, ()): + await widget_to_code(section_conf, menu_section_spec, var) + return var + + +menu_page_spec = MenuPageType() + +MENU_SCHEMA = cv.Schema( + { + cv.Required(CONF_PAGES): cv.All( + cv.ensure_list( + container_schema( + menu_page_spec, {cv.GenerateID(): cv.declare_id(lv_menu_page_t)} + ) + ), + cv.Length(min=1), + ), + cv.Required(CONF_ROOT_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_SIDEBAR_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_HEADER_MODE, default="top_fixed"): LV_MENU_MODES.one_of, + cv.Optional( + CONF_ROOT_BACK_BTN, default="disabled" + ): LV_MENU_ROOT_BACK_BUTTON_MODES.one_of, + cv.Optional(CONF_HEADER_STYLE): part_schema(obj_spec.parts), + cv.Optional(CONF_SIDEBAR_HEADER_STYLE): part_schema(obj_spec.parts), + cv.Optional(CONF_ON_ROOT_BACK_BUTTON_CLICK): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template())} + ), + } +) + + +class MenuType(WidgetType): + def __init__(self): + super().__init__( + CONF_MENU, lv_menu_t, (CONF_MAIN,), schema=MENU_SCHEMA, modify_schema={} + ) + + def get_uses(self): + return CONF_BUTTON, CONF_IMAGE, TYPE_FLEX + + def validate(self, value: ConfigType) -> ConfigType: + # The sidebar header only exists once a sidebar page has been set. + if CONF_SIDEBAR_HEADER_STYLE in value and CONF_SIDEBAR_PAGE not in value: + raise cv.Invalid( + f"'{CONF_SIDEBAR_HEADER_STYLE}' requires '{CONF_SIDEBAR_PAGE}'", + path=[CONF_SIDEBAR_HEADER_STYLE], + ) + # Runs before the schema, so the default has not been applied yet and an + # invalid value is left for the schema to report. + back_btn = ( + str(value.get(CONF_ROOT_BACK_BTN, "disabled")) + .upper() + .removeprefix(LV_MENU_ROOT_BACK_BUTTON_MODES.prefix) + ) + if CONF_ON_ROOT_BACK_BUTTON_CLICK in value and back_btn != "ENABLED": + raise cv.Invalid( + f"'{CONF_ON_ROOT_BACK_BUTTON_CLICK}' requires " + f"'{CONF_ROOT_BACK_BTN}: enabled'", + path=[CONF_ON_ROOT_BACK_BUTTON_CLICK], + ) + return value + + async def to_code(self, w: Widget, config: dict): + await w.set_property( + "mode_header", await LV_MENU_MODES.process(config[CONF_HEADER_MODE]) + ) + await w.set_property( + "mode_root_back_button", + await LV_MENU_ROOT_BACK_BUTTON_MODES.process(config[CONF_ROOT_BACK_BTN]), + ) + for page_conf in config[CONF_PAGES]: + await widget_to_code(page_conf, menu_page_spec, w.obj) + + root_page = await get_widget_(config[CONF_ROOT_PAGE]) + lv.menu_set_page(w.obj, root_page.obj) + if sidebar_id := config.get(CONF_SIDEBAR_PAGE): + sidebar_page = await get_widget_(sidebar_id) + lv.menu_set_sidebar_page(w.obj, sidebar_page.obj) + + if header_style := config.get(CONF_HEADER_STYLE): + with LocalVariable( + "menu_header", lv_obj_t, rhs=lv_expr.menu_get_main_header(w.obj) + ) as hdr: + await set_obj_properties(Widget(hdr, obj_spec), header_style) + if sidebar_header_style := config.get(CONF_SIDEBAR_HEADER_STYLE): + with LocalVariable( + "menu_sidebar_header", + lv_obj_t, + rhs=lv_expr.menu_get_sidebar_header(w.obj), + ) as hdr: + await set_obj_properties(Widget(hdr, obj_spec), sidebar_header_style) + + +menu_spec = MenuType() + + +def _menu_page_owners(global_config: FinalValidateConfig) -> dict[str, tuple]: + """ + Map every declared menu page id to the config path of the menu that owns it. + + A page id is always declared inside its menu's `pages:` list, so the owning menu + is three steps up the path (`id` -> list index -> `pages`). + """ + return { + str(declared_id): tuple(path[:-3]) + for declared_id, path in global_config.declare_ids + if declared_id.type is lv_menu_page_t + } + + +def validate_menu_pages(global_config: FinalValidateConfig) -> None: + """ + Check that a menu only ever refers to pages of its own. + + `lv_menu_page_create()` makes a page a child of one specific menu, so handing a + page of another menu to `root_page:`, `sidebar_page:` or `lvgl.menu.set_page` + can only misbehave at run time. It also hangs code generation when the other + menu is defined later, since the waiting menu's page never appears. + """ + owners = _menu_page_owners(global_config) + for menu_path in set(owners.values()): + menu_config = global_config.get_config_for_path(list(menu_path)) + for key in (CONF_ROOT_PAGE, CONF_SIDEBAR_PAGE): + page_id = menu_config.get(key) + if page_id is not None and owners.get(str(page_id)) != menu_path: + raise cv.Invalid( + f"'{page_id}' is not a page of this menu; " + f"'{key}' must name one of the menu's own 'pages:'", + # Paths reported from final validation are relative to the + # component config, so drop the leading domain name. + path=[*menu_path[1:], key], + ) + for config in get_menu_set_page_actions(): + menu_id = config[CONF_ID] + page_id = config[CONF_PAGE] + menu_path = tuple(global_config.get_path_for_id(menu_id)[:-1]) + if owners.get(str(page_id)) != menu_path: + raise cv.Invalid( + f"'lvgl.menu.set_page' page '{page_id}' is not a page of menu '{menu_id}'" + ) + + +async def add_root_back_button_triggers() -> None: + """ + Wire up on_root_back_button_click for every menu, once all widgets exist. + Actions like `lvgl.widget.hide` on the menu itself need the widget map fully + populated, so this must run as a deferred pass rather than inline in + MenuType.to_code -- doing it inline deadlocks the code-generation coroutine + scheduler. + """ + # Snapshot: build_automation() can create widgets (e.g. `lvgl.list.add`). + for w in list(get_widget_map().values()): + if not isinstance(w.type, MenuType): + continue + for conf in w.config.get(CONF_ON_ROOT_BACK_BUTTON_CLICK, ()): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await automation.build_automation(trigger, [], conf) + async with LambdaContext(EVENT_ARG, where=conf[CONF_TRIGGER_ID]) as context: + with LvConditional( + lvgl_ns.lv_menu_back_button_is_root_click(w.obj, literal("event")) + ): + lv_add(trigger.trigger()) + lv_obj.add_event_cb( + w.obj, await context.get_lambda(), LV_EVENT.CLICKED, cg.nullptr + ) + + +def _record_set_page_action(config: ConfigType) -> ConfigType: + """Remember the action so validate_menu_pages() can check the page's owner once + the whole config is known.""" + get_menu_set_page_actions().append(config) + return config + + +@automation.register_action( + "lvgl.menu.set_page", + ObjUpdateAction, + cv.All( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_menu_t), + cv.Required(CONF_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_SIDEBAR, default=False): cv.boolean, + } + ), + _record_set_page_action, + ), + synchronous=True, +) +async def menu_set_page(config, action_id, template_arg, args): + widget = await get_widgets(config) + page = await get_widget_(config[CONF_PAGE]) + sidebar = config[CONF_SIDEBAR] + + async def do_set_page(w: Widget): + if sidebar: + lv.menu_set_sidebar_page(w.obj, page.obj) + else: + lv.menu_set_page(w.obj, page.obj) + + return await action_to_code(widget, do_set_page, action_id, template_arg, args) diff --git a/tests/component_tests/lvgl/config/menu_test.yaml b/tests/component_tests/lvgl/config/menu_test.yaml new file mode 100644 index 00000000000..df0faeb1bc3 --- /dev/null +++ b/tests/component_tests/lvgl/config/menu_test.yaml @@ -0,0 +1,81 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + id: lvgl_id + displays: tft_display + widgets: + - menu: + id: test_menu + header_mode: top_fixed + root_back_btn: enabled + root_page: main_page + sidebar_page: nav_page + header_style: + bg_color: 0xFF0000 + sidebar_header_style: + bg_color: 0x00FF00 + on_root_back_button_click: + - logger.log: "root back clicked" + on_value: + - logger.log: "page changed" + pages: + - id: main_page + title: "Settings" + sections: + - id: wifi_section + widgets: + - label: + text: "Wifi" + - switch: + id: wifi_switch + widgets: + - button: + id: advanced_button + text: "Advanced" + on_click: + - lvgl.menu.set_page: + id: test_menu + page: advanced_page + - id: advanced_page + title: "Advanced" + widgets: + - label: + text: "Debug" + - id: nav_page + title: "Navigate" + widgets: + - button: + id: nav_button + text: "Go" + on_click: + - lvgl.menu.set_page: + id: test_menu + page: main_page + sidebar: true diff --git a/tests/component_tests/lvgl/test_menu.py b/tests/component_tests/lvgl/test_menu.py new file mode 100644 index 00000000000..287dd927cea --- /dev/null +++ b/tests/component_tests/lvgl/test_menu.py @@ -0,0 +1,407 @@ +"""Tests for the LVGL ``menu`` widget: schema validation for the ``menu``/ +``menu_page``/``menu_section`` widget types and the ``lvgl.menu.set_page`` +action, and the code they generate. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.widgets.menu import MENU_PAGE_SCHEMA, MENU_SCHEMA +from esphome.config import load_config, read_config +import esphome.config_validation as cv +from esphome.core import CORE + +# --------------------------------------------------------------------------- +# The menu widget's own schema +# --------------------------------------------------------------------------- + + +class TestMenuSchema: + def test_pages_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['pages'\]" + ): + MENU_SCHEMA({"root_page": "main"}) + + def test_root_page_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['root_page'\]" + ): + MENU_SCHEMA({"pages": [{"id": "main"}]}) + + def test_sidebar_page_optional(self) -> None: + config = MENU_SCHEMA({"pages": [{"id": "main"}], "root_page": "main"}) + assert "sidebar_page" not in config + + def test_header_mode_and_root_back_btn_defaults(self) -> None: + config = MENU_SCHEMA({"pages": [{"id": "main"}], "root_page": "main"}) + assert config["header_mode"] == "LV_MENU_HEADER_TOP_FIXED" + assert config["root_back_btn"] == "LV_MENU_ROOT_BACK_BUTTON_DISABLED" + + def test_header_mode_accepts_bare_choice(self) -> None: + config = MENU_SCHEMA( + { + "pages": [{"id": "main"}], + "root_page": "main", + "header_mode": "bottom_fixed", + } + ) + assert config["header_mode"] == "LV_MENU_HEADER_BOTTOM_FIXED" + + +# --------------------------------------------------------------------------- +# A menu page's own schema (title optional, sections optional) +# --------------------------------------------------------------------------- + + +class TestMenuPageSchema: + def test_empty_page_is_valid(self) -> None: + config = MENU_PAGE_SCHEMA({}) + assert "title" not in config + assert "sections" not in config + + def test_title_optional(self) -> None: + config = MENU_PAGE_SCHEMA({"title": "Settings"}) + assert config["title"] == "Settings" + + def test_section_id_is_optional(self) -> None: + """cv.GenerateID() means a section's id: is optional, unlike a page's.""" + config = MENU_PAGE_SCHEMA({"sections": [{}]}) + assert config["sections"][0]["id"].is_declaration + + +# --------------------------------------------------------------------------- +# lvgl.menu.set_page action schema +# --------------------------------------------------------------------------- + + +class TestMenuSetPageActionSchema: + @property + def schema(self): + return ACTION_REGISTRY["lvgl.menu.set_page"].raw_schema + + def test_id_and_page_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['page'\]" + ): + self.schema({"id": "my_menu"}) + + def test_sidebar_defaults_false(self) -> None: + config = self.schema({"id": "my_menu", "page": "my_page"}) + assert config["sidebar"] is False + + def test_sidebar_can_be_set_true(self) -> None: + config = self.schema({"id": "my_menu", "page": "my_page", "sidebar": True}) + assert config["sidebar"] is True + + +# --------------------------------------------------------------------------- +# Pages belong to exactly one menu +# --------------------------------------------------------------------------- + +# Boilerplate around the widgets under test: an LVGL instance needs a display. +MENU_CONFIG_TEMPLATE = """ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: +{widgets} +""" + + +def _config_errors(tmp_path: Path, widgets: str) -> list[str]: + """Validate a config built from the given `widgets:` block and return the + validation errors as text.""" + config_path = tmp_path / "menu.yaml" + config_path.write_text(MENU_CONFIG_TEMPLATE.format(widgets=widgets)) + original_path = CORE.config_path + try: + CORE.config_path = config_path + return [str(err) for err in load_config({}).errors] + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_root_page_of_another_menu_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_b_page + pages: + - id: menu_a_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("'menu_b_page' is not a page of this menu" in err for err in errors) + + +def test_empty_pages_list_is_rejected(tmp_path: Path) -> None: + """Without this, a menu with no pages of its own escapes the page-owner check.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_b_page + pages: [] + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("length of value must be at least 1" in err for err in errors) + + +def test_sidebar_header_style_without_sidebar_page_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + sidebar_header_style: + bg_color: 0x101010 + pages: + - id: menu_a_page +""", + ) + assert any( + "'sidebar_header_style' requires 'sidebar_page'" in err for err in errors + ) + + +def test_root_back_button_click_needs_root_back_button_enabled(tmp_path: Path) -> None: + """With the default `root_back_btn: disabled` the button never appears, so the + trigger could never fire.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + on_root_back_button_click: + - logger.log: "never" + pages: + - id: menu_a_page +""", + ) + assert any( + "'on_root_back_button_click' requires 'root_back_btn: enabled'" in err + for err in errors + ) + + +def test_sidebar_page_of_another_menu_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + sidebar_page: menu_b_page + pages: + - id: menu_a_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("'menu_b_page' is not a page of this menu" in err for err in errors) + + +def test_set_page_action_with_a_page_of_another_menu_is_rejected( + tmp_path: Path, +) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + pages: + - id: menu_a_page + widgets: + - button: + text: "Go" + on_click: + - lvgl.menu.set_page: + id: menu_a + page: menu_b_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any( + "page 'menu_b_page' is not a page of menu 'menu_a'" in err for err in errors + ) + + +def test_section_id_is_not_accepted_where_a_page_is_required(tmp_path: Path) -> None: + """The page and section tag types are distinct, so a section id can't be + passed off as a page.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_section + pages: + - id: menu_a_page + sections: + - id: menu_a_section +""", + ) + assert any( + "ID 'menu_a_section' of type lv_menu_section_t doesn't inherit from " + "lv_menu_page_t" in err + for err in errors + ) + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared menu-widget YAML config once per + module -- see test_widget_state.py for why this is module-scoped and + inlines the generate_main fixture logic rather than depending on it. + """ + config_path = Path(request.fspath).parent / "config" / "menu_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_menu_created_with_header_and_back_button_modes(main_cpp: str) -> None: + assert "test_menu = lv_menu_create(lvgl_id->get_screen_active());" in main_cpp + assert "lv_menu_set_mode_header(test_menu, LV_MENU_HEADER_TOP_FIXED);" in main_cpp + assert ( + "lv_menu_set_mode_root_back_button(test_menu, LV_MENU_ROOT_BACK_BUTTON_ENABLED);" + in main_cpp + ) + + +def test_page_created_with_title(main_cpp: str) -> None: + assert 'main_page = lv_menu_page_create(test_menu, "Settings");' in main_cpp + assert 'advanced_page = lv_menu_page_create(test_menu, "Advanced");' in main_cpp + + +def test_section_created_under_its_page_not_the_menu(main_cpp: str) -> None: + assert "wifi_section = lv_menu_section_create(main_page);" in main_cpp + assert "wifi_switch = lv_switch_create(wifi_section);" in main_cpp + + +def test_ungrouped_widget_created_directly_under_page(main_cpp: str) -> None: + assert "advanced_button = lv_button_create(main_page);" in main_cpp + + +def test_sections_created_before_ungrouped_widgets(main_cpp: str) -> None: + assert main_cpp.index("wifi_section = lv_menu_section_create(main_page);") < ( + main_cpp.index("advanced_button = lv_button_create(main_page);") + ) + + +def test_root_page_and_sidebar_page_set_at_boot(main_cpp: str) -> None: + assert "lv_menu_set_page(test_menu, main_page);" in main_cpp + assert "lv_menu_set_sidebar_page(test_menu, nav_page);" in main_cpp + + +def test_header_style_applied_via_main_header_accessor(main_cpp: str) -> None: + assert ( + "lv_obj_t *menu_header_VAR_ = lv_menu_get_main_header(test_menu);\n" + "lv_obj_set_style_bg_color(menu_header_VAR_, lv_color_make(255, 0, 0), LV_PART_MAIN);" + ) in main_cpp + + +def test_sidebar_header_style_applied_via_sidebar_header_accessor( + main_cpp: str, +) -> None: + assert ( + "lv_obj_t *menu_sidebar_header_VAR_ = lv_menu_get_sidebar_header(test_menu);\n" + "lv_obj_set_style_bg_color(menu_sidebar_header_VAR_, lv_color_make(0, 255, 0), LV_PART_MAIN);" + ) in main_cpp + + +def test_root_back_button_click_guarded_by_helper(main_cpp: str) -> None: + """The on_root_back_button_click trigger must only fire when the guard + helper (which wraps lv_menu_back_button_is_root) returns true. + """ + assert ( + "lv_obj_add_event_cb(test_menu, [](lv_event_t * event) -> void {" + ) in main_cpp + assert ( + "if (lvgl::lv_menu_back_button_is_root_click(test_menu, event)) {\n" + " trigger_id->trigger();\n" + " }" + ) in main_cpp + assert "}, LV_EVENT_CLICKED, nullptr);" in main_cpp + + +def test_set_page_action_targets_main_by_default(main_cpp: str) -> None: + assert "lv_menu_set_page(test_menu, advanced_page);" in main_cpp + + +def test_set_page_action_targets_sidebar_when_flagged(main_cpp: str) -> None: + assert "lv_menu_set_sidebar_page(test_menu, main_page);" in main_cpp + + +def test_on_value_passes_current_main_page(main_cpp: str) -> None: + assert ( + "->trigger(lv_menu_get_cur_main_page(test_menu), event);\n" + " \n" + "}, LV_EVENT_VALUE_CHANGED, lvgl::lv_update_event, nullptr);" + ) in main_cpp diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 952dd5f50f1..3ac6e6a81d6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1777,6 +1777,56 @@ lvgl: - label: text: "2,2" + - menu: + id: settings_menu + header_mode: top_fixed + root_back_btn: enabled + root_page: settings_main_page + sidebar_page: settings_nav_page + header_style: + bg_color: 0x202020 + sidebar_header_style: + bg_color: 0x101010 + on_root_back_button_click: + - logger.log: "settings menu closed at root" + on_value: + - lambda: |- + ESP_LOGD("lvgl", "Menu page %p", page); + pages: + - id: settings_main_page + title: "Settings" + sections: + - id: wifi_section + widgets: + - label: + text: "Wifi" + - switch: + id: wifi_switch + widgets: + - button: + id: open_advanced_button + text: "Advanced" + on_click: + - lvgl.menu.set_page: + id: settings_menu + page: settings_advanced_page + - id: settings_advanced_page + title: "Advanced" + widgets: + - label: + text: "Debug options" + - id: settings_nav_page + title: "Navigate" + widgets: + - button: + id: nav_to_main_button + text: "Go to Settings" + on_click: + - lvgl.menu.set_page: + id: settings_menu + page: settings_main_page + sidebar: true + # Sized from its text font, with a colour given as an id - color_picker: id: lv_color_picker_1 diff --git a/tests/components/lvgl/validate.host.yaml b/tests/components/lvgl/validate.host.yaml index 77db404a6ec..afdb89b9499 100644 --- a/tests/components/lvgl/validate.host.yaml +++ b/tests/components/lvgl/validate.host.yaml @@ -1,5 +1,5 @@ esphome: - name: lvgl-list-validate + name: lvgl-validate host: @@ -15,6 +15,46 @@ display: lvgl: displays: sdl0 widgets: + # A menu with no sidebar_page (optional), no page titles (optional) and a + # single empty page (no sections, no widgets) as its root. + - menu: + id: menu_a + root_page: menu_a_empty_page + pages: + - id: menu_a_empty_page + # A second, independent menu -- exercises sidebar_page, multiple + # sections, and a page with both sections and ungrouped widgets. + - menu: + id: menu_b + header_mode: bottom_fixed + root_back_btn: enabled + root_page: menu_b_page + sidebar_page: menu_b_nav_page + pages: + - id: menu_b_page + title: "Settings" + sections: + - id: menu_b_section_1 + widgets: + - label: {text: "Wifi"} + - id: menu_b_section_2 + widgets: + - label: {text: "Bluetooth"} + - switch: {} + widgets: + - button: + text: "Advanced" + on_click: + - lvgl.menu.set_page: {id: menu_b, page: menu_b_page} + - id: menu_b_nav_page + widgets: + - button: + text: "Nav" + on_click: + - lvgl.menu.set_page: + id: menu_b + page: menu_b_page + sidebar: true # Two independent lists, each with their own on_add/on_remove and, for list_a, # more than one automation under the same trigger key -- checks that the # per-list trigger bookkeeping is keyed correctly and doesn't require exactly From 6a50f5c166c3882110086db3b783c5721d9d2ff7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:46:40 +1100 Subject: [PATCH 419/651] [apc_proteous] Cover component for APC Proteous gate motors (#13403) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/apc_proteous/__init__.py | 3 + .../apc_proteous/apc_proteous_cover.cpp | 242 ++++++++++ .../apc_proteous/apc_proteous_cover.h | 52 +++ esphome/components/apc_proteous/cover.py | 33 ++ tests/components/apc_proteous/common.h | 60 +++ tests/components/apc_proteous/common.yaml | 5 + .../cover/apc_proteous_cover_test.cpp | 415 ++++++++++++++++++ .../apc_proteous/test.esp32-idf.yaml | 3 + 9 files changed, 814 insertions(+) create mode 100644 esphome/components/apc_proteous/__init__.py create mode 100644 esphome/components/apc_proteous/apc_proteous_cover.cpp create mode 100644 esphome/components/apc_proteous/apc_proteous_cover.h create mode 100644 esphome/components/apc_proteous/cover.py create mode 100644 tests/components/apc_proteous/common.h create mode 100644 tests/components/apc_proteous/common.yaml create mode 100644 tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp create mode 100644 tests/components/apc_proteous/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index ae813fa1be2..5e7c2eae794 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,6 +40,7 @@ esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apc1/* @ademuri +esphome/components/apc_proteous/* @clydebarrow esphome/components/apds9306/* @aodrenah esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core diff --git a/esphome/components/apc_proteous/__init__.py b/esphome/components/apc_proteous/__init__.py new file mode 100644 index 00000000000..0f837d3b9d9 --- /dev/null +++ b/esphome/components/apc_proteous/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["uart"] +DOMAIN = "apc_proteous" diff --git a/esphome/components/apc_proteous/apc_proteous_cover.cpp b/esphome/components/apc_proteous/apc_proteous_cover.cpp new file mode 100644 index 00000000000..f6a8432faa0 --- /dev/null +++ b/esphome/components/apc_proteous/apc_proteous_cover.cpp @@ -0,0 +1,242 @@ +#include "apc_proteous_cover.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +namespace esphome::apc_proteous { + +ESPHOME_LOG_TAG(TAG, "apc_proteous.cover"); +static constexpr const char *START_CMD = "*1\r"; // start/stop toggle +static constexpr const char *OPEN_CMD = "*6\r"; +static constexpr const char *CLOSE_CMD = "*7\r"; +static constexpr const char *QUERY_S = "?s\r"; +static constexpr const char *QUERY_X = "?x\r"; + +// Depending on calibration the reported position may stop a little short of the physical limits +// (e.g. 99% at the fully open reed switch), so readings this close to an end count as that end. +static constexpr uint8_t ENDPOINT_MARGIN = 2; + +using namespace esphome::cover; + +void APCProteousCover::setup() { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(this); + } else { + this->position = 0.5f; + } +} + +void APCProteousCover::loop() { + uint8_t data; + while (this->available() > 0) { + if (!this->read_byte(&data)) { + continue; + } + if (data == '\r') { + if (this->rx_len_ != 0) { + this->rx_buffer_[this->rx_len_] = '\0'; + ESP_LOGVV(TAG, "rx: '%s'", this->rx_buffer_); + this->parse_response_(); + this->rx_len_ = 0; + } + } else if (data != '\n') { + if (this->rx_len_ >= MAX_RESPONSE_LEN) { + ESP_LOGVV(TAG, "rx overflow, discarding"); + this->rx_len_ = 0; + } + this->rx_buffer_[this->rx_len_++] = static_cast(data); + } + } +} + +void APCProteousCover::parse_response_() { + // Expected format: "?s=XX" or "?x=XX" where XX is a hex value + if (this->rx_len_ < 4 || this->rx_buffer_[0] != '?' || this->rx_buffer_[2] != '=') { + ESP_LOGV(TAG, "Invalid response: %s", this->rx_buffer_); + return; + } + + const char *hex_str = this->rx_buffer_ + 3; + char *end_ptr; + uint8_t value = static_cast(strtol(hex_str, &end_ptr, 16)); + if (end_ptr == hex_str) { + ESP_LOGW(TAG, "Failed to parse hex value: %s", this->rx_buffer_); + return; + } + + bool state_changed = false; + char type = this->rx_buffer_[1]; + if (type == 's') { + // bit 0 = operating, bit 1 = direction (0 = opening, 1 = closing) + CoverOperation new_operation = COVER_OPERATION_IDLE; + if (value & 0x01) { + new_operation = (value & 0x02) ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING; + } + if (this->current_operation != new_operation) { + this->current_operation = new_operation; + state_changed = true; + } + // Motion in the commanded direction acknowledges the pending command + if ((this->pending_command_ == OPEN_CMD && new_operation == COVER_OPERATION_OPENING) || + (this->pending_command_ == CLOSE_CMD && new_operation == COVER_OPERATION_CLOSING)) { + this->clear_pending_(); + } + // A partial move is over once the acknowledged motion has stopped, wherever it stopped, so an + // old target cannot halt a movement started from the wall button or the remote later on + if (new_operation == COVER_OPERATION_IDLE && this->pending_command_ == nullptr) { + this->target_position_.reset(); + } + ESP_LOGV(TAG, "s-status: 0x%02X (operation=%d)", value, new_operation); + } else if (type == 'x') { + // Position percentage, snapped to the ends so is_open/is_closed can latch despite calibration offsets + float new_position; + if (value + ENDPOINT_MARGIN >= 100) { + new_position = COVER_OPEN; + } else if (value <= ENDPOINT_MARGIN) { + new_position = COVER_CLOSED; + } else { + new_position = value / 100.0f; + } + if (this->position != new_position) { + this->position = new_position; + state_changed = true; + // Until the controller has confirmed motion in the commanded direction, current_operation may + // still show the previous one, so a reversing partial move must not be judged against it + if (this->target_position_.has_value() && this->pending_command_ == nullptr && + ((this->current_operation == COVER_OPERATION_OPENING && this->position >= *this->target_position_) || + (this->current_operation == COVER_OPERATION_CLOSING && this->position <= *this->target_position_))) { + ESP_LOGD(TAG, "Target position %.2f reached", *this->target_position_); + this->stop_cmd_(App.get_loop_component_start_time()); + } + } + ESP_LOGV(TAG, "x-status: %d%% (position=%.2f)", value, this->position); + } + + if (state_changed) { + this->publish_state(); + } +} + +void APCProteousCover::clear_pending_() { + this->pending_command_ = nullptr; + this->command_retries_ = 0; +} + +void APCProteousCover::retry_pending_command_(uint32_t now) { + if (this->pending_command_ == nullptr || (now - this->pending_command_time_) < COMMAND_ACK_MS) { + return; + } + bool is_open_cmd = this->pending_command_ == OPEN_CMD; + // Nothing to resend once the gate is at the commanded end + if ((is_open_cmd && this->position >= COVER_OPEN) || (!is_open_cmd && this->position <= COVER_CLOSED)) { + this->clear_pending_(); + return; + } + const LogString *label = is_open_cmd ? LOG_STR("open") : LOG_STR("close"); + if (this->command_retries_ >= MAX_COMMAND_RETRIES) { + ESP_LOGW(TAG, "Gate did not acknowledge %s command after %u retries", LOG_STR_ARG(label), this->command_retries_); + this->clear_pending_(); + this->target_position_.reset(); + return; + } + this->command_retries_++; + ESP_LOGD(TAG, "Gate did not respond, resending %s command (retry %u)", LOG_STR_ARG(label), this->command_retries_); + this->pending_command_time_ = now; + this->write_command_(this->pending_command_, now); +} + +void APCProteousCover::update() { + const uint32_t now = App.get_loop_component_start_time(); + this->retry_pending_command_(now); + + // Let the command and its echo clear the line before the next query + if ((now - this->last_command_tx_) < COMMAND_QUIET_MS) { + return; + } + // The line is idle, so anything left in the buffer is an unterminated echo + if (this->rx_len_ != 0) { + this->rx_buffer_[this->rx_len_] = '\0'; + ESP_LOGVV(TAG, "discarding stale rx '%s' before query", this->rx_buffer_); + this->rx_len_ = 0; + } + + const char *query = this->query_s_next_ ? QUERY_S : QUERY_X; + ESP_LOGVV(TAG, "tx: %s", query); + this->write_str(query); + this->query_s_next_ = !this->query_s_next_; +} + +void APCProteousCover::dump_config() { + LOG_COVER("", "APC Proteous Cover", this); + LOG_UPDATE_INTERVAL(this); +} + +CoverTraits APCProteousCover::get_traits() { + auto traits = CoverTraits(); + traits.set_supports_position(true); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + return traits; +} + +void APCProteousCover::write_command_(const char *cmd, uint32_t now) { + ESP_LOGVV(TAG, "tx: %s", cmd); + this->last_command_tx_ = now; + this->write_str(cmd); +} + +void APCProteousCover::send_command_(const char *cmd) { + // The published state comes only from status reads; this just records the command so a stop + // can cancel it before the controller reports motion, and so it can be resent if dropped. + // The commands are idempotent, so only skip one when the gate is already moving that way. + CoverOperation operation = cmd == OPEN_CMD ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING; + if (this->current_operation == operation) { + return; + } + ESP_LOGD(TAG, "Sending %s command", + operation == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("open") : LOG_STR_LITERAL("close")); + const uint32_t now = App.get_loop_component_start_time(); + this->pending_command_ = cmd; + this->pending_command_time_ = now; + this->command_retries_ = 0; + this->write_command_(cmd, now); +} + +void APCProteousCover::stop_cmd_(uint32_t now) { + // START_CMD is a start/stop toggle, so it is only sent while the gate is moving, or while a + // movement command is in flight that the controller has not reported yet; otherwise it would start the gate. + if (this->current_operation == COVER_OPERATION_IDLE && this->pending_command_ == nullptr) { + ESP_LOGD(TAG, "Cover already idle, ignoring stop command"); + return; + } + ESP_LOGD(TAG, "Sending stop command"); + this->write_command_(START_CMD, now); + this->clear_pending_(); + this->target_position_.reset(); +} + +void APCProteousCover::control(const CoverCall &call) { + if (call.get_stop()) { + this->stop_cmd_(App.get_loop_component_start_time()); + } else if (call.get_position().has_value()) { + float target = *call.get_position(); + ESP_LOGD(TAG, "Target position: %.2f, current: %.2f", target, this->position); + // There is no position command: a partial target is reached by moving towards it and stopping + // once the reported position gets there + this->target_position_.reset(); + if (target == COVER_OPEN) { + this->send_command_(OPEN_CMD); + } else if (target == COVER_CLOSED) { + this->send_command_(CLOSE_CMD); + } else if (target != this->position) { + this->target_position_ = target; + this->send_command_(target > this->position ? OPEN_CMD : CLOSE_CMD); + } + } else if (call.get_toggle()) { + this->clear_pending_(); + this->target_position_.reset(); + this->write_command_(START_CMD, App.get_loop_component_start_time()); + } +} + +} // namespace esphome::apc_proteous diff --git a/esphome/components/apc_proteous/apc_proteous_cover.h b/esphome/components/apc_proteous/apc_proteous_cover.h new file mode 100644 index 00000000000..03c8eb5c4d1 --- /dev/null +++ b/esphome/components/apc_proteous/apc_proteous_cover.h @@ -0,0 +1,52 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/cover/cover.h" + +namespace esphome::apc_proteous { + +class APCProteousCover : public cover::Cover, public PollingComponent, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void update() override; + void dump_config() override; + + cover::CoverTraits get_traits() override; + + protected: + void control(const cover::CoverCall &call) override; + void parse_response_(); + void send_command_(const char *cmd); + void write_command_(const char *cmd, uint32_t now); + void stop_cmd_(uint32_t now); + void retry_pending_command_(uint32_t now); + void clear_pending_(); + + // The longest valid frame is "?s=XX"; anything longer is line noise + static constexpr size_t MAX_RESPONSE_LEN = 16; + + // A movement command is acknowledged once the controller reports motion in the commanded + // direction; resend it this often, up to MAX_COMMAND_RETRIES times, since the serial link + // occasionally drops one. The stop toggle is never resent because a spurious toggle could start the gate. + static constexpr uint32_t COMMAND_ACK_MS = 2000; + static constexpr uint8_t MAX_COMMAND_RETRIES = 3; + + // The controller echoes every command, sometimes without a terminator. A query sent too soon + // after a command collides with that echo, so polling pauses for this long after each command. + static constexpr uint32_t COMMAND_QUIET_MS = 250; + + // Set only for a partial target; the gate is stopped when the reported position reaches it + optional target_position_{}; + // Last movement command sent and not yet acknowledged; lets a stop cancel it and drives the retries + const char *pending_command_{nullptr}; + uint32_t pending_command_time_{0}; + uint32_t last_command_tx_{0}; + char rx_buffer_[MAX_RESPONSE_LEN + 1]; + uint8_t rx_len_{0}; + uint8_t command_retries_{0}; + bool query_s_next_{true}; +}; + +} // namespace esphome::apc_proteous diff --git a/esphome/components/apc_proteous/cover.py b/esphome/components/apc_proteous/cover.py new file mode 100644 index 00000000000..4c38589580c --- /dev/null +++ b/esphome/components/apc_proteous/cover.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import cover, uart +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import DOMAIN + +apc_proteous_ns = cg.esphome_ns.namespace("apc_proteous") +APCProteousCover = apc_proteous_ns.class_( + "APCProteousCover", cover.Cover, cg.PollingComponent, uart.UARTDevice +) + +CONFIG_SCHEMA = ( + cover.cover_schema(APCProteousCover) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.polling_component_schema("500ms")) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=9600, + require_tx=True, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = await cover.new_cover(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/tests/components/apc_proteous/common.h b/tests/components/apc_proteous/common.h new file mode 100644 index 00000000000..a3baa5131de --- /dev/null +++ b/tests/components/apc_proteous/common.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/apc_proteous/apc_proteous_cover.h" + +namespace esphome::apc_proteous::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)); } + std::string tx_string() const { return std::string(this->tx.begin(), this->tx.end()); } + + // 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) +}; + +class TestableAPCProteousCover : public APCProteousCover { + public: + using APCProteousCover::COMMAND_ACK_MS; + using APCProteousCover::COMMAND_QUIET_MS; + using APCProteousCover::MAX_COMMAND_RETRIES; + using APCProteousCover::MAX_RESPONSE_LEN; + using APCProteousCover::command_retries_; + using APCProteousCover::last_command_tx_; + using APCProteousCover::pending_command_; + using APCProteousCover::pending_command_time_; + using APCProteousCover::rx_len_; + using APCProteousCover::target_position_; +}; + +} // namespace esphome::apc_proteous::testing diff --git a/tests/components/apc_proteous/common.yaml b/tests/components/apc_proteous/common.yaml new file mode 100644 index 00000000000..1e1217c04ca --- /dev/null +++ b/tests/components/apc_proteous/common.yaml @@ -0,0 +1,5 @@ +cover: + - platform: apc_proteous + id: apc_proteous_gate + name: Gate + device_class: gate diff --git a/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp new file mode 100644 index 00000000000..566701338b3 --- /dev/null +++ b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp @@ -0,0 +1,415 @@ +#include + +#include "esphome/core/application.h" + +#include "../common.h" + +namespace esphome::apc_proteous::testing { + +using cover::COVER_CLOSED; +using cover::COVER_OPEN; +using cover::COVER_OPERATION_CLOSING; +using cover::COVER_OPERATION_IDLE; +using cover::COVER_OPERATION_OPENING; + +namespace { +class APCProteousCoverTest : public ::testing::Test { + protected: + void SetUp() override { + this->cover_.set_uart_parent(&this->uart_); + this->cover_.setup(); + // setup() may restore a saved position, so start every test from a known state. + this->cover_.position = 0.5f; + this->cover_.current_operation = COVER_OPERATION_IDLE; + this->cover_.add_on_state_callback([this]() { this->publishes_++; }); + this->end_quiet_period_(); + } + + void feed_(const char *data) { + this->uart_.push_rx(data); + this->cover_.loop(); + } + + void end_quiet_period_() { + this->cover_.last_command_tx_ = + App.get_loop_component_start_time() - TestableAPCProteousCover::COMMAND_QUIET_MS - 1; + } + + // Pretend the pending command was sent long enough ago for a retry to be due. + void expire_ack_window_() { + this->cover_.pending_command_time_ = + App.get_loop_component_start_time() - TestableAPCProteousCover::COMMAND_ACK_MS - 1; + this->end_quiet_period_(); + } + + void send_position_(float position) { this->cover_.make_call().set_position(position).perform(); } + + MockUARTComponent uart_; + TestableAPCProteousCover cover_; + int publishes_{0}; +}; +} // namespace + +TEST_F(APCProteousCoverTest, TraitsSupportPositionStopAndToggle) { + auto traits = this->cover_.get_traits(); + EXPECT_TRUE(traits.get_supports_position()); + EXPECT_TRUE(traits.get_supports_stop()); + EXPECT_TRUE(traits.get_supports_toggle()); + EXPECT_FALSE(traits.get_is_assumed_state()); +} + +// s-status bit 0 is "operating", bit 1 is the direction (set when closing). +TEST_F(APCProteousCoverTest, SStatusSetsOperation) { + this->feed_("?s=01\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_OPENING); + EXPECT_EQ(this->publishes_, 1); + + this->feed_("?s=03\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_CLOSING); + EXPECT_EQ(this->publishes_, 2); + + this->feed_("?s=02\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); + EXPECT_EQ(this->publishes_, 3); +} + +TEST_F(APCProteousCoverTest, UnchangedStatusDoesNotPublish) { + this->feed_("?s=00\r"); + EXPECT_EQ(this->publishes_, 0); + this->feed_("?x=32\r"); + EXPECT_EQ(this->publishes_, 0); +} + +// x-status is the position in percent, sent as hex. +TEST_F(APCProteousCoverTest, XStatusSetsPosition) { + this->feed_("?x=19\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); + EXPECT_EQ(this->publishes_, 1); + + this->feed_("?x=4B\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.75f); + EXPECT_EQ(this->publishes_, 2); +} + +TEST_F(APCProteousCoverTest, XStatusNearEndsSnapsToEndpoints) { + this->feed_("?x=62\r"); // 98% + EXPECT_FLOAT_EQ(this->cover_.position, COVER_OPEN); + this->feed_("?x=02\r"); // 2% + EXPECT_FLOAT_EQ(this->cover_.position, COVER_CLOSED); + this->feed_("?x=03\r"); // 3% is outside the margin + EXPECT_FLOAT_EQ(this->cover_.position, 0.03f); + this->feed_("?x=61\r"); // 97% is outside the margin + EXPECT_FLOAT_EQ(this->cover_.position, 0.97f); +} + +TEST_F(APCProteousCoverTest, MalformedFramesAreIgnored) { + this->feed_("?x\r"); // too short + this->feed_("!x=10\r"); // wrong start character + this->feed_("?x:10\r"); // missing '=' + this->feed_("?x=zz\r"); // not hex + this->feed_("?q=10\r"); // unknown type + EXPECT_FLOAT_EQ(this->cover_.position, 0.5f); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); + EXPECT_EQ(this->publishes_, 0); + EXPECT_EQ(this->cover_.rx_len_, 0); +} + +TEST_F(APCProteousCoverTest, LineFeedsAreIgnored) { + this->feed_("\n?x=\n19\r\n"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); +} + +TEST_F(APCProteousCoverTest, FrameSplitAcrossReadsIsAssembled) { + this->feed_("?x="); + EXPECT_EQ(this->publishes_, 0); + this->feed_("19\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); +} + +// Noise without a carriage return must not grow the buffer without limit, nor stop the +// next frame from being read. +TEST_F(APCProteousCoverTest, UnterminatedNoiseIsDiscarded) { + this->feed_("0123456789ABCDEF"); + this->feed_("?x=19\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); + + this->feed_("0123456789ABCDEF0123456789ABCDEF"); + EXPECT_LE(this->cover_.rx_len_, TestableAPCProteousCover::MAX_RESPONSE_LEN); +} + +TEST_F(APCProteousCoverTest, OpenSendsOpenCommand) { + this->cover_.make_call().set_command_open().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); + EXPECT_NE(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, CloseSendsCloseCommand) { + this->cover_.make_call().set_command_close().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); + EXPECT_NE(this->cover_.pending_command_, nullptr); +} + +// The published state only changes once the controller reports it. +TEST_F(APCProteousCoverTest, CommandDoesNotPublishState) { + this->cover_.make_call().set_command_open().perform(); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); + EXPECT_EQ(this->publishes_, 0); +} + +TEST_F(APCProteousCoverTest, OpenIsNotResentWhileAlreadyOpening) { + this->feed_("?s=01\r"); + this->cover_.make_call().set_command_open().perform(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, CloseIsNotResentWhileAlreadyClosing) { + this->feed_("?s=03\r"); + this->cover_.make_call().set_command_close().perform(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +// An open command reverses a closing gate. +TEST_F(APCProteousCoverTest, OpenIsSentWhileClosing) { + this->feed_("?s=03\r"); + this->cover_.make_call().set_command_open().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); +} + +// The stop command toggles start/stop, so sending it to an idle gate would start it. +TEST_F(APCProteousCoverTest, StopIsIgnoredWhenIdle) { + this->cover_.make_call().set_command_stop().perform(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, StopIsSentWhenMoving) { + this->feed_("?s=01\r"); + this->cover_.make_call().set_command_stop().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); +} + +// A stop straight after an open must cancel it, even before the controller reports motion. +TEST_F(APCProteousCoverTest, StopCancelsUnacknowledgedCommand) { + this->cover_.make_call().set_command_open().perform(); + this->uart_.tx.clear(); + this->cover_.make_call().set_command_stop().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, StopClearsPartialTarget) { + this->send_position_(0.8f); + this->feed_("?s=01\r"); + this->cover_.make_call().set_command_stop().perform(); + EXPECT_FALSE(this->cover_.target_position_.has_value()); +} + +TEST_F(APCProteousCoverTest, ToggleSendsStartCommand) { + this->cover_.make_call().set_command_toggle().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, ToggleClearsPendingCommandAndTarget) { + this->send_position_(0.8f); + this->uart_.tx.clear(); + this->cover_.make_call().set_command_toggle().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_FALSE(this->cover_.target_position_.has_value()); +} + +TEST_F(APCProteousCoverTest, PartialPositionAboveCurrentOpens) { + this->send_position_(0.8f); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); +} + +TEST_F(APCProteousCoverTest, PartialPositionBelowCurrentCloses) { + this->send_position_(0.2f); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); +} + +TEST_F(APCProteousCoverTest, PartialPositionAtCurrentSendsNothing) { + this->send_position_(0.5f); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, StopIsSentWhenOpeningReachesTarget) { + this->send_position_(0.7f); + this->feed_("?s=01\r"); + this->uart_.tx.clear(); + + this->feed_("?x=3C\r"); // 60%, short of the target + EXPECT_TRUE(this->uart_.tx.empty()); + this->feed_("?x=46\r"); // 70% + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_FALSE(this->cover_.target_position_.has_value()); + + // The gate keeps moving until the controller reports it idle; the stop is not repeated. + this->uart_.tx.clear(); + this->feed_("?x=48\r"); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, StopIsSentWhenClosingReachesTarget) { + this->send_position_(0.3f); + this->feed_("?s=03\r"); + this->uart_.tx.clear(); + + this->feed_("?x=28\r"); // 40%, short of the target + EXPECT_TRUE(this->uart_.tx.empty()); + this->feed_("?x=1C\r"); // 28%, past the target + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); +} + +// A full open runs to the limit switch, so the position must not trigger a stop. +TEST_F(APCProteousCoverTest, FullOpenIsNotStoppedByPosition) { + this->cover_.make_call().set_command_open().perform(); + this->feed_("?s=01\r"); + this->uart_.tx.clear(); + this->feed_("?x=50\r"); + this->feed_("?x=64\r"); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +// A full open replaces an earlier partial target. +TEST_F(APCProteousCoverTest, FullOpenCancelsPartialTarget) { + this->send_position_(0.7f); + this->cover_.make_call().set_command_open().perform(); + EXPECT_FALSE(this->cover_.target_position_.has_value()); + this->feed_("?s=01\r"); + this->uart_.tx.clear(); + this->feed_("?x=46\r"); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, ReportedMotionAcknowledgesCommand) { + this->cover_.make_call().set_command_open().perform(); + this->feed_("?s=03\r"); // motion in the wrong direction does not count + EXPECT_NE(this->cover_.pending_command_, nullptr); + this->feed_("?s=01\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_EQ(this->cover_.command_retries_, 0); +} + +TEST_F(APCProteousCoverTest, CommandIsNotRetriedBeforeAckTimeout) { + this->cover_.make_call().set_command_open().perform(); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string().find("*6\r"), std::string::npos); + EXPECT_EQ(this->cover_.command_retries_, 0); +} + +TEST_F(APCProteousCoverTest, UnacknowledgedCommandIsRetried) { + this->cover_.make_call().set_command_close().perform(); + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); + EXPECT_EQ(this->cover_.command_retries_, 1); +} + +TEST_F(APCProteousCoverTest, RetriesStopAfterLimit) { + this->cover_.make_call().set_command_open().perform(); + for (int i = 0; i < TestableAPCProteousCover::MAX_COMMAND_RETRIES; i++) { + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); + } + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string().find("*6\r"), std::string::npos); + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_EQ(this->cover_.command_retries_, 0); +} + +TEST_F(APCProteousCoverTest, PartialTargetIsClearedWhenRetriesRunOut) { + this->send_position_(0.8f); + for (int i = 0; i <= TestableAPCProteousCover::MAX_COMMAND_RETRIES; i++) { + this->expire_ack_window_(); + this->cover_.update(); + } + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_FALSE(this->cover_.target_position_.has_value()); +} + +// While a reversing command is unconfirmed, the reported direction is still the old one and must +// not be compared with the target. +TEST_F(APCProteousCoverTest, ReversingPartialMoveWaitsForConfirmedDirection) { + this->feed_("?s=01\r"); + this->send_position_(0.3f); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); + this->uart_.tx.clear(); + + this->feed_("?x=28\r"); // 40%, still reported as opening + EXPECT_TRUE(this->uart_.tx.empty()); + + this->feed_("?s=03\r"); + this->feed_("?x=1C\r"); // 28%, past the target + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); +} + +// The gate reports idle right after a command, before it starts moving; that must not drop the target. +TEST_F(APCProteousCoverTest, PartialTargetIsKeptWhileCommandIsUnconfirmed) { + this->send_position_(0.8f); + this->feed_("?s=00\r"); + EXPECT_TRUE(this->cover_.target_position_.has_value()); +} + +// A partial move that stops short of its target must not stop a later move started from the remote. +TEST_F(APCProteousCoverTest, PartialTargetIsClearedWhenConfirmedMoveStops) { + this->send_position_(0.8f); + this->feed_("?s=01\r"); + this->feed_("?s=00\r"); + EXPECT_FALSE(this->cover_.target_position_.has_value()); + + this->uart_.tx.clear(); + this->feed_("?s=01\r"); + this->feed_("?x=55\r"); // 85% + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, CommandIsNotRetriedOnceAtEndpoint) { + this->cover_.make_call().set_command_open().perform(); + this->feed_("?x=64\r"); + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string().find("*6\r"), std::string::npos); + EXPECT_EQ(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, UpdateAlternatesQueries) { + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?s\r"); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?x\r"); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?s\r"); +} + +TEST_F(APCProteousCoverTest, UpdateHoldsOffPollingAfterCommand) { + this->cover_.make_call().set_command_open().perform(); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->end_quiet_period_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?s\r"); +} + +// A command echo left without a terminator must not merge with the next response. +TEST_F(APCProteousCoverTest, UpdateDiscardsStaleEcho) { + this->feed_("*6"); + EXPECT_NE(this->cover_.rx_len_, 0); + this->cover_.update(); + EXPECT_EQ(this->cover_.rx_len_, 0); + this->feed_("?s=01\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_OPENING); +} + +} // namespace esphome::apc_proteous::testing diff --git a/tests/components/apc_proteous/test.esp32-idf.yaml b/tests/components/apc_proteous/test.esp32-idf.yaml new file mode 100644 index 00000000000..954a2def240 --- /dev/null +++ b/tests/components/apc_proteous/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + apc_proteous: !include common.yaml From 62e3c25c83206d114ea9cf85a28ada7d8012cf09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 21:50:47 -1000 Subject: [PATCH 420/651] [lvgl] Fix the menu test after lambdas gained a line reset (#20798) --- tests/component_tests/lvgl/test_menu.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/component_tests/lvgl/test_menu.py b/tests/component_tests/lvgl/test_menu.py index 287dd927cea..b496075e934 100644 --- a/tests/component_tests/lvgl/test_menu.py +++ b/tests/component_tests/lvgl/test_menu.py @@ -6,6 +6,7 @@ action, and the code they generate. from __future__ import annotations from pathlib import Path +import re import pytest @@ -400,8 +401,10 @@ def test_set_page_action_targets_sidebar_when_flagged(main_cpp: str) -> None: def test_on_value_passes_current_main_page(main_cpp: str) -> None: - assert ( - "->trigger(lv_menu_get_cur_main_page(test_menu), event);\n" - " \n" - "}, LV_EVENT_VALUE_CHANGED, lvgl::lv_update_event, nullptr);" - ) in main_cpp + callback = re.search( + r"add_event_cb\(test_menu, (.*?)\}, LV_EVENT_VALUE_CHANGED, lvgl::lv_update_event, nullptr\);", + main_cpp, + re.DOTALL, + ) + assert callback is not None + assert "->trigger(lv_menu_get_cur_main_page(test_menu), event);" in callback[1] From 7b08e738d3340aa793627b713597c63517f3d093 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 10:13:58 +0200 Subject: [PATCH 421/651] [fusb302b] Add sensors, contract text sensor and automations (#20796) --- esphome/components/fusb302b/__init__.py | 98 +++++++++++- esphome/components/fusb302b/automation.h | 55 +++++++ esphome/components/fusb302b/fusb302b.cpp | 181 ++++++++++++++++++++++- esphome/components/fusb302b/fusb302b.h | 73 ++++++++- esphome/components/fusb302b/pd.cpp | 9 +- esphome/components/fusb302b/pd.h | 6 +- tests/components/fusb302b/common.yaml | 23 +++ tests/components/fusb302b/pd_test.cpp | 42 ++++++ 8 files changed, 472 insertions(+), 15 deletions(-) create mode 100644 esphome/components/fusb302b/automation.h diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index 212ef057e94..c9dd24463b4 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -1,19 +1,43 @@ -from esphome import pins +from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import i2c, sensor, text_sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERRUPT_PIN +from esphome.const import ( + CONF_CURRENT, + CONF_ID, + CONF_INTERRUPT_PIN, + CONF_ON_CONNECT, + CONF_ON_DISCONNECT, + CONF_ON_ERROR, + CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_FLASH, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, +) from esphome.types import ConfigType +AUTO_LOAD = ["sensor", "text_sensor"] CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] DOMAIN = "fusb302b" +CONF_CONTRACT = "contract" +CONF_ON_POWER_READY = "on_power_ready" +CONF_ON_REQUEST_FAILED = "on_request_failed" CONF_REQUEST_VOLTAGE = "request_voltage" fusb302b_ns = cg.esphome_ns.namespace("fusb302b") FUSB302B = fusb302b_ns.class_("FUSB302B", cg.Component, i2c.I2CDevice) +ConnectForwarder = fusb302b_ns.class_("ConnectForwarder") +DisconnectForwarder = fusb302b_ns.class_("DisconnectForwarder") +PowerReadyForwarder = fusb302b_ns.class_("PowerReadyForwarder") +ErrorForwarder = fusb302b_ns.class_("ErrorForwarder") + # USB PD fixed supply voltages are 5, 9, 12, 15 and 20 V; the highest one that does not exceed the # requested voltage is chosen. validate_voltage = cv.int_range(min=5, max=20) @@ -24,6 +48,29 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(FUSB302B), cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema, cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage, + cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( + icon=ICON_FLASH, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + 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=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}), + cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}), + cv.Optional(CONF_ON_POWER_READY): automation.validate_automation({}), + cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), + cv.Optional(CONF_ON_REQUEST_FAILED): automation.validate_automation({}), } ) .extend(cv.COMPONENT_SCHEMA) @@ -31,6 +78,24 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp32, ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CONNECT, "add_on_state_callback", forwarder=ConnectForwarder + ), + automation.CallbackAutomation( + CONF_ON_DISCONNECT, "add_on_state_callback", forwarder=DisconnectForwarder + ), + automation.CallbackAutomation( + CONF_ON_POWER_READY, "add_on_state_callback", forwarder=PowerReadyForwarder + ), + automation.CallbackAutomation( + CONF_ON_ERROR, "add_on_state_callback", forwarder=ErrorForwarder + ), + automation.CallbackAutomation( + CONF_ON_REQUEST_FAILED, "add_on_request_failed_callback" + ), +) + async def to_code(config: ConfigType) -> None: interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) @@ -38,3 +103,30 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_CONTRACT, var.set_contract_text_sensor) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +automation.register_apply_action( + "fusb302b.request_voltage", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FUSB302B), + cv.Required(CONF_VOLTAGE): cv.templatable(validate_voltage), + }, + key=CONF_VOLTAGE, + ), + automation.ApplyField(CONF_VOLTAGE, "request_voltage", cg.int_), +) + +automation.register_apply_condition( + "fusb302b.is_connected", + automation.maybe_simple_id({cv.GenerateID(): cv.use_id(FUSB302B)}), + "is_connected()", +) diff --git a/esphome/components/fusb302b/automation.h b/esphome/components/fusb302b/automation.h new file mode 100644 index 00000000000..4d1ce39db25 --- /dev/null +++ b/esphome/components/fusb302b/automation.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include "esphome/core/automation.h" +#include "pd.h" + +namespace esphome::fusb302b { + +/// Triggers when a source is attached, including after recovering from a communication error. +struct ConnectForwarder { + Automation<> *automation; + void operator()(PdState state, PdState previous) const { + if (is_connected_state(state) && !is_connected_state(previous)) + this->automation->trigger(); + } +}; + +/// Triggers when the source is removed, or is no longer known to be attached because communication failed. +struct DisconnectForwarder { + Automation<> *automation; + void operator()(PdState state, PdState previous) const { + if (!is_connected_state(state) && is_connected_state(previous)) + this->automation->trigger(); + } +}; + +/// Triggers when negotiation is finished: a new PD contract is in place, or the source does not speak PD. +struct PowerReadyForwarder { + Automation<> *automation; + void operator()(PdState state, PdState /*previous*/) const { + if (state == PdState::PD_STATE_EXPLICIT_CONTRACT || state == PdState::PD_STATE_PD_TIMEOUT) + this->automation->trigger(); + } +}; + +/// Triggers when communication with the chip fails. +struct ErrorForwarder { + Automation<> *automation; + void operator()(PdState state, PdState previous) const { + if (state == PdState::PD_STATE_ERROR && previous != PdState::PD_STATE_ERROR) + this->automation->trigger(); + } +}; + +static_assert(sizeof(ConnectForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(DisconnectForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(PowerReadyForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(ErrorForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp index 6cffbf114f7..479981f8cac 100644 --- a/esphome/components/fusb302b/fusb302b.cpp +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -2,6 +2,10 @@ #ifdef USE_ESP32 +#include +#include + +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "fusb302b_registers.h" @@ -13,6 +17,7 @@ ESPHOME_LOG_TAG(TAG, "fusb302b"); // Task notification bits static constexpr uint32_t NOTIFY_IRQ = 1 << 0; static constexpr uint32_t NOTIFY_STOP = 1 << 1; +static constexpr uint32_t NOTIFY_REQUEST = 1 << 2; static constexpr uint32_t TASK_STACK_SIZE = 4096; static constexpr UBaseType_t TASK_PRIORITY = 18; @@ -71,6 +76,97 @@ void FUSB302B::setup() { this->interrupt_pin_->attach_interrupt(&FUSB302B::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); } +void FUSB302B::queue_event_(uint8_t event) { + const uint8_t head = this->event_queue_head_.load(std::memory_order_relaxed); + if (static_cast(head - this->event_queue_tail_.load(std::memory_order_acquire)) < EVENT_QUEUE_SIZE) { + this->event_queue_[head % EVENT_QUEUE_SIZE] = event; + this->event_queue_head_.store(head + 1, std::memory_order_release); + } else { + if (event == EVENT_REQUEST_FAILED) + this->dropped_request_failures_.fetch_add(1, std::memory_order_relaxed); + this->event_queue_overflow_ = true; + } + this->enable_loop_soon_any_context(); +} + +void FUSB302B::on_state_changed() { + const PdState state = this->state_; + if (state == this->queued_state_) { + // Only the contract changed + this->enable_loop_soon_any_context(); + return; + } + this->queued_state_ = state; + this->queue_event_(static_cast(state)); +} + +void FUSB302B::on_request_failed() { this->queue_event_(EVENT_REQUEST_FAILED); } + +void FUSB302B::loop() { + const uint32_t contract = this->contract_; + if (contract != this->published_contract_) { + this->published_contract_ = contract; + this->publish_contract_(PackedContract{contract}); + } + + uint8_t tail = this->event_queue_tail_.load(std::memory_order_relaxed); + const uint8_t head = this->event_queue_head_.load(std::memory_order_acquire); + while (tail != head) { + const uint8_t event = this->event_queue_[tail % EVENT_QUEUE_SIZE]; + this->event_queue_tail_.store(++tail, std::memory_order_release); + if (event == EVENT_REQUEST_FAILED) { + this->request_failed_callback_.call(); + } else { + this->publish_state_(static_cast(event)); + } + } + if (this->event_queue_overflow_.exchange(false)) { + ESP_LOGW(TAG, "Events arrived faster than the main loop handled them; some state triggers were skipped"); + // Drop the queued states before reading the state, so no older state is replayed after it. Request failures + // are only counted, so every one of them still fires. + uint8_t failures = this->dropped_request_failures_.exchange(0, std::memory_order_relaxed); + const uint8_t end = this->event_queue_head_.load(std::memory_order_acquire); + for (; tail != end; tail++) { + if (this->event_queue_[tail % EVENT_QUEUE_SIZE] == EVENT_REQUEST_FAILED) + failures++; + } + this->event_queue_tail_.store(end, std::memory_order_release); + this->publish_state_(this->state_); + for (; failures > 0; failures--) + this->request_failed_callback_.call(); + } + // Re-enabled by on_state_changed() or on_request_failed() from the PHY task + this->disable_loop(); +} + +void FUSB302B::publish_state_(PdState state) { + if (state == this->published_state_) + return; + const PdState previous = this->published_state_; + this->published_state_ = state; + this->state_callback_.call(state, previous); +} + +void FUSB302B::publish_contract_(PackedContract contract) { +#ifdef USE_SENSOR + if (this->voltage_sensor_ != nullptr) + this->voltage_sensor_->publish_state(contract.voltage()); + if (this->current_sensor_ != nullptr) + this->current_sensor_->publish_state(contract.current()); +#endif +#ifdef USE_TEXT_SENSOR + if (this->contract_text_sensor_ != nullptr) { + if (!contract.is_set()) { + this->contract_text_sensor_->publish_state("Detached"); + } else { + char buf[24]; + snprintf(buf, sizeof(buf), "%.2fA @ %.0fV", contract.current(), contract.voltage()); + this->contract_text_sensor_->publish_state(buf); + } + } +#endif +} + void FUSB302B::dump_config() { ESP_LOGCONFIG(TAG, "FUSB302B:\n" @@ -78,6 +174,13 @@ void FUSB302B::dump_config() { this->get_request_voltage()); LOG_I2C_DEVICE(this); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Contract", this->contract_text_sensor_); +#endif } void FUSB302B::on_shutdown() { @@ -87,6 +190,23 @@ void FUSB302B::on_shutdown() { xTaskNotify(this->task_.get_handle(), NOTIFY_STOP, eSetBits); } +void FUSB302B::request_voltage(int voltage) { + if (voltage < MIN_REQUEST_VOLTAGE || voltage > MAX_REQUEST_VOLTAGE) { + ESP_LOGW(TAG, "Requested %d V is outside %u-%u V, clamping", voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE); + } + this->set_request_voltage(static_cast(std::clamp(voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE))); + if (this->is_failed()) { + ESP_LOGW(TAG, "Cannot request %u V, the component failed", this->get_request_voltage()); + // loop() no longer runs, so fire the trigger from the scheduler. Calling it here directly would recurse if the + // automation requests a voltage again. + App.scheduler.set_timeout(this, 0, [this]() { this->request_failed_callback_.call(); }); + return; + } + // Before setup() the voltage is simply used at the first attach + if (this->task_.is_created()) + xTaskNotify(this->task_.get_handle(), NOTIFY_REQUEST, eSetBits); +} + void FUSB302B::run_task_() { // Give the source time to settle after power-up before the first CC measurement vTaskDelay(pdMS_TO_TICKS(STARTUP_DELAY_MS)); @@ -94,7 +214,8 @@ void FUSB302B::run_task_() { while (true) { uint32_t wait_ms = CC_POLL_INTERVAL_MS; if (this->attached_) { - const bool busy = this->waiting_for_source_caps_ || this->retry_request_pending_ || this->check_ams_(); + const bool busy = this->waiting_for_source_caps_ || this->request_pending_ || this->retry_request_pending_ || + this->check_ams_(); wait_ms = busy ? PD_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; } uint32_t bits = 0; @@ -104,6 +225,10 @@ void FUSB302B::run_task_() { while (true) vTaskSuspend(nullptr); } + if (bits & NOTIFY_REQUEST) { + // When unattached, the new voltage is simply used at the next attach + this->request_pending_ = this->attached_; + } // INT_N is held low until the interrupt registers are read, so a new event that arrives while it is // already low has no falling edge: keep servicing while the pin stays asserted. @@ -118,6 +243,11 @@ void FUSB302B::run_task_() { } this->check_transition_timeout_(); this->check_request_retry_(); + // Waits for a pending Wait retry to end in a contract or a failure, so the two sequences never overlap + if (this->request_pending_ && !this->retry_request_pending_ && !this->check_ams_()) { + this->request_pending_ = false; + this->start_negotiation_(); + } this->check_source_caps_(); } } @@ -341,6 +471,7 @@ void FUSB302B::detach_() { ESP_LOGD(TAG, "Detached (VBUS lost)"); this->attached_ = false; this->waiting_for_source_caps_ = false; + this->request_pending_ = false; this->reset_pd_(); this->clear_contract_(); this->set_state_(PdState::PD_STATE_DISCONNECTED); @@ -348,7 +479,8 @@ void FUSB302B::detach_() { void FUSB302B::handle_hard_reset_() { ESP_LOGD(TAG, "Hard reset received"); - // The source returns to Type-C default power and sends its capabilities again + // The source returns to Type-C default power and sends its capabilities again, using the current request voltage + this->request_pending_ = false; // Also flushes the RX FIFO, so the caller has nothing left to read if (!this->reset_pd_()) { this->enter_error_(); @@ -364,17 +496,49 @@ void FUSB302B::enter_error_() { ESP_LOGW(TAG, "Communication with the chip failed"); this->attached_ = false; this->waiting_for_source_caps_ = false; + this->request_pending_ = false; + // Also stops a pending Wait retry, which would otherwise send its request to the failed chip + this->reset_protocol_(); this->clear_contract_(); this->set_state_(PdState::PD_STATE_ERROR); } +void FUSB302B::start_negotiation_() { + ESP_LOGD(TAG, "Requesting source capabilities for %u V", this->get_request_voltage()); + // send_message() logs a failure; the soft reset is the next step either way, so do not wait for an answer + if (!this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP))) { + this->send_soft_reset_(); + return; + } + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_GET_SENT); +} + void FUSB302B::send_soft_reset_() { - this->reset_pd_(); - this->send_message(this->make_control_msg_(PD_CNTRL_SOFT_RESET)); + if (!this->reset_pd_()) { + this->enter_error_(); + return; + } + if (!this->send_message(this->make_control_msg_(PD_CNTRL_SOFT_RESET))) { + // No answer can come, so do not wait for one + this->source_caps_failed_(); + return; + } // The source answers a soft reset with new source capabilities this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT); } +void FUSB302B::source_caps_failed_() { + this->waiting_for_source_caps_ = false; + if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT) { + ESP_LOGW(TAG, "PD negotiation failed, staying at 5 V"); + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + } else { + // A request made at runtime got no answer. A soft reset keeps the power level, so the contract in place stays. + ESP_LOGW(TAG, "Renegotiation failed, keeping the current contract"); + } + this->on_request_failed(); +} + void FUSB302B::start_source_cap_wait_(SourceCapStep step) { this->waiting_for_source_caps_ = true; this->source_cap_step_ = step; @@ -390,7 +554,10 @@ void FUSB302B::check_source_caps_() { if (elapsed < SOURCE_CAP_WAIT_MS) return; ESP_LOGD(TAG, "No source capabilities received, requesting them"); - this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP)); + if (!this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP))) { + this->send_soft_reset_(); + return; + } this->source_cap_step_ = SourceCapStep::SOURCE_CAP_STEP_GET_SENT; break; case SourceCapStep::SOURCE_CAP_STEP_GET_SENT: @@ -402,9 +569,7 @@ void FUSB302B::check_source_caps_() { case SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT: if (elapsed < SOURCE_CAP_RETRY_MS) return; - ESP_LOGW(TAG, "PD negotiation failed, staying at 5 V"); - this->waiting_for_source_caps_ = false; - this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + this->source_caps_failed_(); return; } this->source_cap_step_start_ = millis(); diff --git a/esphome/components/fusb302b/fusb302b.h b/esphome/components/fusb302b/fusb302b.h index 341d64558df..e63944ebd07 100644 --- a/esphome/components/fusb302b/fusb302b.h +++ b/esphome/components/fusb302b/fusb302b.h @@ -2,6 +2,9 @@ #ifdef USE_ESP32 +#include +#include + #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" @@ -9,6 +12,13 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + #include "pd.h" namespace esphome::fusb302b { @@ -16,16 +26,39 @@ namespace esphome::fusb302b { /// FUSB302B USB Type-C / Power Delivery sink controller. /// /// A dedicated task owns all I2C traffic to the chip so that PD replies meet the protocol timing -/// even while the main loop is busy. +/// even while the main loop is busy. The main loop only publishes state changes. class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { public: explicit FUSB302B(InternalGPIOPin *interrupt_pin) : interrupt_pin_(interrupt_pin) {} void setup() override; + void loop() override; void dump_config() override; void on_shutdown() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } + /// Negotiate a new contract with the highest source voltage that does not exceed `voltage`. + /// Values outside 5-20 V are clamped with a warning. + void request_voltage(int voltage); + + /// Callbacks receive each new state in order, with the state before it. + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + /// Called when a voltage request was rejected, got no answer, or was dropped because the component failed. + /// A contract already in place stays valid. + template void add_on_request_failed_callback(F &&callback) { + this->request_failed_callback_.add(std::forward(callback)); + } + +#ifdef USE_SENSOR + void set_voltage_sensor(sensor::Sensor *sensor) { this->voltage_sensor_ = sensor; } + void set_current_sensor(sensor::Sensor *sensor) { this->current_sensor_ = sensor; } +#endif +#ifdef USE_TEXT_SENSOR + void set_contract_text_sensor(text_sensor::TextSensor *sensor) { this->contract_text_sensor_ = sensor; } +#endif + protected: enum class SourceCapStep : uint8_t { SOURCE_CAP_STEP_WAIT, @@ -37,10 +70,11 @@ class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { static void task_func(void *arg); bool send_message(const PdMsg &msg) override; - void on_state_changed() override {} + void on_state_changed() override; void on_soft_reset_received() override { this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); } void on_source_caps_received() override { this->waiting_for_source_caps_ = false; } void on_transition_timeout() override { this->send_soft_reset_(); } + void on_request_failed() override; // PHY task only [[noreturn]] void run_task_(); @@ -54,10 +88,16 @@ class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { void detach_(); void handle_hard_reset_(); void start_source_cap_wait_(SourceCapStep step); + void start_negotiation_(); void send_soft_reset_(); + void source_caps_failed_(); void check_source_caps_(); void enter_error_(); + void publish_contract_(PackedContract contract); + void publish_state_(PdState state); + void queue_event_(uint8_t event); + InternalGPIOPin *interrupt_pin_; StaticTask task_; @@ -67,6 +107,35 @@ class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { bool attached_{false}; bool waiting_for_source_caps_{false}; bool cc_ambiguous_logged_{false}; + bool request_pending_{false}; + // Last state put in the queue + PdState queued_state_{PdState::PD_STATE_DISCONNECTED}; + + // State changes and request failures from the PHY task, replayed in order by loop() so no event is lost while the + // main loop is blocked. An entry is a PdState value or EVENT_REQUEST_FAILED. The PHY task only writes head, the + // main loop only writes tail. + static constexpr uint8_t EVENT_QUEUE_SIZE = 8; + static constexpr uint8_t EVENT_REQUEST_FAILED = 0xFF; + std::array event_queue_{}; + std::atomic event_queue_head_{0}; + std::atomic event_queue_tail_{0}; + std::atomic event_queue_overflow_{false}; + // Request failures that did not fit in the queue + std::atomic dropped_request_failures_{0}; + + // Main loop only + PdState published_state_{PdState::PD_STATE_DISCONNECTED}; + uint32_t published_contract_{UINT32_MAX}; + + LazyCallbackManager state_callback_; + LazyCallbackManager request_failed_callback_; +#ifdef USE_SENSOR + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *contract_text_sensor_{nullptr}; +#endif }; } // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.cpp b/esphome/components/fusb302b/pd.cpp index 4b6931b38fa..28d8e35fe9b 100644 --- a/esphome/components/fusb302b/pd.cpp +++ b/esphome/components/fusb302b/pd.cpp @@ -114,8 +114,14 @@ void PowerDelivery::set_ams_(bool active) { } bool PowerDelivery::check_ams_() { - if (this->active_ams_ && millis() - this->ams_start_ > AMS_TIMEOUT_MS) + if (this->active_ams_ && millis() - this->ams_start_ > AMS_TIMEOUT_MS) { this->set_ams_(false); + // Every sequence carries a request. In TRANSITION, check_transition_timeout_() handles the missing PS_RDY. + if (this->state_ != PdState::PD_STATE_TRANSITION) { + ESP_LOGW(TAG, "Source did not complete the request in time"); + this->request_failed_(); + } + } return this->active_ams_; } @@ -148,6 +154,7 @@ void PowerDelivery::request_failed_() { // A contract already in place stays valid. Without one, report that negotiation is over at default power. if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT) this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + this->on_request_failed(); } void PowerDelivery::check_transition_timeout_() { diff --git a/esphome/components/fusb302b/pd.h b/esphome/components/fusb302b/pd.h index 66918ce98da..b9712f1a673 100644 --- a/esphome/components/fusb302b/pd.h +++ b/esphome/components/fusb302b/pd.h @@ -125,6 +125,9 @@ class PowerDelivery { /// Called after an accepted request was not followed by PS_RDY in time and the state fell back to default power; /// the PHY sends a soft reset so the source offers its capabilities again. virtual void on_transition_timeout() = 0; + /// Called when a request ended without a new contract: it was rejected, the source kept answering Wait or did not + /// answer in time, or it could not be sent. A contract already in place stays valid. + virtual void on_request_failed() = 0; void handle_message_(const PdMsg &msg); /// Called by the PHY when the chip gave up on a message after its retries without receiving a GoodCRC. @@ -137,7 +140,8 @@ class PowerDelivery { /// contract. void set_state_and_contract_(PdState state, const PdContract &contract); void set_ams_(bool active); - /// Returns true while an atomic message sequence is in progress; ends it after a timeout. + /// Returns true while an atomic message sequence is in progress. Ends it after a timeout, which fails the + /// request unless the source already accepted a new contract. bool check_ams_(); /// Falls back to default power when the source accepted a request but did not report the new supply ready in time. void check_transition_timeout_(); diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml index e684462ba7c..94e5f2da2bc 100644 --- a/tests/components/fusb302b/common.yaml +++ b/tests/components/fusb302b/common.yaml @@ -2,3 +2,26 @@ fusb302b: id: pd interrupt_pin: ${interrupt_pin} request_voltage: 20 + contract: + name: PD Contract + voltage: + name: PD Voltage + current: + name: PD Current + on_connect: + - logger.log: "Source attached" + on_disconnect: + - logger.log: "Source removed" + on_power_ready: + - if: + condition: + fusb302b.is_connected: pd + then: + - logger.log: "Power ready" + on_request_failed: + - logger.log: "Voltage request failed" + on_error: + - fusb302b.request_voltage: 5 + - fusb302b.request_voltage: + id: pd + voltage: !lambda "return 9;" diff --git a/tests/components/fusb302b/pd_test.cpp b/tests/components/fusb302b/pd_test.cpp index c122d59b039..41e7e1b358a 100644 --- a/tests/components/fusb302b/pd_test.cpp +++ b/tests/components/fusb302b/pd_test.cpp @@ -31,6 +31,7 @@ class FakePhy : public PowerDelivery { int soft_resets{0}; int source_caps{0}; int transition_timeouts{0}; + int request_failures{0}; void receive(uint8_t type, uint8_t id, std::vector objects = {}) { PdMsg msg; @@ -70,6 +71,7 @@ class FakePhy : public PowerDelivery { this->source_caps++; } void on_transition_timeout() override { this->transition_timeouts++; } + void on_request_failed() override { this->request_failures++; } }; TEST(FUSB302BPd, HeaderRoundTrip) { @@ -193,6 +195,38 @@ TEST(FUSB302BPd, PsRdyAfterSequenceTimeoutIsIgnored) { pd.advance_ms(2001); pd.receive(PD_CNTRL_PS_RDY, 2); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + // The missing PS_RDY is a transition timeout, not a failed request + EXPECT_EQ(pd.request_failures, 0); +} + +TEST(FUSB302BPd, UnansweredFirstRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + EXPECT_TRUE(pd.check_ams_()); + pd.advance_ms(2001); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); + // Reported once + EXPECT_FALSE(pd.check_ams_()); + EXPECT_EQ(pd.request_failures, 1); +} + +TEST(FUSB302BPd, MissingPsRdyForSameContractFailsRequest) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_ACCEPT, 4); + pd.advance_ms(2001); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_EQ(pd.request_failures, 1); } TEST(FUSB302BPd, TransitionTimeoutFallsBackToDefault) { @@ -255,9 +289,11 @@ TEST(FUSB302BPd, WaitRetriesStopAtLimit) { pd.check_request_retry_(); } EXPECT_EQ(pd.sent.size(), 11u); + EXPECT_EQ(pd.request_failures, 0); pd.receive(PD_CNTRL_WAIT, 3); EXPECT_FALSE(pd.retry_request_pending_); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); pd.advance_ms(101); pd.check_request_retry_(); EXPECT_EQ(pd.sent.size(), 11u); @@ -273,6 +309,7 @@ TEST(FUSB302BPd, FailedRequestEndsNegotiation) { EXPECT_FALSE(pd.check_ams_()); EXPECT_FALSE(pd.retry_request_pending_); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); pd.receive(PD_CNTRL_ACCEPT, 1); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); @@ -312,6 +349,7 @@ TEST(FUSB302BPd, FailedRetriesStopAtLimit) { // The first request, then one attempt for each of the 10 allowed repeats EXPECT_EQ(pd.send_attempts, 11); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); } TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) { @@ -322,6 +360,7 @@ TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) { pd.receive(PD_CNTRL_REJECT, 1); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_EQ(pd.request_failures, 1); } TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) { @@ -333,9 +372,11 @@ TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) { pd.receive(PD_CNTRL_PS_RDY, 2); pd.set_request_voltage(9); pd.receive_source_caps(3); + EXPECT_EQ(pd.request_failures, 0); pd.receive(PD_CNTRL_REJECT, 4); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_EQ(pd.request_failures, 1); } TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) { @@ -345,6 +386,7 @@ TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) { pd.receive(PD_CNTRL_WAIT, 1); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.request_failures, 0); } TEST(FUSB302BPd, UnsupportedDataMessageIsIgnored) { From d3faafc578f2ffda58b140092ec1b810d8de8a6b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:49:50 +1100 Subject: [PATCH 422/651] [display] Always treat 888 pixel blocks as b,g,r in memory (#20799) --- esphome/components/display/display.h | 3 +- .../components/display/display_color_utils.h | 3 +- esphome/components/hub75/hub75.cpp | 6 ++-- esphome/components/it8951/it8951.cpp | 5 +--- esphome/components/mipi_dsi/mipi_dsi.cpp | 30 ++++++------------- esphome/components/mipi_dsi/mipi_dsi.h | 3 -- .../display/test_draw_pixels_at.cpp | 8 ++--- 7 files changed, 21 insertions(+), 37 deletions(-) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 1a45c4d6750..f17b7d86f71 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -357,7 +357,8 @@ class Display : public PollingComponent { * \param ptr A pointer to the start of the data to be copied * \param order The ordering of the colors * \param bitness Defines the number of bits and their format for each pixel - * \param big_endian True if 16 bit values are stored big-endian + * \param big_endian True if 16 bit values are stored big-endian. Ignored for 888, which is always b,g,r + * in memory for RGB order, as LVGL and ESPHome images store it. * \param x_offset The initial x-offset into the source buffer. * \param y_offset The initial y-offset into the source buffer. * \param x_pad How many pixels are in each line after the end of the pixels to be copied. diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index 3cf4a54999e..bd658986519 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -23,9 +23,8 @@ class ColorUtil { return (ptr[idx] << 8) + ptr[idx + 1]; return ptr[idx] + (ptr[idx + 1] << 8); } else if constexpr (BITNESS == COLOR_BITNESS_888) { + // Always the LVGL RGB888 layout, b,g,r in memory for RGB order; byte order applies to 565 only const size_t idx = index * 3; - if (big_endian) - return (ptr[idx + 0] << 16) + (ptr[idx + 1] << 8) + ptr[idx + 2]; return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16); } else { return ptr[index]; diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index 226b59a2912..5f2149747e1 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -160,7 +160,8 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co // 24-bit: 3 bytes per pixel, tightly packed format = Hub75PixelFormat::RGB888; bytes_per_pixel = 3; - // Note: 24-bit is always RGB order in LVGL + // ESPHome RGB order 888 is b,g,r in memory, which hub75 calls BGR + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::BGR : Hub75ColorOrder::RGB; #else ESP_LOGE(TAG, "Unsupported LV_COLOR_DEPTH: %d", LV_COLOR_DEPTH); return; @@ -169,7 +170,8 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co // Non-LVGL mode: standard 24-bit RGB888 format = Hub75PixelFormat::RGB888; bytes_per_pixel = 3; - color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR; + // ESPHome RGB order 888 is b,g,r in memory, which hub75 calls BGR + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::BGR : Hub75ColorOrder::RGB; #endif } else { ESP_LOGE(TAG, "Unsupported bitness: %d", static_cast(bitness)); diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 565f6f7f8bb..3612b795b98 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -987,10 +987,7 @@ void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, c } case COLOR_BITNESS_888: { const size_t i = source_idx * 3; - color_value = - big_endian - ? (static_cast(ptr[i]) << 16) | (static_cast(ptr[i + 1]) << 8) | ptr[i + 2] - : ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + color_value = ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); break; } default: diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index a27be568f25..64166012c20 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -297,15 +297,10 @@ void MipiDsi::draw_pixel_at(int x, int y, Color color) { break; } case display::COLOR_BITNESS_888: - if (this->color_mode_ == display::COLOR_ORDER_BGR) { - this->buffer_[pos * 3] = color.b; - this->buffer_[pos * 3 + 1] = color.g; - this->buffer_[pos * 3 + 2] = color.r; - } else { - this->buffer_[pos * 3] = color.r; - this->buffer_[pos * 3 + 1] = color.g; - this->buffer_[pos * 3 + 2] = color.b; - } + // The panel applies the colour order, so the buffer keeps the standard b,g,r layout + this->buffer_[pos * 3] = color.b; + this->buffer_[pos * 3 + 1] = color.g; + this->buffer_[pos * 3 + 2] = color.r; break; case display::COLOR_BITNESS_332: break; @@ -341,19 +336,12 @@ void MipiDsi::fill(Color color) { } case display::COLOR_BITNESS_888: - if (this->color_mode_ == display::COLOR_ORDER_BGR) { - for (size_t i = 0; i != this->width_ * this->height_; i++) { - this->buffer_[i * 3 + 0] = color.b; - this->buffer_[i * 3 + 1] = color.g; - this->buffer_[i * 3 + 2] = color.r; - } - } else { - for (size_t i = 0; i != this->width_ * this->height_; i++) { - this->buffer_[i * 3 + 0] = color.r; - this->buffer_[i * 3 + 1] = color.g; - this->buffer_[i * 3 + 2] = color.b; - } + for (size_t i = 0; i != this->width_ * this->height_; i++) { + this->buffer_[i * 3 + 0] = color.b; + this->buffer_[i * 3 + 1] = color.g; + this->buffer_[i * 3 + 2] = color.r; } + break; default: break; diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 4df22354681..ba3f29ca05a 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -39,8 +39,6 @@ class MipiDsi final : public display::Display { public: MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} - display::ColorOrder get_color_mode() { return this->color_mode_; } - void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } @@ -102,7 +100,6 @@ class MipiDsi final : public display::Display { uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes bool invert_colors_{}; - display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; display::ColorBitness color_depth_; uint8_t pixel_mode_{}; diff --git a/tests/components/display/test_draw_pixels_at.cpp b/tests/components/display/test_draw_pixels_at.cpp index c8c32d15145..1278e2462df 100644 --- a/tests/components/display/test_draw_pixels_at.cpp +++ b/tests/components/display/test_draw_pixels_at.cpp @@ -62,8 +62,7 @@ static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool b TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); } TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); } TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); } -TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888BigEndian) { expect_walks_match(COLOR_BITNESS_888, 3, true); } -TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888LittleEndian) { expect_walks_match(COLOR_BITNESS_888, 3, false); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888) { expect_walks_match(COLOR_BITNESS_888, 3, false); } // One known pixel per bitness and byte order, so a decode that swapped bytes or channels // could not pass the walk comparison above by being wrong the same way twice. @@ -81,8 +80,9 @@ TEST(DrawPixelsAt, Golden565BigEndian) { TEST(DrawPixelsAt, Golden565LittleEndian) { expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, false, Color(0, 255, 0)); // 0x07E0: pure green } -TEST(DrawPixelsAt, Golden888BigEndian) { - expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x10, 0x20, 0x30)); +// 888 is always b,g,r in memory for RGB order, whatever the byte order flag says +TEST(DrawPixelsAt, Golden888IgnoresBigEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x30, 0x20, 0x10)); } TEST(DrawPixelsAt, Golden888LittleEndian) { expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, false, Color(0x30, 0x20, 0x10)); From 49b12daa5803b7cbb44a8329e23ba244163507ae Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:55:19 +1100 Subject: [PATCH 423/651] [epaper_spi] Ensure test card gets drawn (#20804) --- esphome/components/epaper_spi/display.py | 1 + .../config/test_card_update_interval.yaml | 22 +++++++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 12 ++++++++++ 3 files changed, 35 insertions(+) create mode 100644 tests/component_tests/epaper_spi/config/test_card_update_interval.yaml diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 06aa53acb69..d04ffd33be8 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -203,6 +203,7 @@ def _final_validate(config) -> None: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: config[CONF_SHOW_TEST_CARD] = True + config[CONF_UPDATE_INTERVAL] = update_interval("1min") elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") diff --git a/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml new file mode 100644 index 00000000000..fb6eeec8d77 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + update_interval: never + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 4b8f5788925..81e197abf81 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -580,3 +580,15 @@ def test_model_with_no_default_init_sequence_generates( # init_sequence_length, ...); a length of 0 confirms the empty init # sequence array was generated instead of raising during code generation. assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp) + + +def test_test_card_overrides_update_interval( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With nothing else to draw, update_interval is not left at never for the test card.""" + main_cpp = generate_main(component_config_path("test_card_update_interval.yaml")) + + match = re.search(r"epaper_display->set_update_interval\((\d+)\);", main_cpp) + assert match is not None + assert int(match.group(1)) != 4294967295 From d924d2daf72766305bdb1c39b1078b00825f52c1 Mon Sep 17 00:00:00 2001 From: Jeroen Date: Thu, 15 Oct 2026 19:15:11 +0200 Subject: [PATCH 424/651] [core] Extend fast value formatting to four decimal places (#20800) Co-authored-by: jeroen85 <26403565+jeroen85@users.noreply.github.com> --- esphome/core/helpers.cpp | 8 +-- esphome/core/helpers.h | 4 +- tests/components/core/test_helpers.cpp | 1 + tests/components/core/test_value_accuracy.cpp | 66 ++++++++++++++++++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 872bb199084..85fb260192c 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -598,7 +598,7 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de } } -// Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). +// Fast float-to-string for accuracy_decimals 0-4 (covers sensors and small PID coefficients). // Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. // Caller must guarantee value is finite and |value| * mult fits in uint32_t. static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals, uint32_t mult) { @@ -625,9 +625,9 @@ static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); - // Fast path for accuracy 0-3, finite values whose scaled magnitude fits in uint32_t. - // For 3 decimals that's |value| < ~4.29e6; larger totals fall through to snprintf. - if (accuracy_decimals <= 3 && std::isfinite(value)) { + // Fast path for accuracy 0-4, finite values whose scaled magnitude fits in uint32_t. + // For 4 decimals that's |value| < ~4.29e5; larger totals fall through to snprintf. + if (accuracy_decimals <= 4 && std::isfinite(value)) { const uint32_t mult = small_pow10(accuracy_decimals); if (std::fabs(value) < static_cast(UINT32_MAX) / mult) { return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals, mult); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index e628b02ac94..c51ef4a652d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1445,8 +1445,8 @@ inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, con return buf; } -/// Return 10^n for small non-negative n (0-3) as uint32_t, avoiding float. -inline uint32_t small_pow10(int8_t n) { return n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; } +/// Return 10^n for small non-negative n (0-4) as uint32_t, avoiding float. +inline uint32_t small_pow10(int8_t n) { return n == 4 ? 10000 : n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; } /// Minimum buffer size for uint32_to_str: 10 digits + null terminator. static constexpr size_t UINT32_MAX_STR_SIZE = 11; diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index ac71cf82327..67e112d0cf7 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -166,6 +166,7 @@ TEST(SmallPow10, Zero) { EXPECT_EQ(small_pow10(0), 1u); } TEST(SmallPow10, One) { EXPECT_EQ(small_pow10(1), 10u); } TEST(SmallPow10, Two) { EXPECT_EQ(small_pow10(2), 100u); } TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); } +TEST(SmallPow10, Four) { EXPECT_EQ(small_pow10(4), 10000u); } // --- frac_to_str_unchecked() --- diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp index 381a742a9cd..5ffb84e1296 100644 --- a/tests/components/core/test_value_accuracy.cpp +++ b/tests/components/core/test_value_accuracy.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -64,6 +65,67 @@ TEST(ValueAccuracyToBuf, ThreeDecimals) { EXPECT_EQ(va_to_string(0.0f, 3), "0.000"); } +TEST(ValueAccuracyToBuf, FourDecimals) { + EXPECT_EQ(va_to_string(0.0f, 4), "0.0000"); + EXPECT_EQ(va_to_string(0.0001f, 4), "0.0001"); + EXPECT_EQ(va_to_string(0.0030f, 4), "0.0030"); + EXPECT_EQ(va_to_string(-0.0008f, 4), "-0.0008"); + EXPECT_EQ(va_to_string(23.456f, 4), va_reference(23.456f, 4)); +} + +TEST(ValueAccuracyToBuf, FourDecimalPidSteps) { + for (int step = 0; step <= 30; step++) { + float value = step * 0.0001f; + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)) << "step=" << step; + } +} + +TEST(ValueAccuracyToBuf, FourDecimalRoundingBoundaries) { + for (int step = -1000; step <= 1000; step++) { + float midpoint = static_cast((step + 0.5) / 10000.0); + for (float value : {std::nextafter(midpoint, -INFINITY), midpoint, std::nextafter(midpoint, INFINITY)}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)) << "value=" << value; + } + } + for (float value : {0.03125f, -0.03125f, 0.09375f, -0.09375f, 0.99995f, -0.99995f}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)); + } +} + +TEST(ValueAccuracyToBuf, FourDecimalUint32Boundary) { + float limit = static_cast(UINT32_MAX) / 10000; + for (float value : {std::nextafter(limit, 0.0f), limit, std::nextafter(limit, INFINITY), 1.0e6f, + std::numeric_limits::max()}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)); + EXPECT_EQ(va_to_string(-value, 4), va_reference(-value, 4)); + } +} + +TEST(ValueAccuracyToBuf, FourDecimalSpecialValuesAndFallback) { + for (float value : {-0.0f, -0.00001f, NAN, INFINITY, -INFINITY}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)); + } + EXPECT_EQ(va_to_string(0.00001f, 5), "0.00001"); + EXPECT_EQ(va_to_string(-0.00001f, 5), "-0.00001"); +} + +TEST(ValueAccuracyToBuf, FourDecimalBufferAndLength) { + struct { + char before{'B'}; + char buffer[VALUE_ACCURACY_MAX_LEN]; + char after{'A'}; + } guarded; + for (float value : {0.0f, -0.0f, 0.0001f, -0.0030f, 429496.0f, 1.0e6f}) { + size_t len = value_accuracy_to_buf(std::span(guarded.buffer), value, 4); + EXPECT_EQ(guarded.before, 'B'); + EXPECT_EQ(guarded.after, 'A'); + ASSERT_LT(len, VALUE_ACCURACY_MAX_LEN); + EXPECT_EQ(guarded.buffer[len], '\0'); + EXPECT_EQ(strlen(guarded.buffer), len); + EXPECT_EQ(std::string(guarded.buffer, len), va_reference(value, 4)); + } +} + // --- Negative values --- TEST(ValueAccuracyToBuf, NegativeValues) { @@ -121,7 +183,7 @@ TEST(ValueAccuracyToBuf, Rounding) { TEST(ValueAccuracyToBuf, MatchesSnprintf) { float test_values[] = {0.0f, 1.0f, -1.0f, 23.456f, -23.456f, 100.0f, 0.1f, 0.01f, 99.99f, 1013.25f, -40.0f}; - int8_t test_accuracies[] = {0, 1, 2, 3}; + int8_t test_accuracies[] = {0, 1, 2, 3, 4}; for (float value : test_values) { for (int8_t acc : test_accuracies) { @@ -223,7 +285,7 @@ TEST(ValueAccuracyWithUomToBuf, NearBufferLimitTruncates) { TEST(ValueAccuracyWithUomToBuf, MatchesSnprintf) { const char *units[] = {"°C", "hPa", "%", "W", "kWh", "m/s"}; float values[] = {0.0f, 23.456f, -40.0f, 1013.25f, 100.0f}; - int8_t accs[] = {0, 1, 2, 3}; + int8_t accs[] = {0, 1, 2, 3, 4}; for (const char *u : units) { for (float v : values) { for (int8_t a : accs) { From 7f342054fe8ead9d7968a7e62e1f9c571ae92abf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 07:27:19 -1000 Subject: [PATCH 425/651] [wifi] Retry a failed fallback AP once per ap_timeout and start the captive portal once (#20784) --- .../captive_portal/captive_portal.cpp | 3 ++ esphome/components/wifi/wifi_component.cpp | 38 +++++++++++-------- esphome/components/wifi/wifi_component.h | 11 ++++++ .../wifi/wifi_component_esp_idf.cpp | 2 +- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 0aa48bc3ea0..16ebd407625 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -80,6 +80,9 @@ void CaptivePortal::setup() { this->disable_loop(); } void CaptivePortal::start() { + // Each start takes a web server reference that end() returns once + if (this->active_) + return; this->base_->init(); if (!this->initialized_) { this->base_->add_handler_without_auth(this); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 837c0a1a9d6..156ba6e9ffa 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -696,7 +696,8 @@ void WiFiComponent::setup() { void WiFiComponent::start() { ESP_LOGCONFIG(TAG, "Starting"); - this->last_connected_ = millis(); + // The loop's time, not millis(): a wifi.enable from a trigger in this pass must not stamp a time after now + this->set_last_connected_(App.get_loop_component_start_time()); uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL; @@ -890,7 +891,7 @@ void WiFiComponent::loop() { this->retry_connect(); } else { this->status_clear_warning(); - this->last_connected_ = now; + this->set_last_connected_(now); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the @@ -946,12 +947,14 @@ void WiFiComponent::loop() { // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. if (this->has_ap() && !this->ap_setup_ && !this->is_provisioning_closed_()) { + const uint32_t since_connected = now - this->last_connected_; if (this->ap_timeout_ != 0 && - (now - this->last_connected_ > this->ap_timeout_) #ifdef USE_WIFI_AP_EXCLUSIVE - // After a pause, or a start that failed, the networks get a full - // ap_timeout before the AP is tried again. - && now - this->ap_changed_ > this->ap_timeout_ + // After a pause, or a start that failed, the networks get a full ap_timeout before the AP is tried again + since_connected > this->ap_timeout_ && now - this->ap_changed_ > this->ap_timeout_ +#else + // Tried in whole ap_timeout slots after the last connection; after UINT8_MAX slots it is no longer tried + this->ap_start_failures_ != UINT8_MAX && since_connected / this->ap_timeout_ > this->ap_start_failures_ #endif ) { ESP_LOGI(TAG, "Starting fallback AP"); @@ -959,16 +962,14 @@ void WiFiComponent::loop() { this->ap_changed_ = now; #endif this->setup_ap_config_(); - // A portal is only started behind an AP that came up: the portal keeps a reference to the web server - // on every start, and a failed start is tried again later - if (this->ap_setup_) { -#ifdef USE_CAPTIVE_PORTAL - // 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; -#endif - this->start_ap_portal_(); +#ifndef USE_WIFI_AP_EXCLUSIVE + if (!this->ap_setup_) { + // Skip the next slot too, so at least a full ap_timeout passes wherever in its slot this try ran + uint32_t slot = since_connected / this->ap_timeout_ + 1; + this->ap_start_failures_ = slot < UINT8_MAX ? slot : UINT8_MAX; } +#endif + this->start_ap_portal_(); } } #endif // USE_WIFI_AP @@ -1825,7 +1826,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { #endif // The fallback and reboot checks later in this pass must see the connection, not the old timestamp - this->last_connected_ = now; + this->set_last_connected_(now); this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; // Refresh is_connected() cache; loop()'s refresh ran before this transition. this->update_connected_state_(); @@ -2441,7 +2442,12 @@ bool WiFiComponent::is_ap_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_() { + // Only behind an AP that came up; a fallback AP (networks and an ap_timeout) is retried by loop() + if (!this->ap_setup_) + return; #ifdef USE_CAPTIVE_PORTAL + // Force one full scan now that the portal lists networks (earlier scans were filtered) + this->has_completed_scan_after_captive_portal_start_ = false; if (captive_portal::global_captive_portal != nullptr) captive_portal::global_captive_portal->start(); #endif diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index db4cbae8389..8cfdaaff00c 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -1025,6 +1025,13 @@ class WiFiComponent final : public Component { // the disconnect is treated as roaming-related and the attempts counter is preserved. static constexpr uint32_t ROAMING_SCAN_GRACE_PERIOD = 30 * 1000; // 30 seconds + void set_last_connected_(uint32_t now) { + this->last_connected_ = now; +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_EXCLUSIVE) + this->ap_start_failures_ = 0; +#endif + } + // 4-byte members float output_power_{NAN}; uint32_t action_started_; @@ -1144,6 +1151,10 @@ class WiFiComponent final : public Component { // in the driver's interrupt context on the Pico W. The main loop only reads the byte while the AP is up // and only writes it (a reset) while the AP is down, so no lock is needed. uint8_t ap_clients_{0}; +#ifndef USE_WIFI_AP_EXCLUSIVE + // Fallback AP starts that failed since the last connection; each one waits another ap_timeout + uint8_t ap_start_failures_{0}; +#endif #endif bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c8e1df12f31..4b857b98141 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1306,7 +1306,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { this->dpp_listening_ = false; ESP_LOGI(TAG, "DPP configured network " LOG_SECRET("'%s'"), credentials->ssid); // The reboot timer starts over for the connect attempt, as after a boot with saved credentials - this->last_connected_ = App.get_loop_component_start_time(); + this->set_last_connected_(App.get_loop_component_start_time()); // Saved like portal credentials; from a cooldown this already restarts the retries this->save_wifi_sta(credentials->ssid, credentials->password); if (this->state_ == WIFI_COMPONENT_STATE_OFF || this->state_ == WIFI_COMPONENT_STATE_AP) { From 93dc80d895dca4a542d3b07190d338a2f36e8c80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 07:28:07 -1000 Subject: [PATCH 426/651] [esp8266] Refuse two components that drive hardware timer 1 (#20794) --- esphome/components/esp8266/__init__.py | 2 + esphome/components/esp8266/const.py | 23 ++++++++ esphome/components/hdmi_cec/__init__.py | 5 ++ esphome/components/lightwaverf/__init__.py | 5 ++ esphome/components/opentherm/__init__.py | 5 ++ script/analyze_component_buses.py | 3 + .../components/esp8266/test_timer1.py | 55 +++++++++++++++++++ 7 files changed, 98 insertions(+) create mode 100644 tests/unit_tests/components/esp8266/test_timer1.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 09ced18fd67..c2a827ca35c 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -57,6 +57,7 @@ from .const import ( PREFERENCES_RTC_NORMAL_WORDS, THROW_STUBS_HEADER, check_preferences_fit, + check_timer1_users, enable_serial, enable_serial1, esp8266_ns, @@ -540,6 +541,7 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(finalize_waveform_config) CORE.add_job(finalize_serial_config) CORE.add_job(check_preferences_fit) + CORE.add_job(check_timer1_users) @coroutine_with_priority(CoroPriority.WORKAROUNDS) diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 177bf540473..673c1e91eac 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -17,6 +17,7 @@ CONF_EARLY_PIN_INIT = "early_pin_init" CONF_ENABLE_SERIAL = "enable_serial" CONF_ENABLE_SERIAL1 = "enable_serial1" KEY_WAVEFORM_REQUIRED = "waveform_required" +KEY_TIMER1_USERS = "timer1_users" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator @@ -129,6 +130,28 @@ def require_waveform() -> None: require_waveform() """ CORE.data.setdefault(KEY_ESP8266, {})[KEY_WAVEFORM_REQUIRED] = True + use_timer1("the Arduino waveform generator (esp8266_pwm, ac_dimmer)") + + +def use_timer1(owner: str) -> None: + """Record that a component drives hardware timer 1; check_timer1_users() rejects a second one. + + The ESP8266 has one general purpose timer with one interrupt. Two components that program it + take it from each other, so the configuration is refused instead. Components that go through + the Arduino waveform generator share it and register through require_waveform(). + """ + users = CORE.data.setdefault(KEY_ESP8266, {}).setdefault(KEY_TIMER1_USERS, set()) + users.add(owner) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def check_timer1_users() -> None: + """Fail the build when more than one component drives hardware timer 1.""" + users = sorted(CORE.data[KEY_ESP8266].get(KEY_TIMER1_USERS, ())) + if len(users) > 1: + raise EsphomeError( + f"{' and '.join(users)} each need hardware timer 1 of the ESP8266, which only one of them can drive" + ) def enable_serial() -> None: diff --git a/esphome/components/hdmi_cec/__init__.py b/esphome/components/hdmi_cec/__init__.py index c718968e201..68499d933e3 100644 --- a/esphome/components/hdmi_cec/__init__.py +++ b/esphome/components/hdmi_cec/__init__.py @@ -103,6 +103,11 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + if CORE.is_esp8266: + from esphome.components.esp8266.const import use_timer1 + + # The acknowledge bit is released from hardware timer 1 + use_timer1(DOMAIN) if CORE.is_esp32: # The acknowledge bit is released from a general purpose timer, left out of the build by default include_builtin_idf_component("esp_driver_gptimer") diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 1a4234ceaf0..5cb18878bc3 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_REPEAT, CONF_WRITE_PIN, ) +from esphome.core import CORE from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType @@ -65,6 +66,10 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: + if CORE.is_esp8266: + from esphome.components.esp8266.const import use_timer1 + + use_timer1(DOMAIN) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index cfcdfe77833..026951be93d 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -75,6 +75,11 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: dict[str, Any]) -> None: + if CORE.is_esp8266: + from esphome.components.esp8266.const import use_timer1 + + # Per hub: every hub programs the timer and the interrupt has one instance pointer + use_timer1(f"{DOMAIN} {config[CONF_ID]}") if CORE.is_esp32: include_builtin_idf_component("esp_driver_gptimer") diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index fee1438636b..fd60b2452c6 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -87,10 +87,13 @@ ISOLATED_COMPONENTS = { "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", + "hdmi_cec": "Drives hardware timer 1 on the ESP8266, which the esp8266 component refuses to share with lightwaverf and opentherm", "lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs", + "lightwaverf": "Drives hardware timer 1 on the ESP8266, which the esp8266 component refuses to share with hdmi_cec and opentherm", "mapping": "Uses dict format for image/display sections incompatible with standard list format - ESPHome merge_config cannot handle", "openthread": "Conflicts with wifi: used by most components", "openthread_info": "Conflicts with wifi: used by most components", + "opentherm": "Drives hardware timer 1 on the ESP8266, which the esp8266 component refuses to share with hdmi_cec and lightwaverf", "matrix_keypad": "Needs isolation due to keypad", "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", "mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components", diff --git a/tests/unit_tests/components/esp8266/test_timer1.py b/tests/unit_tests/components/esp8266/test_timer1.py new file mode 100644 index 00000000000..63ded333b0b --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_timer1.py @@ -0,0 +1,55 @@ +"""Two components that program hardware timer 1 are refused.""" + +import asyncio + +import pytest + +from esphome.components.esp8266.const import ( + KEY_ESP8266, + check_timer1_users, + require_waveform, + use_timer1, +) +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def esp8266_core() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp8266"} + CORE.data[KEY_ESP8266] = {} + + +def _check() -> None: + asyncio.run(check_timer1_users()) + + +def test_one_user_passes() -> None: + use_timer1("opentherm ot") + use_timer1("opentherm ot") + _check() + + +def test_two_opentherm_hubs_are_refused() -> None: + use_timer1("opentherm ot1") + use_timer1("opentherm ot2") + with pytest.raises(EsphomeError, match="opentherm ot1 and opentherm ot2"): + _check() + + +def test_two_users_are_refused() -> None: + use_timer1("opentherm") + use_timer1("hdmi_cec") + with pytest.raises( + EsphomeError, match="hdmi_cec and opentherm each need hardware timer 1" + ): + _check() + + +def test_waveform_outputs_share_the_timer_but_not_with_a_direct_user() -> None: + require_waveform() # esp8266_pwm + require_waveform() # ac_dimmer + _check() + use_timer1("lightwaverf") + with pytest.raises(EsphomeError, match="waveform generator"): + _check() From c433e96734e826a6a08527f2953e4b7f9e65cb68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 07:28:27 -1000 Subject: [PATCH 427/651] [homeassistant] Share the Python list parser between the platforms (#20793) --- .../components/homeassistant/homeassistant.h | 136 ++++++++++++++++++ .../homeassistant/select/__init__.py | 1 + .../select/homeassistant_select.cpp | 118 +-------------- .../homeassistant/test_python_list.cpp | 62 ++++++++ 4 files changed, 203 insertions(+), 114 deletions(-) create mode 100644 esphome/components/homeassistant/homeassistant.h create mode 100644 tests/components/homeassistant/test_python_list.cpp diff --git a/esphome/components/homeassistant/homeassistant.h b/esphome/components/homeassistant/homeassistant.h new file mode 100644 index 00000000000..65eb6ef5fd2 --- /dev/null +++ b/esphome/components/homeassistant/homeassistant.h @@ -0,0 +1,136 @@ +#pragma once + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the item early. +template bool emit_utf8(uint32_t code_point, C &on_char) { + if (code_point == 0 || code_point > 0x10FFFF) + return false; + if (code_point < 0x80) { + on_char(static_cast(code_point)); + } else if (code_point < 0x800) { + on_char(static_cast(0xC0 | (code_point >> 6))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else if (code_point < 0x10000) { + on_char(static_cast(0xE0 | (code_point >> 12))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else { + on_char(static_cast(0xF0 | (code_point >> 18))); + on_char(static_cast(0x80 | ((code_point >> 12) & 0x3F))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } + return true; +} + +/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as + * `['Low', "It's high", 'caf\xe9']`, or of an enum, such as `[]`, where the + * quoted value stands for the item. + * + * Calls on_char for each decoded byte of an item and on_item at the end of each item. Returns false + * on malformed input; the callbacks may already have run by then. + */ +template bool parse_python_list(StringRef src, C &&on_char, O &&on_item) { + const char *pos = src.c_str(); + const char *const end = pos + src.size(); + auto skip_spaces = [&]() { + while (pos < end && *pos == ' ') + pos++; + }; + + skip_spaces(); + if (pos == end || *pos != '[') + return false; + pos++; + skip_spaces(); + if (pos < end && *pos == ']') { + pos++; + } else { + while (true) { + skip_spaces(); + // An enum member is shown as ; the quoted value is the item + const bool enum_member = pos < end && *pos == '<'; + if (enum_member) { + pos++; + while (pos < end && (std::isalnum(static_cast(*pos)) || *pos == '_' || *pos == '.')) + pos++; + if (pos == end || *pos != ':') + return false; + pos++; + skip_spaces(); + } + if (pos == end || (*pos != '\'' && *pos != '"')) + return false; + const char quote = *pos++; + while (true) { + if (pos == end) + return false; + const char c = *pos++; + if (c == quote) + break; + if (c != '\\') { + on_char(c); + continue; + } + if (pos == end) + return false; + const char escape = *pos++; + switch (escape) { + case '\\': + case '\'': + case '"': + on_char(escape); + break; + case 'n': + on_char('\n'); + break; + case 'r': + on_char('\r'); + break; + case 't': + on_char('\t'); + break; + case 'x': + case 'u': + case 'U': { + const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8); + if (static_cast(end - pos) < digits) + return false; + auto code_point = parse_hex(pos, digits); + if (!code_point.has_value() || !emit_utf8(*code_point, on_char)) + return false; + pos += digits; + break; + } + default: + return false; + } + } + if (enum_member) { + skip_spaces(); + if (pos == end || *pos != '>') + return false; + pos++; + } + on_item(); + skip_spaces(); + if (pos == end) + return false; + const char separator = *pos++; + if (separator == ']') + break; + if (separator != ',') + return false; + } + } + skip_spaces(); + return pos == end; +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py index e8ad0fc23b8..1bdf0f2cade 100644 --- a/esphome/components/homeassistant/select/__init__.py +++ b/esphome/components/homeassistant/select/__init__.py @@ -13,6 +13,7 @@ from .. import ( CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["api"] +AUTO_LOAD = ["homeassistant"] CONF_MAX_OPTIONS = "max_options" CONF_OPTIONS_BUFFER_SIZE = "options_buffer_size" diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp index 4f4fc7a3295..75b77ca7bb8 100644 --- a/esphome/components/homeassistant/select/homeassistant_select.cpp +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -2,6 +2,8 @@ #include +#include "../homeassistant.h" + #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/helpers.h" @@ -13,118 +15,6 @@ namespace esphome::homeassistant { ESPHOME_LOG_TAG(TAG, "homeassistant.select"); -namespace { - -/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the option early. -template bool emit_utf8(uint32_t code_point, C &on_char) { - if (code_point == 0 || code_point > 0x10FFFF) - return false; - if (code_point < 0x80) { - on_char(static_cast(code_point)); - } else if (code_point < 0x800) { - on_char(static_cast(0xC0 | (code_point >> 6))); - on_char(static_cast(0x80 | (code_point & 0x3F))); - } else if (code_point < 0x10000) { - on_char(static_cast(0xE0 | (code_point >> 12))); - on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); - on_char(static_cast(0x80 | (code_point & 0x3F))); - } else { - on_char(static_cast(0xF0 | (code_point >> 18))); - on_char(static_cast(0x80 | ((code_point >> 12) & 0x3F))); - on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); - on_char(static_cast(0x80 | (code_point & 0x3F))); - } - return true; -} - -/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as - * `['Low', "It's high", 'caf\xe9']`. - * - * Calls on_char for each decoded byte of an option and on_option at the end of each option. Returns false - * on malformed input; the callbacks may already have run by then. - */ -template bool parse_option_list(StringRef src, C &&on_char, O &&on_option) { - const char *pos = src.c_str(); - const char *const end = pos + src.size(); - auto skip_spaces = [&]() { - while (pos < end && *pos == ' ') - pos++; - }; - - skip_spaces(); - if (pos == end || *pos != '[') - return false; - pos++; - skip_spaces(); - if (pos < end && *pos == ']') { - pos++; - } else { - while (true) { - skip_spaces(); - if (pos == end || (*pos != '\'' && *pos != '"')) - return false; - const char quote = *pos++; - while (true) { - if (pos == end) - return false; - const char c = *pos++; - if (c == quote) - break; - if (c != '\\') { - on_char(c); - continue; - } - if (pos == end) - return false; - const char escape = *pos++; - switch (escape) { - case '\\': - case '\'': - case '"': - on_char(escape); - break; - case 'n': - on_char('\n'); - break; - case 'r': - on_char('\r'); - break; - case 't': - on_char('\t'); - break; - case 'x': - case 'u': - case 'U': { - const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8); - if (static_cast(end - pos) < digits) - return false; - auto code_point = parse_hex(pos, digits); - if (!code_point.has_value() || !emit_utf8(*code_point, on_char)) - return false; - pos += digits; - break; - } - default: - return false; - } - } - on_option(); - skip_spaces(); - if (pos == end) - return false; - const char separator = *pos++; - if (separator == ']') - break; - if (separator != ',') - return false; - } - } - skip_spaces(); - return pos == end; -} - -} // namespace - void HomeassistantSelect::setup() { this->options_buffer_ = std::make_unique(this->options_buffer_size_); this->option_list_ = std::make_unique(this->max_options_); @@ -146,7 +36,7 @@ void HomeassistantSelect::options_changed_(StringRef options) { size_t match_len = 0; bool matching = active != nullptr; optional new_active; - bool valid = parse_option_list( + bool valid = parse_python_list( options, [&](char c) { bytes++; @@ -183,7 +73,7 @@ void HomeassistantSelect::options_changed_(StringRef options) { size_t offset = 0; size_t option_start = 0; size_t index = 0; - parse_option_list( + parse_python_list( options, [&](char c) { buffer[offset++] = c; }, [&]() { buffer[offset++] = '\0'; diff --git a/tests/components/homeassistant/test_python_list.cpp b/tests/components/homeassistant/test_python_list.cpp new file mode 100644 index 00000000000..0feaefee060 --- /dev/null +++ b/tests/components/homeassistant/test_python_list.cpp @@ -0,0 +1,62 @@ +#include + +#include +#include + +#include "esphome/components/homeassistant/homeassistant.h" + +namespace esphome::homeassistant::testing { + +namespace { + +struct Parsed { + bool valid; + std::vector items; +}; + +Parsed parse(const char *text) { + Parsed result; + std::string item; + result.valid = parse_python_list( + StringRef(text), [&](char c) { item += c; }, + [&]() { + result.items.push_back(item); + item.clear(); + }); + return result; +} + +} // namespace + +TEST(PythonList, ReadsStringsWithTheirEscapes) { + auto parsed = parse(R"(['Low', "It's high", 'caf\xe9'])"); + ASSERT_TRUE(parsed.valid); + ASSERT_EQ(parsed.items.size(), 3u); + EXPECT_EQ(parsed.items[0], "Low"); + EXPECT_EQ(parsed.items[1], "It's high"); + EXPECT_EQ(parsed.items[2], "caf\xc3\xa9"); +} + +TEST(PythonList, ReadsTheValueOfEnumMembers) { + auto parsed = parse("[, ]"); + ASSERT_TRUE(parsed.valid); + ASSERT_EQ(parsed.items.size(), 2u); + EXPECT_EQ(parsed.items[0], "hs"); + EXPECT_EQ(parsed.items[1], "color_temp"); +} + +TEST(PythonList, AcceptsAnEmptyList) { + auto parsed = parse("[]"); + EXPECT_TRUE(parsed.valid); + EXPECT_TRUE(parsed.items.empty()); +} + +TEST(PythonList, RejectsMalformedLists) { + for (const char *text : {"", "[", "['a'", "['a',", "['a' 'b']", "['a'] x", "[1]", "['a\\q']", "[]", "[, ]", + "[]", "[]", "[]", "[<'a'>]"}) { + EXPECT_FALSE(parse(text).valid) << text; + } +} + +} // namespace esphome::homeassistant::testing From cf4e69273c694b5ffa7b3fc8bafc998be8077b22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 07:28:55 -1000 Subject: [PATCH 428/651] [shelly_dimmer] Keep the STM32 device table in flash (#20643) --- esphome/components/shelly_dimmer/dev_table.h | 189 +++++++++--------- esphome/components/shelly_dimmer/stm32flash.h | 7 +- 2 files changed, 104 insertions(+), 92 deletions(-) diff --git a/esphome/components/shelly_dimmer/dev_table.h b/esphome/components/shelly_dimmer/dev_table.h index 32b4810d7a2..8dee068b607 100644 --- a/esphome/components/shelly_dimmer/dev_table.h +++ b/esphome/components/shelly_dimmer/dev_table.h @@ -21,6 +21,7 @@ #include "esphome/core/defines.h" #ifdef USE_SHD_FIRMWARE_DATA +#include "esphome/core/hal.h" #include "stm32flash.h" namespace esphome::shelly_dimmer { @@ -38,118 +39,128 @@ constexpr uint32_t SZ_256K = 0x00040000; /* * Page-size for page-by-page flash erase. * Arrays are zero terminated; last non-zero value is automatically repeated + * In PROGMEM and read by direct indexing: each entry is an aligned 32-bit load, which is safe from flash on ESP8266 */ /* fixed size pages */ -constexpr uint32_t p_128[] = {SZ_128, 0}; // NOLINT -constexpr uint32_t p_256[] = {SZ_256, 0}; // NOLINT -constexpr uint32_t p_1k[] = {SZ_1K, 0}; // NOLINT -constexpr uint32_t p_2k[] = {SZ_2K, 0}; // NOLINT +alignas(4) constexpr uint32_t p_128[] PROGMEM = {SZ_128, 0}; // NOLINT +alignas(4) constexpr uint32_t p_256[] PROGMEM = {SZ_256, 0}; // NOLINT +alignas(4) constexpr uint32_t p_1k[] PROGMEM = {SZ_1K, 0}; // NOLINT +alignas(4) constexpr uint32_t p_2k[] PROGMEM = {SZ_2K, 0}; // NOLINT /* F2 and F4 page size */ -constexpr uint32_t f2f4[] = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT +alignas(4) constexpr uint32_t f2f4[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT /* F4 dual bank page size */ -constexpr uint32_t f4db[] = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT - SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; +alignas(4) constexpr uint32_t f4db[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT + SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; /* F7 page size */ -constexpr uint32_t f7[] = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT +alignas(4) constexpr uint32_t f7[] PROGMEM = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT /* * Device table, corresponds to the "Bootloader device-dependant parameters" * table in ST document AN2606. * Note that the option bytes upper range is inclusive! */ -constexpr stm32_dev_t DEVICES[] = { - /* ID "name" SRAM-address-range FLASH-address-range PPS PSize +alignas(4) constexpr stm32_dev_t DEVICES[] PROGMEM = { + /* ID SRAM-address-range FLASH-address-range PPS PSize Option-byte-addr-range System-mem-addr-range Flags */ /* F0 */ - {0x440, "STM32F030x8/F05xxx", 0x20000800, 0x20002000, 0x08000000, 0x08010000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFEC00, 0x1FFFF800, 0}, - {0x442, "STM32F030xC/F09xxx", 0x20001800, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC800, 0x1FFFF800, F_OBLL}, - {0x444, "STM32F03xx4/6", 0x20000800, 0x20001000, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFEC00, 0x1FFFF800, 0}, - {0x445, "STM32F04xxx/F070x6", 0x20001800, 0x20001800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC400, 0x1FFFF800, 0}, - {0x448, "STM32F070xB/F071xx/F72xx", 0x20001800, 0x20004000, 0x08000000, 0x08020000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC800, 0x1FFFF800, 0}, + // STM32F030x8/F05xxx + {0x440, 0x20000800, 0x20002000, 0x08000000, 0x08010000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFEC00, 0x1FFFF800, 0}, + // STM32F030xC/F09xxx + {0x442, 0x20001800, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC800, 0x1FFFF800, + F_OBLL}, + // STM32F03xx4/6 + {0x444, 0x20000800, 0x20001000, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFEC00, 0x1FFFF800, 0}, + // STM32F04xxx/F070x6 + {0x445, 0x20001800, 0x20001800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC400, 0x1FFFF800, 0}, + // STM32F070xB/F071xx/F72xx + {0x448, 0x20001800, 0x20004000, 0x08000000, 0x08020000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC800, 0x1FFFF800, 0}, /* F1 */ - {0x412, "STM32F10xxx Low-density", 0x20000200, 0x20002800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x410, "STM32F10xxx Medium-density", 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x414, "STM32F10xxx High-density", 0x20000200, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x420, "STM32F10xxx Medium-density VL", 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x428, "STM32F10xxx High-density VL", 0x20000200, 0x20008000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x418, "STM32F105xx/F107xx", 0x20001000, 0x20010000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFB000, 0x1FFFF800, 0}, - {0x430, "STM32F10xxx XL-density", 0x20000800, 0x20018000, 0x08000000, 0x08100000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFE000, 0x1FFFF800, 0}, + // STM32F10xxx Low-density + {0x412, 0x20000200, 0x20002800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx Medium-density + {0x410, 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx High-density + {0x414, 0x20000200, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx Medium-density VL + {0x420, 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx High-density VL + {0x428, 0x20000200, 0x20008000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F105xx/F107xx + {0x418, 0x20001000, 0x20010000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFB000, 0x1FFFF800, 0}, + // STM32F10xxx XL-density + {0x430, 0x20000800, 0x20018000, 0x08000000, 0x08100000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFE000, 0x1FFFF800, 0}, /* F2 */ - {0x411, "STM32F2xxxx", 0x20002000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, + // STM32F2xxxx + {0x411, 0x20002000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, /* F3 */ - {0x432, "STM32F373xx/F378xx", 0x20001400, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFD800, 0x1FFFF800, 0}, - {0x422, "STM32F302xB(C)/F303xB(C)/F358xx", 0x20001400, 0x2000A000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x439, "STM32F301xx/F302x4(6/8)/F318xx", 0x20001800, 0x20004000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x438, "STM32F303x4(6/8)/F334xx/F328xx", 0x20001800, 0x20003000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x446, "STM32F302xD(E)/F303xD(E)/F398xx", 0x20001800, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F373xx/F378xx + {0x432, 0x20001400, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F302xB(C)/F303xB(C)/F358xx + {0x422, 0x20001400, 0x2000A000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F301xx/F302x4(6/8)/F318xx + {0x439, 0x20001800, 0x20004000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F303x4(6/8)/F334xx/F328xx + {0x438, 0x20001800, 0x20003000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F302xD(E)/F303xD(E)/F398xx + {0x446, 0x20001800, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, /* F4 */ - {0x413, "STM32F40xxx/41xxx", 0x20003000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x419, "STM32F42xxx/43xxx", 0x20003000, 0x20030000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x423, "STM32F401xB(C)", 0x20003000, 0x20010000, 0x08000000, 0x08040000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x433, "STM32F401xD(E)", 0x20003000, 0x20018000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x458, "STM32F410xx", 0x20003000, 0x20008000, 0x08000000, 0x08020000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x431, "STM32F411xx", 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x421, "STM32F446xx", 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x434, "STM32F469xx", 0x20003000, 0x20060000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, + // STM32F40xxx/41xxx + {0x413, 0x20003000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F42xxx/43xxx + {0x419, 0x20003000, 0x20030000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F401xB(C) + {0x423, 0x20003000, 0x20010000, 0x08000000, 0x08040000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F401xD(E) + {0x433, 0x20003000, 0x20018000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F410xx + {0x458, 0x20003000, 0x20008000, 0x08000000, 0x08020000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F411xx + {0x431, 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F446xx + {0x421, 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F469xx + {0x434, 0x20003000, 0x20060000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, /* F7 */ - {0x449, "STM32F74xxx/75xxx", 0x20004000, 0x20050000, 0x08000000, 0x08100000, 1, f7, 0x1FFF0000, 0x1FFF001F, - 0x1FF00000, 0x1FF0EDC0, 0}, + // STM32F74xxx/75xxx + {0x449, 0x20004000, 0x20050000, 0x08000000, 0x08100000, 1, f7, 0x1FFF0000, 0x1FFF001F, 0x1FF00000, 0x1FF0EDC0, 0}, /* L0 */ - {0x425, "STM32L031xx/041xx", 0x20001000, 0x20002000, 0x08000000, 0x08008000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x417, "STM32L05xxx/06xxx", 0x20001000, 0x20002000, 0x08000000, 0x08010000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x447, "STM32L07xxx/08xxx", 0x20002000, 0x20005000, 0x08000000, 0x08030000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF02000, 0}, + // STM32L031xx/041xx + {0x425, 0x20001000, 0x20002000, 0x08000000, 0x08008000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L05xxx/06xxx + {0x417, 0x20001000, 0x20002000, 0x08000000, 0x08010000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L07xxx/08xxx + {0x447, 0x20002000, 0x20005000, 0x08000000, 0x08030000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF02000, + 0}, /* L1 */ - {0x416, "STM32L1xxx6(8/B)", 0x20000800, 0x20004000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, F_NO_ME}, - {0x429, "STM32L1xxx6(8/B)A", 0x20001000, 0x20008000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x427, "STM32L1xxxC", 0x20001000, 0x20008000, 0x08000000, 0x08040000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF02000, 0}, - {0x436, "STM32L1xxxD", 0x20001000, 0x2000C000, 0x08000000, 0x08060000, 16, p_256, 0x1FF80000, 0x1FF8009F, - 0x1FF00000, 0x1FF02000, 0}, - {0x437, "STM32L1xxxE", 0x20001000, 0x20014000, 0x08000000, 0x08080000, 16, p_256, 0x1FF80000, 0x1FF8009F, - 0x1FF00000, 0x1FF02000, F_NO_ME}, + // STM32L1xxx6(8/B) + {0x416, 0x20000800, 0x20004000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + F_NO_ME}, + // STM32L1xxx6(8/B)A + {0x429, 0x20001000, 0x20008000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L1xxxC + {0x427, 0x20001000, 0x20008000, 0x08000000, 0x08040000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF02000, + 0}, + // STM32L1xxxD + {0x436, 0x20001000, 0x2000C000, 0x08000000, 0x08060000, 16, p_256, 0x1FF80000, 0x1FF8009F, 0x1FF00000, 0x1FF02000, + 0}, + // STM32L1xxxE + {0x437, 0x20001000, 0x20014000, 0x08000000, 0x08080000, 16, p_256, 0x1FF80000, 0x1FF8009F, 0x1FF00000, 0x1FF02000, + F_NO_ME}, /* L4 */ - {0x415, "STM32L476xx/486xx", 0x20003100, 0x20018000, 0x08000000, 0x08100000, 1, p_2k, 0x1FFF7800, 0x1FFFF80F, - 0x1FFF0000, 0x1FFF7000, 0}, + // STM32L476xx/486xx + {0x415, 0x20003100, 0x20018000, 0x08000000, 0x08100000, 1, p_2k, 0x1FFF7800, 0x1FFFF80F, 0x1FFF0000, 0x1FFF7000, 0}, /* These are not (yet) in AN2606: */ - {0x641, "Medium_Density PL", 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x9a8, "STM32W-128K", 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x08040800, 0x0804080F, 0x08040000, - 0x08040800, 0}, - {0x9b0, "STM32W-256K", 0x20000200, 0x20004000, 0x08000000, 0x08040000, 4, p_2k, 0x08040800, 0x0804080F, 0x08040000, - 0x08040800, 0}, - {0x0, "", 0x0, 0x0, 0x0, 0x0, 0x0, nullptr, 0x0, 0x0, 0x0, 0x0, 0x0}, + // Medium_Density PL + {0x641, 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32W-128K + {0x9a8, 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x08040800, 0x0804080F, 0x08040000, 0x08040800, 0}, + // STM32W-256K + {0x9b0, 0x20000200, 0x20004000, 0x08000000, 0x08040000, 4, p_2k, 0x08040800, 0x0804080F, 0x08040000, 0x08040800, 0}, + {0x0, 0x0, 0x0, 0x0, 0x0, 0x0, nullptr, 0x0, 0x0, 0x0, 0x0, 0x0}, }; } // namespace esphome::shelly_dimmer diff --git a/esphome/components/shelly_dimmer/stm32flash.h b/esphome/components/shelly_dimmer/stm32flash.h index 9a81f07373e..9aa2e470808 100644 --- a/esphome/components/shelly_dimmer/stm32flash.h +++ b/esphome/components/shelly_dimmer/stm32flash.h @@ -72,17 +72,18 @@ using stm32_cmd_t = struct Stm32Cmd { uint8_t crc; }; +// Word-only fields: the table is in PROGMEM and aligned 32-bit loads from flash are safe on ESP8266 using stm32_dev_t = struct Stm32Dev { // NOLINT - const uint16_t id; - const char *name; + const uint32_t id; const uint32_t ram_start, ram_end; const uint32_t fl_start, fl_end; - const uint16_t fl_pps; // pages per sector + const uint32_t fl_pps; // pages per sector const uint32_t *fl_ps; // page size const uint32_t opt_start, opt_end; const uint32_t mem_start, mem_end; const uint32_t flags; }; +static_assert(sizeof(stm32_dev_t) == 12 * sizeof(uint32_t), "stm32_dev_t must stay word-only for flash reads"); using stm32_t = struct Stm32 { uart::UARTDevice *stream; From 1947c3d3551e98fbfd76a082bbe470083b22f7a4 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:47:14 +0100 Subject: [PATCH 429/651] [improv_ble] Start Improv again after a session has stopped (#20808) --- esphome/components/esp32_ble_server/ble_service.h | 1 + esphome/components/improv_ble/improv_ble_component.cpp | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index a0592d0a809..51a6ce72cde 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -48,6 +48,7 @@ class BLEService final { bool is_created() { return this->state_ == CREATED; } bool is_running() { return this->state_ == RUNNING; } bool is_starting() { return this->state_ == STARTING; } + bool is_stopped() { return this->state_ == STOPPED; } bool is_deleted() { return this->state_ == DELETED; } protected: diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 2029058899f..6e11975b6c3 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -142,7 +142,8 @@ void ImprovBLEComponent::loop() { this->set_status_indicator_state_(false); if (this->should_start_ && this->setup_complete_) { - if (this->service_->is_created()) { + // A service that an earlier session stopped stays STOPPED and can be started again + if (this->service_->is_created() || this->service_->is_stopped()) { this->service_->start(); } else if (this->service_->is_running()) { // Start by advertising the device name first BEFORE setting any state From bf67b49104ef7575ba9214be3ef48cedaa79a265 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:47:52 +0100 Subject: [PATCH 430/651] [i2c] Let host unit tests with I2C build on macOS (#20738) --- esphome/components/i2c/__init__.py | 21 +++++++++- tests/unit_tests/components/test_i2c.py | 51 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/components/test_i2c.py diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 453c2513fa7..5bf64609f53 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -144,10 +144,15 @@ def validate_config(config: ConfigType) -> ConfigType: return config +def _host_is_linux() -> bool: + """The host I2C bus needs Linux i2c-dev.""" + return sys.platform.lower().startswith("linux") + + def validate_host_config(config: ConfigType) -> ConfigType: if CORE.is_host: # Host I2C is currently only supported on Linux - if not sys.platform.lower().startswith("linux"): + if not _host_is_linux(): raise cv.Invalid( "I2C is only supported on Linux for the host platform. " f"Current platform: {sys.platform}" @@ -446,7 +451,7 @@ def final_validate_device_schema( ) -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_platform_filter = filter_source_files_from_platform( { "i2c_bus_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, @@ -463,3 +468,15 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "i2c_bus_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) + + +def FILTER_SOURCE_FILES() -> list[str]: + excluded = _platform_filter() + # An empty list means the target isn't known yet. Otherwise the host bus is + # only kept for the host platform, and it needs Linux i2c-dev + # (validate_host_config rejects host I2C elsewhere). Leaving it out lets host + # builds without a bus, like the C++ unit tests with their fake bus, build on + # macOS too. + if excluded and "i2c_bus_host.cpp" not in excluded and not _host_is_linux(): + excluded.append("i2c_bus_host.cpp") + return excluded diff --git a/tests/unit_tests/components/test_i2c.py b/tests/unit_tests/components/test_i2c.py new file mode 100644 index 00000000000..91304d69cf9 --- /dev/null +++ b/tests/unit_tests/components/test_i2c.py @@ -0,0 +1,51 @@ +"""I2C backend source selection.""" + +from unittest.mock import patch + +import pytest + +from esphome.components import i2c +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + + +@pytest.mark.parametrize( + "build_os, host_bus_built", + [ + ("linux", True), + ("darwin", False), + ], +) +def test_host_bus_is_only_built_on_linux(build_os: str, host_bus_built: bool) -> None: + core_data = {KEY_CORE: {KEY_TARGET_PLATFORM: "host", KEY_TARGET_FRAMEWORK: "host"}} + with ( + patch("esphome.config_helpers.CORE.data", core_data), + patch.object(i2c.sys, "platform", build_os), + ): + excluded = i2c.FILTER_SOURCE_FILES() + assert ("i2c_bus_host.cpp" not in excluded) is host_bus_built + assert {"i2c_bus_arduino.cpp", "i2c_bus_esp_idf.cpp", "i2c_bus_zephyr.cpp"} <= set( + excluded + ) + + +@pytest.mark.parametrize("build_os", ["linux", "darwin"]) +def test_nothing_excluded_before_the_target_is_known(build_os: str) -> None: + with ( + patch("esphome.config_helpers.CORE.data", {}), + patch.object(i2c.sys, "platform", build_os), + ): + assert i2c.FILTER_SOURCE_FILES() == [] + + +@pytest.mark.parametrize("build_os", ["linux", "darwin"]) +def test_host_bus_left_out_for_other_targets(build_os: str) -> None: + core_data = { + KEY_CORE: {KEY_TARGET_PLATFORM: "esp32", KEY_TARGET_FRAMEWORK: "esp-idf"} + } + with ( + patch("esphome.config_helpers.CORE.data", core_data), + patch.object(i2c.sys, "platform", build_os), + ): + excluded = i2c.FILTER_SOURCE_FILES() + assert excluded.count("i2c_bus_host.cpp") == 1 + assert "i2c_bus_esp_idf.cpp" not in excluded From 6356458081d1dd57519b27d09220210435c260a8 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 20:27:57 +0200 Subject: [PATCH 431/651] [dfrobot_sen0609] Add DFRobot SEN0609/SEN0610 radar (#20809) Co-authored-by: Claude Opus 5.5 --- CODEOWNERS | 1 + .../components/dfrobot_sen0609/__init__.py | 76 +++ .../dfrobot_sen0609/binary_sensor.py | 24 + .../dfrobot_sen0609/dfrobot_sen0609.cpp | 483 ++++++++++++++++++ .../dfrobot_sen0609/dfrobot_sen0609.h | 113 ++++ esphome/components/dfrobot_sen0609/sensor.py | 64 +++ .../components/dfrobot_sen0609/text_sensor.py | 29 ++ .../components/dfrobot_sen0609/benchmark.yaml | 5 + .../dfrobot_sen0609/common-speed.yaml | 19 + tests/components/dfrobot_sen0609/common.h | 94 ++++ tests/components/dfrobot_sen0609/common.yaml | 17 + .../dfrobot_sen0609_frames.cpp | 72 +++ .../dfrobot_sen0609_sequencer.cpp | 174 +++++++ .../dfrobot_sen0609/test-speed.esp32-idf.yaml | 3 + .../dfrobot_sen0609/test.esp32-idf.yaml | 3 + .../dfrobot_sen0609/test.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_dfrobot_sen0609.yaml | 56 ++ .../test_uart_mock_dfrobot_sen0609.py | 57 +++ 18 files changed, 1293 insertions(+) create mode 100644 esphome/components/dfrobot_sen0609/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/binary_sensor.py create mode 100644 esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp create mode 100644 esphome/components/dfrobot_sen0609/dfrobot_sen0609.h create mode 100644 esphome/components/dfrobot_sen0609/sensor.py create mode 100644 esphome/components/dfrobot_sen0609/text_sensor.py create mode 100644 tests/components/dfrobot_sen0609/benchmark.yaml create mode 100644 tests/components/dfrobot_sen0609/common-speed.yaml create mode 100644 tests/components/dfrobot_sen0609/common.h create mode 100644 tests/components/dfrobot_sen0609/common.yaml create mode 100644 tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp create mode 100644 tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp create mode 100644 tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0609/test.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0609/test.esp8266-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml create mode 100644 tests/integration/test_uart_mock_dfrobot_sen0609.py diff --git a/CODEOWNERS b/CODEOWNERS index 5e7c2eae794..46b94954fdc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -156,6 +156,7 @@ esphome/components/delonghi/* @grob6000 esphome/components/dew_point/* @CFlix esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber +esphome/components/dfrobot_sen0609/* @remcom esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py new file mode 100644 index 00000000000..4825f4b770a --- /dev/null +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -0,0 +1,76 @@ +from collections.abc import Callable + +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODE +from esphome.core import ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["uart"] +DOMAIN = "dfrobot_sen0609" +MULTI_CONF = True + +dfrobot_sen0609_ns = cg.esphome_ns.namespace("dfrobot_sen0609") +DFRobotSEN0609Component = dfrobot_sen0609_ns.class_( + "DFRobotSEN0609Component", cg.Component, uart.UARTDevice +) +RunMode = dfrobot_sen0609_ns.enum("RunMode", is_class=True) + +CONF_DFROBOT_SEN0609_ID = "dfrobot_sen0609_id" + +MODE_PRESENCE = "presence" +MODE_SPEED_AND_DISTANCE = "speed_and_distance" +RUN_MODES = { + MODE_PRESENCE: RunMode.RUN_MODE_PRESENCE, + MODE_SPEED_AND_DISTANCE: RunMode.RUN_MODE_SPEED_AND_DISTANCE, +} + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(DFRobotSEN0609Component), + cv.Optional(CONF_MODE, default=MODE_PRESENCE): cv.one_of( + *RUN_MODES, lower=True + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=9600, + require_tx=True, + require_rx=True, + parity="NONE", + stop_bits=1, +) + + +def get_hub_config(root: ConfigType, hub_id: ID) -> ConfigType: + """Return the configuration of the hub with the given id.""" + return next(conf for conf in root[DOMAIN] if conf[CONF_ID] == hub_id) + + +def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: + """Reject entity keys that need a different hub mode; `keys` maps each key to the mode it needs.""" + + def validator(config: ConfigType) -> None: + hub = get_hub_config(fv.full_config.get(), config[CONF_DFROBOT_SEN0609_ID]) + for key, mode in keys.items(): + if key in config and hub[CONF_MODE] != mode: + raise cv.Invalid( + f"'{key}' needs the {DOMAIN} 'mode' to be '{mode}'", path=[key] + ) + + return validator + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/dfrobot_sen0609/binary_sensor.py b/esphome/components/dfrobot_sen0609/binary_sensor.py new file mode 100644 index 00000000000..8d4cb7ed846 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/binary_sensor.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_HAS_TARGET, CONF_ID, DEVICE_CLASS_OCCUPANCY, ICON_ACCOUNT +from esphome.types import ConfigType + +from . import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component + +DEPENDENCIES = ["dfrobot_sen0609"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + 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_DFROBOT_SEN0609_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_has_target_binary_sensor) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp new file mode 100644 index 00000000000..0e6ff31ffe7 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -0,0 +1,483 @@ +#include "dfrobot_sen0609.h" + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include +#include +#include + +namespace esphome::dfrobot_sen0609 { + +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0609"); + +static constexpr uint8_t MAX_ATTEMPTS = 3; +// The module ignores a command that follows its last answer too closely +static constexpr uint32_t COMMAND_GAP_MS = 100; +static constexpr uint32_t DEFAULT_TIMEOUT_MS = 1000; +static constexpr uint32_t LONG_TIMEOUT_MS = 2000; +static constexpr uint32_t GIVE_UP_BACKOFF_MS = 30000; +// Presence frames come every second (setUartOutput period); this long without one means the module lost them +static constexpr uint32_t FRAME_TIMEOUT_MS = 10000; +static constexpr uint8_t FRAME_FIELDS = 5; + +static constexpr uint8_t LINE_END[] = {'\r', '\n'}; + +// Indexed by Param; empty for parameters that cannot be read +PROGMEM_STRING_TABLE(ReadCommands, "", "", "getSWV", "getHWV"); + +static_assert(PARAM_COUNT <= 16, "pending_writes_ and pending_reads_ hold one bit per parameter"); +static constexpr uint16_t param_bit(Param param) { return static_cast(1U << param); } + +static bool starts_with(const char *str, ESPHOME_PGM_P prefix) { + return ESPHOME_strncmp_P(str, prefix, ESPHOME_strlen_P(prefix)) == 0; +} + +static bool contains(const char *str, ESPHOME_PGM_P needle) { + const size_t len = ESPHOME_strlen_P(needle); + for (; *str != '\0'; str++) { + if (ESPHOME_strncmp_P(str, needle, len) == 0) + return true; + } + return false; +} + +// Splits "a,b,c*" in place; returns the number of fields stored in `fields` +static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { + uint8_t count = 0; + char *start = str; + for (;; str++) { + if (*str != ',' && *str != '*' && *str != '\0') + continue; + const bool last = *str != ','; + *str = '\0'; + if (count < max_fields) + fields[count++] = start; + if (last) + return count; + start = str + 1; + } +} + +// Answers that refuse a command whose effect is already in place +static bool answer_means_done(Operation operation, const char *line) { + switch (operation) { + case Operation::OPERATION_STOP: + return contains(line, ESPHOME_PSTR("stopped already")); + case Operation::OPERATION_START: + return contains(line, ESPHOME_PSTR("started already")); + default: + return false; + } +} + +static bool parse_float(const char *str, float &value) { + char *end; + value = strtof(str, &end); + return end != str && *end == '\0'; +} + +static const LogString *operation_to_str(Operation operation) { + switch (operation) { + case Operation::OPERATION_STOP: + return LOG_STR("stop"); + case Operation::OPERATION_START: + return LOG_STR("start"); + case Operation::OPERATION_WRITE: + return LOG_STR("write"); + case Operation::OPERATION_READ: + return LOG_STR("read"); + default: + return LOG_STR("none"); + } +} + +DFRobotSEN0609Component::DFRobotSEN0609Component(RunMode mode) : mode_(mode) {} + +void DFRobotSEN0609Component::dump_config() { + ESP_LOGCONFIG( + TAG, + "DFRobot SEN0609:\n" + " Mode: %s", + this->mode_ == RunMode::RUN_MODE_PRESENCE ? LOG_STR_LITERAL("presence") : LOG_STR_LITERAL("speed and distance")); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "HasTarget", this->has_target_binary_sensor_); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "TargetDistance", this->target_distance_sensor_); + LOG_SENSOR(" ", "TargetSpeed", this->target_speed_sensor_); + LOG_SENSOR(" ", "TargetEnergy", this->target_energy_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); + LOG_TEXT_SENSOR(" ", "HardwareVersion", this->hardware_version_text_sensor_); +#endif +} + +void DFRobotSEN0609Component::setup() { this->queue_configuration_(); } + +void DFRobotSEN0609Component::loop() { + uint8_t buf[MAX_LINE_LENGTH]; + size_t avail = this->available(); + while (avail > 0) { + const size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + if (!this->read_failed_) { + ESP_LOGW(TAG, "UART read failed"); + } + this->read_failed_ = true; + break; + } + this->read_failed_ = false; + avail -= to_read; + for (size_t i = 0; i < to_read; i++) + this->readline_(buf[i]); + } + this->run_sequencer_(); +} + +void DFRobotSEN0609Component::readline_(uint8_t c) { + if (c == '\r') + return; + if (c == '\n') { + if (!this->line_overflow_) { + this->line_[this->line_pos_] = '\0'; + this->handle_line_(this->line_); + } + this->line_overflow_ = false; + this->line_pos_ = 0; + return; + } + if (this->line_overflow_) + return; + if (this->line_pos_ >= MAX_LINE_LENGTH - 1) { + ESP_LOGW(TAG, "Line too long, dropped"); + this->line_overflow_ = true; + return; + } + this->line_[this->line_pos_++] = static_cast(c); + // The prompt has no line end, and the echo of the next command follows it on the same line + if (c == '>') { + this->line_[this->line_pos_] = '\0'; + if (ESPHOME_strcmp_P(this->line_, ESPHOME_PSTR("DFRobot:/>")) == 0) + this->line_pos_ = 0; + } +} + +void DFRobotSEN0609Component::handle_line_(char *line) { + if (*line == '\0') + return; + ESP_LOGV(TAG, "RX: %s", line); + if (line[0] == '$') { + this->handle_frame_(line); + return; + } + // Version lines name their value, so they are used even when they arrive late + const bool software = starts_with(line, ESPHOME_PSTR("SoftwareVersion:")); + if (software || starts_with(line, ESPHOME_PSTR("HardwareVersion:"))) { + if (this->operation_ == Operation::OPERATION_READ && + this->param_ == (software ? PARAM_SW_VERSION : PARAM_HW_VERSION)) + this->version_received_ = true; +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *sens = software ? this->version_text_sensor_ : this->hardware_version_text_sensor_; + if (sens != nullptr) + sens->publish_state(line + 16); +#endif + return; + } + if (this->operation_ == Operation::OPERATION_NONE) { + ESP_LOGD(TAG, "Ignored line, no command pending: %s", line); + return; + } + if (strcmp(line, this->command_) == 0) { + this->echo_seen_ = true; + return; + } + // The module echoes every command first; lines before the echo belong to an earlier command + if (!this->echo_seen_) { + ESP_LOGD(TAG, "Ignored line from an earlier command: %s", line); + return; + } + + if (starts_with(line, ESPHOME_PSTR("Done")) || answer_means_done(this->operation_, line)) { + this->finish_operation_(); + } else if (contains(line, ESPHOME_PSTR("not stopped"))) { + // Stop the sensor before the next attempt + this->stopped_ = false; + this->fail_operation_(); + } else if (starts_with(line, ESPHOME_PSTR("Error")) || contains(line, ESPHOME_PSTR("not recognized"))) { + ESP_LOGW(TAG, "Command '%s' rejected: %s", this->command_, line); + this->fail_operation_(); + } else { + ESP_LOGD(TAG, "Unexpected answer to '%s': %s", this->command_, line); + } +} + +void DFRobotSEN0609Component::handle_frame_(char *line) { + // $DFHPD,, , , * + if (starts_with(line, ESPHOME_PSTR("$DFHPD,"))) { + const char presence = line[7]; + if (presence != '0' && presence != '1') { + this->log_bad_frame_(LOG_STR("Malformed presence frame"), line); + return; + } + this->last_frame_ms_ = App.get_loop_component_start_time(); +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->publish_state(presence == '1'); +#endif + return; + } + // $DFDMD,,,,,,...* + if (!starts_with(line, ESPHOME_PSTR("$DFDMD,"))) { + this->log_bad_frame_(LOG_STR("Unknown frame"), line); + return; + } + char *fields[FRAME_FIELDS]; + float distance; + float speed; + float energy; + if (split_fields(line + 7, fields, FRAME_FIELDS) < FRAME_FIELDS) { + this->log_bad_frame_(LOG_STR("Malformed target frame"), line); + return; + } + const optional targets = parse_number(fields[0]); + if (!targets.has_value()) { + this->log_bad_frame_(LOG_STR("Malformed target frame"), line); + return; + } + if (*targets == 0) { +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->publish_state(false); +#endif +#ifdef USE_SENSOR + this->publish_target_(NAN, NAN, NAN); +#endif + return; + } + if (!parse_float(fields[2], distance) || !parse_float(fields[3], speed) || !parse_float(fields[4], energy)) { + this->log_bad_frame_(LOG_STR("Malformed target frame"), line); + return; + } +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->publish_state(true); +#endif +#ifdef USE_SENSOR + this->publish_target_(distance, speed, energy); +#endif +} + +void DFRobotSEN0609Component::log_bad_frame_(const LogString *problem, const char *line) { + // The module sends frames several times a second, so only the first bad one is logged + if (this->bad_frame_logged_) + return; + this->bad_frame_logged_ = true; + ESP_LOGW(TAG, "%s: %s", LOG_STR_ARG(problem), line); +} + +#ifdef USE_SENSOR +static void publish_deduplicated(sensor::Sensor *sens, Deduplicator &dedup, float value) { + if (sens == nullptr) + return; + if (std::isnan(value) ? dedup.next_unknown() : dedup.next(value)) + sens->publish_state(value); +} + +void DFRobotSEN0609Component::publish_target_(float distance, float speed, float energy) { + publish_deduplicated(this->target_distance_sensor_, this->distance_dedup_, distance); + publish_deduplicated(this->target_speed_sensor_, this->speed_dedup_, speed); + publish_deduplicated(this->target_energy_sensor_, this->energy_dedup_, energy); +} +#endif + +void DFRobotSEN0609Component::queue_configuration_() { + // The mode and the UART output are applied at every boot instead of being saved + uint16_t writes = param_bit(PARAM_RUN_APP); + if (this->mode_ == RunMode::RUN_MODE_PRESENCE) + writes |= param_bit(PARAM_UART_OUTPUT); + this->pending_writes_ |= writes; + this->pending_reads_ |= this->wanted_reads_(); +} + +uint16_t DFRobotSEN0609Component::wanted_reads_() const { + uint16_t reads = 0; +#ifdef USE_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + reads |= param_bit(PARAM_SW_VERSION); + if (this->hardware_version_text_sensor_ != nullptr) + reads |= param_bit(PARAM_HW_VERSION); +#endif + return reads; +} + +void DFRobotSEN0609Component::check_frames_(uint32_t now) { + // Speed mode frames are not watched: whether the module sends them without a target is not known + if (this->mode_ != RunMode::RUN_MODE_PRESENCE || now - this->last_frame_ms_ < FRAME_TIMEOUT_MS) + return; + // The module most likely restarted on its own and lost the configuration applied at boot + ESP_LOGW(TAG, "No presence frames, applying the configuration again"); + this->status_set_warning(); +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->invalidate_state(); +#endif + this->last_frame_ms_ = now; + this->queue_configuration_(); +} + +void DFRobotSEN0609Component::hold_(uint32_t duration_ms) { + this->hold_start_ms_ = App.get_loop_component_start_time(); + this->hold_ms_ = duration_ms; +} + +void DFRobotSEN0609Component::run_sequencer_() { + const uint32_t now = App.get_loop_component_start_time(); + if (this->operation_ != Operation::OPERATION_NONE) { + const bool slow = this->operation_ == Operation::OPERATION_STOP; + if (now - this->sent_ms_ < (slow ? LONG_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) + return; + ESP_LOGD(TAG, "No answer to '%s'", this->command_); + this->fail_operation_(); + return; + } + if (now - this->hold_start_ms_ < this->hold_ms_) + return; + + const bool work_pending = this->pending_writes_ != 0 || this->pending_reads_ != 0; + Operation operation; + Param param = PARAM_COUNT; + if (!work_pending) { + if (!this->stopped_) { + this->check_frames_(now); + return; + } + operation = Operation::OPERATION_START; + } else if (!this->stopped_) { + operation = Operation::OPERATION_STOP; + } else if (this->pending_writes_ != 0) { + operation = Operation::OPERATION_WRITE; + param = static_cast(__builtin_ctz(this->pending_writes_)); + } else { + operation = Operation::OPERATION_READ; + param = static_cast(__builtin_ctz(this->pending_reads_)); + } + + const uint16_t bit = param_bit(param); + uint16_t &pending = operation == Operation::OPERATION_WRITE ? this->pending_writes_ : this->pending_reads_; + if (!this->build_command_(operation, param)) { + ESP_LOGE(TAG, "No command for %s of parameter %u", LOG_STR_ARG(operation_to_str(operation)), + static_cast(param)); + pending &= ~bit; + this->sequence_failed_ = true; + this->status_set_warning(); + return; + } + if (operation == Operation::OPERATION_WRITE) + pending &= ~bit; + this->operation_ = operation; + this->param_ = param; + this->echo_seen_ = false; + this->version_received_ = false; + this->sent_ms_ = now; + ESP_LOGV(TAG, "TX: %s", this->command_); + this->write_str(this->command_); + this->write_array(LINE_END, sizeof(LINE_END)); +} + +bool DFRobotSEN0609Component::build_command_(Operation operation, Param param) { + char *buf = this->command_; + constexpr size_t len = MAX_COMMAND_LENGTH; + switch (operation) { + case Operation::OPERATION_STOP: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("sensorStop"), len); + return true; + case Operation::OPERATION_START: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("sensorStart"), len); + return true; + case Operation::OPERATION_READ: + ESPHOME_strncpy_P(buf, reinterpret_cast(ReadCommands::get_progmem_str(param, 0)), len); + return buf[0] != '\0'; + default: + break; + } + + switch (param) { + case PARAM_RUN_APP: + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setRunApp %u"), static_cast(this->mode_)); + return true; + case PARAM_UART_OUTPUT: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("setUartOutput 1 1 1 1.0"), len); + return true; + default: + buf[0] = '\0'; + return false; + } +} + +void DFRobotSEN0609Component::finish_operation_() { + switch (this->operation_) { + case Operation::OPERATION_STOP: + this->stopped_ = true; + break; + case Operation::OPERATION_START: + this->stopped_ = false; + this->last_frame_ms_ = App.get_loop_component_start_time(); + if (!this->sequence_failed_) + this->status_clear_warning(); + this->sequence_failed_ = false; + break; + case Operation::OPERATION_WRITE: + break; + case Operation::OPERATION_READ: + // The versions are published as their lines arrive + if (!this->version_received_) { + ESP_LOGW(TAG, "No version in the answer to '%s'", this->command_); + this->fail_operation_(); + return; + } + this->pending_reads_ &= ~param_bit(this->param_); + break; + default: + return; + } + if (this->is_aux_operation_()) { + this->aux_attempts_ = 0; + } else { + this->attempts_ = 0; + } + this->operation_ = Operation::OPERATION_NONE; + this->hold_(COMMAND_GAP_MS); +} + +void DFRobotSEN0609Component::fail_operation_() { + const bool aux = this->is_aux_operation_(); + uint8_t &attempts = aux ? this->aux_attempts_ : this->attempts_; + const Operation operation = this->operation_; + this->operation_ = Operation::OPERATION_NONE; + this->hold_(COMMAND_GAP_MS); + // The writes are the boot configuration, which the frames depend on: keep them until they succeed + if (operation == Operation::OPERATION_WRITE) + this->pending_writes_ |= param_bit(this->param_); + if (++attempts < MAX_ATTEMPTS) + return; + + attempts = 0; + this->status_set_warning(); + if (aux || operation == Operation::OPERATION_WRITE) { + // Without stop, start or the configuration the sensor cannot run as set up; try again later + ESP_LOGW(TAG, "Command '%s' failed, trying again later", this->command_); + this->hold_ms_ = GIVE_UP_BACKOFF_MS; + return; + } + ESP_LOGW(TAG, "Giving up on %s '%s'", LOG_STR_ARG(operation_to_str(operation)), this->command_); + this->sequence_failed_ = true; + if (operation == Operation::OPERATION_READ) + this->pending_reads_ &= ~param_bit(this->param_); +} + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h new file mode 100644 index 00000000000..7d5baf97a0c --- /dev/null +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -0,0 +1,113 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.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 +#include "esphome/components/uart/uart.h" + +namespace esphome::dfrobot_sen0609 { + +static constexpr uint8_t MAX_LINE_LENGTH = 80; +static constexpr uint8_t MAX_COMMAND_LENGTH = 40; + +enum class RunMode : uint8_t { + RUN_MODE_PRESENCE = 0, + RUN_MODE_SPEED_AND_DISTANCE = 1, +}; + +/// Module parameters; a write or read of each is one command. Pending work runs in this order. +enum Param : uint8_t { + PARAM_RUN_APP, + PARAM_UART_OUTPUT, + PARAM_SW_VERSION, + PARAM_HW_VERSION, + PARAM_COUNT, +}; + +enum class Operation : uint8_t { + OPERATION_NONE, + OPERATION_STOP, + OPERATION_START, + OPERATION_WRITE, + OPERATION_READ, +}; + +class DFRobotSEN0609Component final : public Component, public uart::UARTDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(has_target) +#endif +#ifdef USE_SENSOR + SUB_SENSOR(target_distance) + SUB_SENSOR(target_speed) + SUB_SENSOR(target_energy) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(version) + SUB_TEXT_SENSOR(hardware_version) +#endif + + public: + explicit DFRobotSEN0609Component(RunMode mode); + + void setup() override; + void loop() override; + void dump_config() override; + + protected: + void readline_(uint8_t c); + void handle_line_(char *line); + void handle_frame_(char *line); + void log_bad_frame_(const LogString *problem, const char *line); + + void queue_configuration_(); + uint16_t wanted_reads_() const; + void run_sequencer_(); + void check_frames_(uint32_t now); + bool build_command_(Operation operation, Param param); + void finish_operation_(); + void fail_operation_(); + void hold_(uint32_t duration_ms); + bool is_aux_operation_() const { + return this->operation_ == Operation::OPERATION_STOP || this->operation_ == Operation::OPERATION_START; + } + +#ifdef USE_SENSOR + void publish_target_(float distance, float speed, float energy); + Deduplicator distance_dedup_; + Deduplicator speed_dedup_; + Deduplicator energy_dedup_; +#endif + + char line_[MAX_LINE_LENGTH]; + char command_[MAX_COMMAND_LENGTH]{}; + uint32_t sent_ms_{0}; + uint32_t hold_start_ms_{0}; + uint32_t hold_ms_{0}; + uint32_t last_frame_ms_{0}; + uint16_t pending_writes_{0}; + uint16_t pending_reads_{0}; + RunMode mode_; + Operation operation_{Operation::OPERATION_NONE}; + Param param_{PARAM_COUNT}; + uint8_t line_pos_{0}; + uint8_t attempts_{0}; + uint8_t aux_attempts_{0}; + bool line_overflow_{false}; + bool read_failed_{false}; + bool bad_frame_logged_{false}; + bool echo_seen_{false}; + bool version_received_{false}; + bool stopped_{false}; + bool sequence_failed_{false}; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/sensor.py b/esphome/components/dfrobot_sen0609/sensor.py new file mode 100644 index 00000000000..43c1a1bb05a --- /dev/null +++ b/esphome/components/dfrobot_sen0609/sensor.py @@ -0,0 +1,64 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_SPEED, + ICON_SIGNAL, + STATE_CLASS_MEASUREMENT, + UNIT_METER, + UNIT_METER_PER_SECOND, +) +from esphome.types import ConfigType + +from . import ( + CONF_DFROBOT_SEN0609_ID, + MODE_SPEED_AND_DISTANCE, + DFRobotSEN0609Component, + final_validate_mode, +) + +DEPENDENCIES = ["dfrobot_sen0609"] + +CONF_TARGET_DISTANCE = "target_distance" +CONF_TARGET_ENERGY = "target_energy" +CONF_TARGET_SPEED = "target_speed" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_TARGET_DISTANCE): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_METER, + ), + cv.Optional(CONF_TARGET_SPEED): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_SPEED, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_METER_PER_SECOND, + ), + cv.Optional(CONF_TARGET_ENERGY): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_SIGNAL, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + +FINAL_VALIDATE_SCHEMA = final_validate_mode( + { + CONF_TARGET_DISTANCE: MODE_SPEED_AND_DISTANCE, + CONF_TARGET_ENERGY: MODE_SPEED_AND_DISTANCE, + CONF_TARGET_SPEED: MODE_SPEED_AND_DISTANCE, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_DISTANCE, hub.set_target_distance_sensor) + await sensors(CONF_TARGET_SPEED, hub.set_target_speed_sensor) + await sensors(CONF_TARGET_ENERGY, hub.set_target_energy_sensor) diff --git a/esphome/components/dfrobot_sen0609/text_sensor.py b/esphome/components/dfrobot_sen0609/text_sensor.py new file mode 100644 index 00000000000..8972f33c58e --- /dev/null +++ b/esphome/components/dfrobot_sen0609/text_sensor.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from . import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component + +DEPENDENCIES = ["dfrobot_sen0609"] + +CONF_HARDWARE_VERSION = "hardware_version" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), + cv.Optional(CONF_HARDWARE_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_DFROBOT_SEN0609_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_HARDWARE_VERSION, hub.set_hardware_version_text_sensor) diff --git a/tests/components/dfrobot_sen0609/benchmark.yaml b/tests/components/dfrobot_sen0609/benchmark.yaml new file mode 100644 index 00000000000..9a41ec7cf26 --- /dev/null +++ b/tests/components/dfrobot_sen0609/benchmark.yaml @@ -0,0 +1,5 @@ +# Declaring the entity domains makes the C++ unit test build define USE_SENSOR and friends. +# Loaded with plain yaml.safe_load, so no ESPHome tags here. +binary_sensor: +sensor: +text_sensor: diff --git a/tests/components/dfrobot_sen0609/common-speed.yaml b/tests/components/dfrobot_sen0609/common-speed.yaml new file mode 100644 index 00000000000..b4063a6f6b2 --- /dev/null +++ b/tests/components/dfrobot_sen0609/common-speed.yaml @@ -0,0 +1,19 @@ +dfrobot_sen0609: + id: sen0609_speed + mode: speed_and_distance + +binary_sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + has_target: + name: Target + +sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + target_distance: + name: Target Distance + target_speed: + name: Target Speed + target_energy: + name: Target Energy diff --git a/tests/components/dfrobot_sen0609/common.h b/tests/components/dfrobot_sen0609/common.h new file mode 100644 index 00000000000..08b9d86366a --- /dev/null +++ b/tests/components/dfrobot_sen0609/common.h @@ -0,0 +1,94 @@ +#pragma once +#include +#include +#include +#include "esphome/components/dfrobot_sen0609/dfrobot_sen0609.h" +#include "esphome/components/uart/uart_component.h" +#include "esphome/core/application.h" + +namespace esphome::dfrobot_sen0609::testing { + +// Records what the hub writes and hands it what the test queued as module output. +class FakeUART : public uart::UARTComponent { + public: + void write_array(const uint8_t *data, size_t len) override { + this->tx.append(reinterpret_cast(data), len); + } + bool peek_byte(uint8_t *data) override { + if (this->available() == 0) + return false; + *data = this->rx[this->rx_pos]; + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (this->available() < len) + return false; + memcpy(data, this->rx.data() + this->rx_pos, len); + this->rx_pos += len; + return true; + } + size_t available() override { return this->rx.size() - this->rx_pos; } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + void check_logger_conflict() override {} +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif + + std::string tx; + std::string rx; + size_t rx_pos{0}; +}; + +class SEN0609Test : public ::testing::Test { + protected: + explicit SEN0609Test(RunMode mode = RunMode::RUN_MODE_PRESENCE) : hub_(mode) {} + + void SetUp() override { + this->hub_.set_uart_parent(&this->uart_); + this->hub_.set_has_target_binary_sensor(&this->has_target_); + } + + // Runs the hub's loop `ms` later than the previous run + void run_(uint32_t ms = 16) { + this->now_ += ms; + LoopBlockingGuard guard{nullptr, nullptr, this->now_}; + this->hub_.loop(); + } + + void feed_(const std::string &text) { + this->uart_.rx += text; + this->run_(); + } + + // Runs the loop until the hub writes a full command line, and returns it without the line end + std::string next_command_(uint32_t max_ms = 5000) { + for (uint32_t waited = 0; waited <= max_ms; waited += 16) { + const size_t end = this->uart_.tx.find("\r\n"); + if (end != std::string::npos) { + std::string command = this->uart_.tx.substr(0, end); + this->uart_.tx.erase(0, end + 2); + return command; + } + this->run_(); + } + return ""; + } + + // Answers a command the way the module does: echo, optional lines, Done and the prompt + void answer_(const std::string &command, const std::string &lines = "") { + this->feed_(command + "\r\n" + lines + "Done\r\nDFRobot:/>"); + } + + // Expects `command` next and answers it + void expect_and_answer_(const std::string &command, const std::string &lines = "") { + ASSERT_EQ(this->next_command_(), command); + this->answer_(command, lines); + } + + FakeUART uart_; + DFRobotSEN0609Component hub_; + binary_sensor::BinarySensor has_target_; + uint32_t now_{1000}; +}; + +} // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/components/dfrobot_sen0609/common.yaml b/tests/components/dfrobot_sen0609/common.yaml new file mode 100644 index 00000000000..1f3887be631 --- /dev/null +++ b/tests/components/dfrobot_sen0609/common.yaml @@ -0,0 +1,17 @@ +dfrobot_sen0609: + id: sen0609_presence + mode: presence + +binary_sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + has_target: + name: Presence + +text_sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + version: + name: Software Version + hardware_version: + name: Hardware Version diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp new file mode 100644 index 00000000000..7044d12675b --- /dev/null +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp @@ -0,0 +1,72 @@ +#include "common.h" + +namespace esphome::dfrobot_sen0609::testing { + +class SEN0609FrameTest : public SEN0609Test { + protected: + SEN0609FrameTest() : SEN0609Test(RunMode::RUN_MODE_SPEED_AND_DISTANCE) {} + + void SetUp() override { + SEN0609Test::SetUp(); + this->hub_.set_target_distance_sensor(&this->distance_); + this->hub_.set_target_speed_sensor(&this->speed_); + this->hub_.set_target_energy_sensor(&this->energy_); + } + + sensor::Sensor distance_; + sensor::Sensor speed_; + sensor::Sensor energy_; +}; + +TEST_F(SEN0609FrameTest, PresenceFrames) { + this->feed_("$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); + this->feed_("$DFHPD,0, , , *\r\n"); + EXPECT_FALSE(this->has_target_.state); +} + +TEST_F(SEN0609FrameTest, TargetFrame) { + this->feed_("$DFDMD,1,0,2.35,-0.40,1234,0,0*\r\n"); + EXPECT_TRUE(this->has_target_.state); + EXPECT_FLOAT_EQ(this->distance_.state, 2.35f); + EXPECT_FLOAT_EQ(this->speed_.state, -0.40f); + EXPECT_FLOAT_EQ(this->energy_.state, 1234.0f); +} + +TEST_F(SEN0609FrameTest, NoTargetMakesValuesUnknown) { + this->feed_("$DFDMD,1,0,2.35,-0.40,1234,0,0*\r\n"); + this->feed_("$DFDMD,0,0,0,0,0,0,0*\r\n"); + EXPECT_FALSE(this->has_target_.state); + EXPECT_TRUE(std::isnan(this->distance_.state)); + EXPECT_TRUE(std::isnan(this->speed_.state)); + EXPECT_TRUE(std::isnan(this->energy_.state)); +} + +TEST_F(SEN0609FrameTest, MalformedFramesAreIgnored) { + this->feed_("$DFHPD,x, , , *\r\n"); + this->feed_("$DFDMD,1,0,abc,0.1,5*\r\n"); + this->feed_("$DFDMD,1,0,2.0*\r\n"); + this->feed_("$DFDMD,,0,2.0,0.1,5*\r\n"); + EXPECT_FALSE(this->has_target_.has_state()); + EXPECT_FALSE(this->distance_.has_state()); +} + +TEST_F(SEN0609FrameTest, FrameAfterPromptOnSameLine) { + this->feed_("DFRobot:/>$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); +} + +TEST_F(SEN0609FrameTest, FrameSplitAcrossReads) { + this->feed_("$DFDMD,1,0,3."); + EXPECT_FALSE(this->distance_.has_state()); + this->feed_("50,0.00,77,0,0*\r\n"); + EXPECT_FLOAT_EQ(this->distance_.state, 3.5f); +} + +TEST_F(SEN0609FrameTest, LongLineIsDroppedAndParserRecovers) { + this->feed_(std::string(200, 'x') + "\r\n"); + this->feed_("$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); +} + +} // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp new file mode 100644 index 00000000000..341ba09432f --- /dev/null +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp @@ -0,0 +1,174 @@ +#include "common.h" + +namespace esphome::dfrobot_sen0609::testing { + +class SEN0609SequencerTest : public SEN0609Test { + protected: + void SetUp() override { + SEN0609Test::SetUp(); + this->hub_.set_version_text_sensor(&this->version_); + this->hub_.set_hardware_version_text_sensor(&this->hardware_version_); + } + + // Runs the boot sequence with a module that answers everything + void boot_() { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + } + + text_sensor::TextSensor version_; + text_sensor::TextSensor hardware_version_; +}; + +TEST_F(SEN0609SequencerTest, BootAppliesModeAndReadsVersions) { + this->boot_(); + EXPECT_EQ(this->version_.state, "V1.0.4"); + EXPECT_EQ(this->hardware_version_.state, "V2.0"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ErrorAnswerIsRetriedThenSkipped) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "getSWV"); + this->feed_("getSWV\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_TRUE(this->hub_.status_has_warning()); + EXPECT_FALSE(this->version_.has_state()); +} + +TEST_F(SEN0609SequencerTest, LateAnswerIsNotTakenForNextCommand) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "getSWV"); + ASSERT_EQ(this->next_command_(), "getHWV"); + // The answer to the last getSWV arrives after it timed out + this->feed_("getSWV\r\nSoftwareVersion:V1.0.4\r\nDone\r\nDFRobot:/>"); + this->answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->hardware_version_.state, "V2.0"); + // The late version line names its value, so it is still used + EXPECT_EQ(this->version_.state, "V1.0.4"); +} + +TEST_F(SEN0609SequencerTest, MissingVersionLineIsRetried) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + EXPECT_EQ(this->version_.state, "V1.0.4"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, FailedConfigurationIsRetriedLater) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setRunApp 0"); + this->feed_("setRunApp 0\r\nError\r\nDFRobot:/>"); + } + EXPECT_TRUE(this->hub_.status_has_warning()); + // The sensor is not started in an unknown mode + EXPECT_EQ(this->next_command_(20000), ""); + ASSERT_EQ(this->next_command_(15000), "setRunApp 0"); + this->answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, SilentModuleBacksOffAndKeepsWork) { + this->hub_.setup(); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "sensorStop"); + this->run_(2100); // the last attempt times out + EXPECT_TRUE(this->hub_.status_has_warning()); + EXPECT_EQ(this->next_command_(20000), ""); + // The module shows up later; the boot work is still pending + ASSERT_EQ(this->next_command_(15000), "sensorStop"); + this->answer_("sensorStop"); + EXPECT_EQ(this->next_command_(), "setRunApp 0"); +} + +TEST_F(SEN0609SequencerTest, AlreadyStoppedCountsAsStopped) { + this->hub_.setup(); + ASSERT_EQ(this->next_command_(), "sensorStop"); + this->feed_("sensorStop\r\nsensor stopped already\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), "setRunApp 0"); +} + +TEST_F(SEN0609SequencerTest, NotStoppedStopsAgainBeforeRetry) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRunApp 0"); + // The sensor was started again behind the hub's back + this->feed_("setRunApp 0\r\nsensor is not stopped\r\nDFRobot:/>"); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + EXPECT_EQ(this->next_command_(), "setUartOutput 1 1 1 1.0"); +} + +TEST_F(SEN0609SequencerTest, AlreadyStartedCountsAsStarted) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + // As left by an earlier sequence that failed + this->hub_.status_set_warning(); + ASSERT_EQ(this->next_command_(), "sensorStart"); + this->feed_("sensorStart\r\nsensor started already\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, PresenceFramesKeepConfiguration) { + this->boot_(); + for (int i = 0; i < 15; i++) { + this->feed_("$DFHPD,0, , , *\r\n"); + this->run_(1000); + } + EXPECT_EQ(this->uart_.tx, ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, MissingPresenceFramesReapplyConfiguration) { + this->boot_(); + this->feed_("$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); + EXPECT_EQ(this->next_command_(9000), ""); + // The module restarted on its own and stopped sending frames + ASSERT_EQ(this->next_command_(), "sensorStop"); + EXPECT_FALSE(this->has_target_.has_state()); + EXPECT_TRUE(this->hub_.status_has_warning()); + this->answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +} // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml b/tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml new file mode 100644 index 00000000000..969abd67776 --- /dev/null +++ b/tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + dfrobot_sen0609: !include common-speed.yaml diff --git a/tests/components/dfrobot_sen0609/test.esp32-idf.yaml b/tests/components/dfrobot_sen0609/test.esp32-idf.yaml new file mode 100644 index 00000000000..e3b03f3e4e1 --- /dev/null +++ b/tests/components/dfrobot_sen0609/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + dfrobot_sen0609: !include common.yaml diff --git a/tests/components/dfrobot_sen0609/test.esp8266-ard.yaml b/tests/components/dfrobot_sen0609/test.esp8266-ard.yaml new file mode 100644 index 00000000000..57fa8a65364 --- /dev/null +++ b/tests/components/dfrobot_sen0609/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + dfrobot_sen0609: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml new file mode 100644 index 00000000000..e1805f81c5d --- /dev/null +++ b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml @@ -0,0 +1,56 @@ +esphome: + name: uart-mock-dfrobot-sen0609 + +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 DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 9600 + # A small model of the module's command line: echo, answer, Done and the prompt + on_tx: + - then: + - lambda: |- + std::string cmd(data.begin(), data.end()); + if (cmd == "\r\n") + return; + std::string reply = cmd + "\r\n"; + if (cmd == "getSWV") + reply += "SoftwareVersion:V1.0.4\r\n"; + reply += "Done\r\nDFRobot:/>"; + id(mock_uart).inject_to_rx_buffer(std::vector(reply.begin(), reply.end())); + +interval: + - interval: 200ms + then: + - lambda: |- + // '$' is kept apart so it is not read as a substitution + static const std::string FRAME = std::string(1, '$') + "DFHPD,1, , , *\r\n"; + id(mock_uart).inject_to_rx_buffer(std::vector(FRAME.begin(), FRAME.end())); + +dfrobot_sen0609: + id: sen0609 + uart_id: mock_uart + +binary_sensor: + - platform: dfrobot_sen0609 + has_target: + name: Has Target + +text_sensor: + - platform: dfrobot_sen0609 + version: + name: Version diff --git a/tests/integration/test_uart_mock_dfrobot_sen0609.py b/tests/integration/test_uart_mock_dfrobot_sen0609.py new file mode 100644 index 00000000000..1146e27d27f --- /dev/null +++ b/tests/integration/test_uart_mock_dfrobot_sen0609.py @@ -0,0 +1,57 @@ +"""Integration test for the DFRobot SEN0609 component against a model of the module's command line. + +Checks the boot sequence: the software version is read and presence frames are parsed. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import BinarySensorInfo, EntityState, TextSensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_dfrobot_sen0609( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Boot reads the software version; presence frames reach the binary sensor.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + states: dict[int, EntityState] = {} + + async def wait_for(condition, what: str) -> None: + try: + async with asyncio.timeout(10.0): + while not condition(): + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"Timeout waiting for {what}; states: {states}") + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + has_target = require_entity(entities, "has_target", BinarySensorInfo) + version = require_entity(entities, "version", TextSensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity): + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + await wait_for(lambda: state_of(has_target) is True, "presence") + await wait_for(lambda: state_of(version) == "V1.0.4", "software version") From 108ed67d3159d4a0a01acca53917c99265e2cf08 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:30:25 -0400 Subject: [PATCH 432/651] [rx8111] Add basic RX8111CE real-time clock support (#20785) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/rx8111/__init__.py | 4 + esphome/components/rx8111/rx8111.cpp | 143 ++++++++++++++++ esphome/components/rx8111/rx8111.h | 20 +++ esphome/components/rx8111/time.py | 36 ++++ tests/components/rx8111/__init__.py | 7 + tests/components/rx8111/common.yaml | 10 ++ tests/components/rx8111/test.esp32-ard.yaml | 3 + tests/components/rx8111/test.esp32-idf.yaml | 3 + tests/components/rx8111/test.esp8266-ard.yaml | 3 + .../rx8111/test.nrf52-adafruit.yaml | 3 + tests/components/rx8111/test.rp2040-ard.yaml | 3 + tests/components/rx8111/test_rx8111.cpp | 160 ++++++++++++++++++ .../components/rx8111/test_rx8111.py | 38 +++++ 14 files changed, 434 insertions(+) create mode 100644 esphome/components/rx8111/__init__.py create mode 100644 esphome/components/rx8111/rx8111.cpp create mode 100644 esphome/components/rx8111/rx8111.h create mode 100644 esphome/components/rx8111/time.py create mode 100644 tests/components/rx8111/__init__.py create mode 100644 tests/components/rx8111/common.yaml create mode 100644 tests/components/rx8111/test.esp32-ard.yaml create mode 100644 tests/components/rx8111/test.esp32-idf.yaml create mode 100644 tests/components/rx8111/test.esp8266-ard.yaml create mode 100644 tests/components/rx8111/test.nrf52-adafruit.yaml create mode 100644 tests/components/rx8111/test.rp2040-ard.yaml create mode 100644 tests/components/rx8111/test_rx8111.cpp create mode 100644 tests/unit_tests/components/rx8111/test_rx8111.py diff --git a/CODEOWNERS b/CODEOWNERS index 46b94954fdc..3cbf36777c0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -510,6 +510,7 @@ esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco esphome/components/rx8025t/* @remcom +esphome/components/rx8111/* @techdregs esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/samsung/* @jorofi diff --git a/esphome/components/rx8111/__init__.py b/esphome/components/rx8111/__init__.py new file mode 100644 index 00000000000..61505825072 --- /dev/null +++ b/esphome/components/rx8111/__init__.py @@ -0,0 +1,4 @@ +"""Epson RX8111CE real-time clock.""" + +CODEOWNERS = ["@techdregs"] +DOMAIN = "rx8111" diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp new file mode 100644 index 00000000000..b3f26cb2611 --- /dev/null +++ b/esphome/components/rx8111/rx8111.cpp @@ -0,0 +1,143 @@ +#include "rx8111.h" +#include "esphome/core/log.h" + +namespace esphome::rx8111 { + +ESPHOME_LOG_TAG(TAG, "rx8111"); + +static constexpr uint8_t RX8111_REG_SEC = 0x10; +static constexpr uint8_t RX8111_REG_FLAG = 0x1E; +static constexpr uint8_t RX8111_REG_CONTROL = 0x1F; +static constexpr uint8_t RX8111_FLAG_POR = 0x80; +static constexpr uint8_t RX8111_FLAG_VLF = 0x02; +static constexpr uint8_t RX8111_FLAG_XST = 0x01; +static constexpr uint8_t RX8111_CTRL_STOP = 0x01; +// FLAG bits are write-zero-to-clear; keep UF/TF/AF/EVF even if they latch during the write. +static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C; + +static constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } +static constexpr uint8_t uint8_to_bcd(uint8_t val) { return ((val / 10) << 4) | (val % 10); } + +void RX8111Component::setup() { this->read_time(); } + +void RX8111Component::update() { this->read_time(); } + +void RX8111Component::dump_config() { + ESP_LOGCONFIG(TAG, "RX8111:"); + LOG_I2C_DEVICE(this); + RealTimeClock::dump_config(); +} + +void RX8111Component::read_time() { + uint8_t flags; + if (!this->read_byte(RX8111_REG_FLAG, &flags)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) { + ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time"); + this->status_set_warning(LOG_STR("RTC time invalidated; write trusted time")); + return; + } + + uint8_t buf[7]; + // I2C access freezes the RX8111 counter, yielding a coherent calendar snapshot. + if (!this->read_bytes(RX8111_REG_SEC, buf, sizeof(buf))) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF}; + for (size_t i = 0; i < sizeof(buf); i++) { + if (i == 3) + continue; // Weekday uses one-hot encoding, not BCD. + buf[i] &= CALENDAR_MASKS[i]; + if ((buf[i] & 0x0F) > 9 || (buf[i] >> 4) > 9) { + ESP_LOGW(TAG, "Invalid BCD in RTC time"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + } + const uint8_t onehot = buf[3]; + if (onehot == 0 || onehot > 0x40 || (onehot & (onehot - 1)) != 0) { + ESP_LOGW(TAG, "Invalid RTC weekday"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + const uint8_t weekday = static_cast(__builtin_ctz(onehot) + 1); + ESPTime rtc_time{ + .second = bcd_to_uint8(buf[0]), + .minute = bcd_to_uint8(buf[1]), + .hour = bcd_to_uint8(buf[2]), + .day_of_week = weekday, + .day_of_month = bcd_to_uint8(buf[4]), + .month = bcd_to_uint8(buf[5]), + .year = static_cast(bcd_to_uint8(buf[6]) + 2000), + .is_dst = false, + .timestamp = 0, + }; + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) { + ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + if (ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week != weekday) { + ESP_LOGW(TAG, "RTC weekday does not match calendar date"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + + this->status_clear_warning(); + ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month, + rtc_time.hour, rtc_time.minute, rtc_time.second); + time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); +} + +void RX8111Component::write_time() { + auto now = time::RealTimeClock::utcnow(); + if (!now.is_valid() || now.year > 2099) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC"); + return; + } + if (!this->set_stop_bit_(true)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + uint8_t buf[7]; + buf[0] = uint8_to_bcd(now.second); + buf[1] = uint8_to_bcd(now.minute); + buf[2] = uint8_to_bcd(now.hour); + buf[3] = 1 << (now.day_of_week - 1); + buf[4] = uint8_to_bcd(now.day_of_month); + buf[5] = uint8_to_bcd(now.month); + buf[6] = uint8_to_bcd(static_cast(now.year - 2000)); + const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf)); + // Always try to restart the counter, even after a failed calendar write. + const bool restarted = this->set_stop_bit_(false); + if (!written || !restarted) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, + now.minute, now.second); +} + +bool RX8111Component::set_stop_bit_(bool stop) { + uint8_t control; + if (!this->read_byte(RX8111_REG_CONTROL, &control)) + return false; + if (stop) { + control |= RX8111_CTRL_STOP; + } else { + control &= ~RX8111_CTRL_STOP; + } + return this->write_byte(RX8111_REG_CONTROL, control); +} + +} // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/rx8111.h b/esphome/components/rx8111/rx8111.h new file mode 100644 index 00000000000..9405d362b0a --- /dev/null +++ b/esphome/components/rx8111/rx8111.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::rx8111 { + +class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + void read_time(); + void write_time(); + + protected: + bool set_stop_bit_(bool stop); +}; + +} // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/time.py b/esphome/components/rx8111/time.py new file mode 100644 index 00000000000..0b166807a22 --- /dev/null +++ b/esphome/components/rx8111/time.py @@ -0,0 +1,36 @@ +"""Epson RX8111CE time platform.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@techdregs"] +DEPENDENCIES = ["i2c"] + +rx8111_ns = cg.esphome_ns.namespace("rx8111") +RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice) + +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(RX8111Component)} +).extend(i2c.i2c_device_schema(0x32)) + +RX8111_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(RX8111Component)} +) + +automation.register_apply_action( + "rx8111.write_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) +automation.register_apply_action( + "rx8111.read_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("read_time()") +) + + +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) + await time.register_time(var, config) diff --git a/tests/components/rx8111/__init__.py b/tests/components/rx8111/__init__.py new file mode 100644 index 00000000000..ad1f584730f --- /dev/null +++ b/tests/components/rx8111/__init__.py @@ -0,0 +1,7 @@ +"""Load the RX8111 time platform's dependencies in the C++ test build.""" + +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/rx8111/common.yaml b/tests/components/rx8111/common.yaml new file mode 100644 index 00000000000..c53dcad6606 --- /dev/null +++ b/tests/components/rx8111/common.yaml @@ -0,0 +1,10 @@ +esphome: + on_boot: + then: + - rx8111.read_time: rtc + - rx8111.write_time: rtc + +time: + - platform: rx8111 + id: rtc + i2c_id: i2c_bus diff --git a/tests/components/rx8111/test.esp32-ard.yaml b/tests/components/rx8111/test.esp32-ard.yaml new file mode 100644 index 00000000000..9fc690ff826 --- /dev/null +++ b/tests/components/rx8111/test.esp32-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.esp32-idf.yaml b/tests/components/rx8111/test.esp32-idf.yaml new file mode 100644 index 00000000000..5368401cdae --- /dev/null +++ b/tests/components/rx8111/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.esp8266-ard.yaml b/tests/components/rx8111/test.esp8266-ard.yaml new file mode 100644 index 00000000000..0d9801376df --- /dev/null +++ b/tests/components/rx8111/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.nrf52-adafruit.yaml b/tests/components/rx8111/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..47fa151be55 --- /dev/null +++ b/tests/components/rx8111/test.nrf52-adafruit.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.rp2040-ard.yaml b/tests/components/rx8111/test.rp2040-ard.yaml new file mode 100644 index 00000000000..4e7128f3b2a --- /dev/null +++ b/tests/components/rx8111/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test_rx8111.cpp b/tests/components/rx8111/test_rx8111.cpp new file mode 100644 index 00000000000..15a7b75c5db --- /dev/null +++ b/tests/components/rx8111/test_rx8111.cpp @@ -0,0 +1,160 @@ +#include + +#include +#include +#include + +#include "esphome/components/rx8111/rx8111.h" + +namespace esphome::rx8111::testing { + +static constexpr uint8_t ADDRESS = 0x32; +static constexpr uint8_t SEC = 0x10; +static constexpr uint8_t WEEK = 0x13; +static constexpr uint8_t DAY = 0x14; +static constexpr uint8_t MONTH = 0x15; +static constexpr uint8_t FLAG = 0x1E; +static constexpr uint8_t CONTROL = 0x1F; + +class RegisterBus final : public i2c::I2CBus { + public: + std::array regs{}; + std::vector writes; + int fail_read{-1}; + int fail_write{-1}; + bool latch_event_before_flag_write{false}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *out, size_t out_size, uint8_t *in, + size_t in_size) override { + EXPECT_EQ(address, ADDRESS); + EXPECT_GT(out_size, 0); + if (out_size == 0) + return i2c::ERROR_TIMEOUT; + const uint8_t reg = out[0]; + if (in_size > 0) { + if (reg == this->fail_read) + return i2c::ERROR_TIMEOUT; + for (size_t i = 0; i < in_size; i++) + in[i] = this->regs[reg + i]; + } else { + if (reg == this->fail_write) + return i2c::ERROR_TIMEOUT; + this->writes.push_back(reg); + if (reg == FLAG) { + if (this->latch_event_before_flag_write) + this->regs[FLAG] |= 0x04; // EVF latches immediately before FLAG write. + // RX8111 flags are write-zero-to-clear; writing one leaves the bit unchanged. + this->regs[FLAG] &= out[1]; + } else { + for (size_t i = 1; i < out_size; i++) + this->regs[reg + i - 1] = out[i]; + } + } + return i2c::ERROR_OK; + } +}; + +class RX8111Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { this->syncs_++; }); + } + + RegisterBus bus_; + RX8111Component rtc_; + unsigned syncs_{0}; +}; + +TEST_F(RX8111Test, WritesCalendarAndPreservesEventFlagsWithoutReadModifyWrite) { + this->bus_.regs[FLAG] = 0xB8; // POR + existing UF/TF/AF. + this->bus_.regs[CONTROL] = 0x5C; + this->bus_.latch_event_before_flag_write = true; + const auto before = ESPTime::from_epoch_utc(::time(nullptr)); + this->rtc_.write_time(); + const auto after = ESPTime::from_epoch_utc(::time(nullptr)); + EXPECT_EQ(this->bus_.regs[CONTROL], 0x5C); + EXPECT_EQ(this->bus_.regs[FLAG], 0x3C); // The newly latched EVF survives too. + auto bcd = [](int value) { return (value / 10) * 16 + value % 10; }; + EXPECT_TRUE((this->bus_.regs[SEC] == bcd(before.second) && this->bus_.regs[DAY] == bcd(before.day_of_month)) || + (this->bus_.regs[SEC] == bcd(after.second) && this->bus_.regs[DAY] == bcd(after.day_of_month))); + EXPECT_TRUE(this->bus_.regs[WEEK] == (1 << (before.day_of_week - 1)) || + this->bus_.regs[WEEK] == (1 << (after.day_of_week - 1))); + EXPECT_EQ(this->bus_.writes, (std::vector{CONTROL, SEC, CONTROL, FLAG})); +} + +TEST_F(RX8111Test, RejectsInvalidFlagsBCDWeekdayAndCalendar) { + this->bus_.regs[FLAG] = 0x83; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.regs[FLAG], 0); + this->bus_.regs[FLAG] = 0x83; + this->bus_.writes.clear(); + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); + EXPECT_TRUE(this->bus_.writes.empty()); + this->bus_.regs[FLAG] = 0; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_FALSE(this->rtc_.status_has_warning()); + const uint8_t valid_weekday = this->bus_.regs[WEEK]; + const uint8_t valid_day = this->bus_.regs[DAY]; + const uint8_t valid_month = this->bus_.regs[MONTH]; + const uint8_t wrong_weekday = valid_weekday == 1 ? 2 : 1; + for (uint8_t weekday : {uint8_t{0x00}, uint8_t{0x03}, uint8_t{0x80}, wrong_weekday}) { + this->bus_.regs[WEEK] = weekday; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1) << "weekday " << unsigned(weekday); + EXPECT_TRUE(this->rtc_.status_has_warning()); + } + this->bus_.regs[WEEK] = valid_weekday; + this->bus_.regs[DAY] = 0x32; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[DAY] = 0x31; + this->bus_.regs[MONTH] = 0x04; // April 31 is always invalid. + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[DAY] = valid_day; + this->bus_.regs[MONTH] = valid_month; + this->bus_.regs[SEC] = 0x6A; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[SEC] = 0x60; // ESPTime permits leap seconds, but RX8111 only counts 00..59. + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[SEC] = 0x00; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 2); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, I2CFailuresDoNotSyncOrLeaveTheCounterStopped) { + this->bus_.fail_read = FLAG; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = SEC; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + this->bus_.fail_read = -1; + this->bus_.fail_write = CONTROL; + this->rtc_.write_time(); + EXPECT_TRUE(this->bus_.writes.empty()); + this->bus_.fail_write = SEC; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.writes, (std::vector{CONTROL, CONTROL})); + EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0); + this->bus_.fail_write = FLAG; + this->bus_.regs[FLAG] = 0x83; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.regs[FLAG], 0x83); + EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0); +} + +} // namespace esphome::rx8111::testing diff --git a/tests/unit_tests/components/rx8111/test_rx8111.py b/tests/unit_tests/components/rx8111/test_rx8111.py new file mode 100644 index 00000000000..48b35d43e08 --- /dev/null +++ b/tests/unit_tests/components/rx8111/test_rx8111.py @@ -0,0 +1,38 @@ +"""Basic RX8111 configuration regressions.""" + +from pathlib import Path + +import pytest + +from esphome.config import validate_config +from esphome.core import CORE + + +def configuration(tmp_path: Path, actions: list | None = None) -> dict: + return { + "esphome": {"name": "rx8111-clock-test", "build_path": str(tmp_path / "build")}, + "esp32": {"board": "esp32dev", "framework": {"type": "esp-idf"}}, + "logger": {}, + "i2c": {"sda": 21, "scl": 22, "scan": False}, + "time": [{"platform": "rx8111", "id": "rtc"}], + "script": [ + { + "id": "clock_actions", + "then": actions or [{"rx8111.read_time": {"id": "rtc"}}], + } + ], + } + + +def validate_rx8111(tmp_path: Path, config: dict): + CORE.reset() + CORE.config_path = tmp_path / "test.yaml" + return validate_config(config, {}) + + +@pytest.mark.parametrize("action", ["rx8111.read_time", "rx8111.write_time"]) +def test_actions(tmp_path: Path, action: str) -> None: + for payload in ({}, {"id": "rtc"}): + assert not validate_rx8111( + tmp_path, configuration(tmp_path, [{action: payload}]) + ).errors From 45ea5f422080d7e3ee09abc9d99fc639add45ca6 Mon Sep 17 00:00:00 2001 From: Adminy <22717869+adminy@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:03:53 +0100 Subject: [PATCH 433/651] [nrf52] nrf52840dongle support (#19009) 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/nrf52/__init__.py | 338 ++++++++++++------ esphome/components/nrf52/boards.py | 22 ++ esphome/components/nrf52/const.py | 1 + esphome/components/nrf52/framework.py | 93 +++++ .../components/zephyr_mcumgr/ota/__init__.py | 4 +- .../nrf52/test.nrf52-nrf52840dongle.yaml | 3 + .../zigbee/test.nrf52-nrf52840dongle.yaml | 2 + ..._components_base.nrf52-nrf52840dongle.yaml | 16 + tests/unit_tests/test_download_types.py | 8 +- tests/unit_tests/test_espidf_toolchain.py | 4 +- tests/unit_tests/test_nrf52_config.py | 101 +++++- tests/unit_tests/test_nrf52_nrfutil.py | 157 ++++++++ tests/unit_tests/test_nrf52_upload.py | 240 +++++++++++-- 13 files changed, 836 insertions(+), 153 deletions(-) create mode 100644 tests/components/nrf52/test.nrf52-nrf52840dongle.yaml create mode 100644 tests/components/zigbee/test.nrf52-nrf52840dongle.yaml create mode 100644 tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml create mode 100644 tests/unit_tests/test_nrf52_nrfutil.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index fcdffc0c645..b0c375eb53d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -40,6 +40,8 @@ from esphome.const import ( CONF_ENABLE_OTA_ROLLBACK, CONF_FRAMEWORK, CONF_ID, + CONF_INVERTED, + CONF_NUMBER, CONF_OTA, CONF_RESET_PIN, CONF_SAFE_MODE, @@ -66,15 +68,17 @@ from esphome.helpers import rmtree, write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG +from .boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG, SELF_RESET_PINS from .const import ( BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + BOOTLOADER_NRF, ) from .framework import ( check_and_install, + ensure_nordic_nrfutil, get_build_env, get_build_paths, setup_platformio_python_env, @@ -192,13 +196,16 @@ def set_framework(config: ConfigType) -> ConfigType: )(config) -BOOTLOADERS = [ +# Bootloaders flashed over USB serial DFU: the Adafruit ones with adafruit-nrfutil, the factory Open +# DFU bootloader with Nordic's nrfutil; mcuboot uses mcumgr instead +SERIAL_DFU_BOOTLOADERS = ( BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - BOOTLOADER_MCUBOOT, -] + BOOTLOADER_NRF, +) +BOOTLOADERS = [*SERIAL_DFU_BOOTLOADERS, BOOTLOADER_MCUBOOT] _validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) @@ -250,6 +257,18 @@ _DFU_SCHEMA = cv.Schema( ) +def _default_self_reset_pin(config: ConfigType) -> ConfigType: + # The factory bootloader ignores the retained register the touch sets, so a board with a GPIO + # wired to RESET resets itself through that pin + dfu = config.get(CONF_DFU) + number = SELF_RESET_PINS.get(config[CONF_BOARD]) + if dfu is not None and number and CONF_RESET_PIN not in dfu: + dfu[CONF_RESET_PIN] = pins.gpio_output_pin_schema( + {CONF_NUMBER: number, CONF_INVERTED: True} + ) + return config + + def _dfu_schema(value: bool | ConfigType) -> ConfigType: if isinstance(value, bool): if not value: @@ -300,6 +319,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), } ), + _default_self_reset_pin, _resolve_toolchain, set_framework, ) @@ -311,6 +331,15 @@ def _validate_mcumgr(config): raise cv.Invalid(f"'{bootloader}' bootloader does not support DFU") +def _validate_open_dfu_toolchain(config: ConfigType) -> None: + # The deprecated PlatformIO toolchain configures every non-mcuboot upload for a touch-reset + # bootloader, which the factory Open DFU bootloader is not + if config[KEY_BOOTLOADER] == BOOTLOADER_NRF and CORE.using_toolchain_platformio: + raise cv.Invalid( + f"'{BOOTLOADER_NRF}' bootloader requires 'toolchain: {Toolchain.SDK_NRF}'" + ) + + def _final_validate(config): # Remove before 2027.2.0 @@ -319,6 +348,7 @@ def _final_validate(config): "The 'platformio' toolchain for nRF52 is deprecated and will be removed in ESPHome 2027.2.0. " "Please use 'toolchain: sdk-nrf' instead." ) + _validate_open_dfu_toolchain(config) if CONF_DFU in config: _validate_mcumgr(config) @@ -470,6 +500,15 @@ async def to_code(config: ConfigType) -> None: }; """ ) + if config[CONF_BOARD] == "nrf52840dongle": + # The board DTS enables uart0 on edge GPIOs (P0.17, P0.22, P0.24) although nothing is wired to it + zephyr_add_overlay( + """ + &uart0 { + status = "disabled"; + }; + """ + ) zephyr_add_prj_conf("REBOOT", True) # some boards enable USB and UART by default. @@ -535,12 +574,12 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: return [] types = [] UF2_PATH = "zephyr/zephyr.uf2" - DFU_PATH = "firmware.zip" HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1 without OTA, SDK 3.4.0+ HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2 to 3.3.x, always generated APP_IMAGE_PATH = "zephyr/app_update.bin" build_dir = Path(storage_json.firmware_bin_path).parent - if (build_dir / UF2_PATH).is_file(): + has_uf2 = (build_dir / UF2_PATH).is_file() + if has_uf2: types = [ { "title": "UF2 package (recommended)", @@ -548,12 +587,6 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: "file": UF2_PATH, "download": f"{storage_json.name}.uf2", }, - { - "title": "DFU package", - "description": "For flashing via adafruit-nrfutil using USB CDC.", - "file": DFU_PATH, - "download": f"dfu-{storage_json.name}.zip", - }, ] else: types = [ @@ -568,6 +601,16 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: "download": f"{storage_json.name}.hex", }, ] + # Bootloaders without a UF2 family (the dongle's Open DFU bootloader) still get a DFU package + if (build_dir / DFU_PACKAGE).is_file(): + types += [ + { + "title": "DFU package", + "description": "For flashing via nrfutil using USB CDC.", + "file": DFU_PACKAGE, + "download": f"dfu-{storage_json.name}.zip", + }, + ] if (build_dir / APP_IMAGE_PATH).is_file(): types += [ { @@ -592,6 +635,105 @@ def _upload_using_platformio( return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) +# USB vendor and product id the nRF52840 Dongle enumerates with while its Open DFU bootloader runs +OPEN_DFU_USB_ID = (0x1915, 0x521F) +DFU_PACKAGE = "firmware.zip" # the serial DFU package, next to the firmware in the build directory + + +def _reset_into_bootloader(host: str) -> None: + """Send the 1200 bps touch that resets an Adafruit bootloader into DFU mode.""" + import time as _time + + import serial as _serial + import serial.tools.list_ports as _list_ports + + try: + ser = _serial.Serial(host, baudrate=1200, timeout=1) + ser.close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + + # Wait for device to reset (port disappears) + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host not in {p.device for p in _list_ports.comports()}: + break + else: + _LOGGER.warning( + "Device did not leave %s within 5 s; it may not have entered bootloader mode", + host, + ) + + # Wait for DFU port to reappear + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host in {p.device for p in _list_ports.comports()}: + break + else: + raise EsphomeError( + f"DFU port {host!r} did not reappear within 10 s. " + "Check that the device entered DFU mode." + ) + + +def _wait_for_port_permissions(host: str) -> None: + """Give udev a moment to set up the re-enumerated port before using it.""" + import time as _time + + from esphome.__main__ import check_permissions + + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + try: + check_permissions(host) + return + except EsphomeError: + _time.sleep(0.05) + check_permissions(host) # raises with helpful message + + +def _open_dfu_ports() -> set[str]: + import serial.tools.list_ports as _list_ports + + return { + port.device + for port in _list_ports.comports(include_links=True) + if (port.vid, port.pid) == OPEN_DFU_USB_ID + } + + +def _enter_open_dfu(host: str) -> str: + """The port of the Open DFU bootloader: ``host`` when it is one already, otherwise the one that + appears after the 1200 bps touch reboots an application built with ``dfu:`` into it. The + bootloader is its own USB device, so it never comes back under the application's path, and + only a port that was not there before the touch counts, not another dongle's bootloader.""" + import time as _time + + import serial as _serial + + before = _open_dfu_ports() + if host in before: + return host + try: + _serial.Serial(host, baudrate=1200, timeout=1).close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if appeared := _open_dfu_ports() - before: + port = min(appeared) + _wait_for_port_permissions(port) + return port + raise EsphomeError( + f"{host} did not enter the Open DFU bootloader. An application built with 'dfu:' enters it " + "on upload; otherwise press the RESET button on the side of the dongle (the red LED pulses " + "while the bootloader runs) and upload again." + ) + + def upload_program(config: ConfigType, args, host: str) -> bool: from esphome.__main__ import check_permissions from esphome.upload_targets import PortType, get_port_type @@ -610,97 +752,57 @@ def upload_program(config: ConfigType, args, host: str) -> bool: if get_port_type(host) == PortType.SERIAL: check_permissions(host) - if zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader == BOOTLOADER_MCUBOOT: mcumgr_device = host + elif CORE.using_toolchain_platformio: + result = _upload_using_platformio(config, host, ["-t", "upload"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True + elif bootloader not in SERIAL_DFU_BOOTLOADERS: + raise EsphomeError( + f"Serial upload is not implemented for the {bootloader} bootloader" + ) else: - if not CORE.using_toolchain_platformio: - bootloader = zephyr_data()[KEY_BOOTLOADER] - if bootloader not in ( - BOOTLOADER_ADAFRUIT, - BOOTLOADER_ADAFRUIT_NRF52_SD132, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - ): - raise EsphomeError("Not implemented yet") + env = get_build_env(None) # no compile, just the DFU tool + dfu_package = CORE.relative_pioenvs_path(CORE.name) / DFU_PACKAGE + if not dfu_package.is_file(): + raise EsphomeError("Firmware not found. Please compile first.") + if bootloader == BOOTLOADER_NRF: + # The factory Open DFU bootloader speaks Nordic's Secure DFU protocol + dfu_cmd = [ + ensure_nordic_nrfutil(env), + "nrf5sdk-tools", + "dfu", + "usb-serial", + "-pkg", + str(dfu_package), + "-p", + _enter_open_dfu(host), + ] + else: check_and_install() paths = get_build_paths() - env = get_build_env(None) # no compile, just nrfutil - build_dir = CORE.relative_pioenvs_path(CORE.name) - dfu_package = build_dir / "firmware.zip" - if not dfu_package.is_file(): - raise EsphomeError("Firmware not found. Please compile first.") - import time as _time - - import serial as _serial - import serial.tools.list_ports as _list_ports - - try: - ser = _serial.Serial(host, baudrate=1200, timeout=1) - ser.close() - except _serial.SerialException as err: - raise EsphomeError(f"Failed to open {host}: {err}") from err - - # Wait for device to reset (port disappears) - deadline = _time.monotonic() + 5 - while _time.monotonic() < deadline: - _time.sleep(0.1) - if host not in {p.device for p in _list_ports.comports()}: - break - else: - _LOGGER.warning( - "Device did not leave %s within 5 s; " - "it may not have entered bootloader mode", - host, - ) - - # Wait for DFU port to reappear - deadline = _time.monotonic() + 10 - while _time.monotonic() < deadline: - _time.sleep(0.1) - if host in {p.device for p in _list_ports.comports()}: - break - else: - raise EsphomeError( - f"DFU port {host!r} did not reappear within 10 s. " - "Check that the device entered DFU mode." - ) - - # Wait for udev to finish setting up device permissions - deadline = _time.monotonic() + 5 - while _time.monotonic() < deadline: - try: - check_permissions(host) - break - except EsphomeError: - _time.sleep(0.05) - else: - check_permissions(host) # raises with helpful message - - python = str(paths["python_executable"]) - if not run_command_ok( - [ - python, - "-m", - "nordicsemi.__main__", - "dfu", - "serial", - "-pkg", - str(dfu_package), - "-p", - host, - "-b", - "115200", - "--singlebank", - ], - env=env, - stream_output=True, - ): - raise EsphomeError("nRF52 serial DFU upload failed") - else: - result = _upload_using_platformio(config, host, ["-t", "upload"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: serial upload + _reset_into_bootloader(host) + _wait_for_port_permissions(host) + dfu_cmd = [ + str(paths["python_executable"]), + "-m", + "nordicsemi.__main__", + "dfu", + "serial", + "-pkg", + str(dfu_package), + "-p", + host, + "-b", + "115200", + "--singlebank", + ] + if not run_command_ok(dfu_cmd, env=env, stream_output=True): + raise EsphomeError("nRF52 serial DFU upload failed") + return True if host == "PYOCD": if not CORE.using_toolchain_platformio: @@ -1099,13 +1201,35 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError(f"Failed to generate UF2 from {hex_file.name}") - if bootloader in ( - BOOTLOADER_ADAFRUIT, - BOOTLOADER_ADAFRUIT_NRF52_SD132, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - ): - dfu_package = build_dir / "firmware.zip" + # The serial DFU package follows the hex it is made from; a package from another bootloader's + # build would otherwise be offered for download with this one + dfu_package = build_dir / DFU_PACKAGE + if bootloader not in SERIAL_DFU_BOOTLOADERS: + dfu_package.unlink(missing_ok=True) + elif bootloader == BOOTLOADER_NRF: + # The factory Open DFU bootloader speaks Nordic's Secure DFU protocol, which only Nordic's own + # tool packages + if not run_command_ok( + [ + ensure_nordic_nrfutil(env), + "nrf5sdk-tools", + "pkg", + "generate", + "--hw-version", + "52", + "--sd-req", + "0x00", + "--application-version", + "1", + "--application", + str(zephyr_dir / "zephyr.hex"), + str(dfu_package), + ], + env=env, + stream_output=True, + ): + raise EsphomeError("Failed to create the DFU package") + else: genpkg_cmd = [ str(paths["python_executable"]), "-m", @@ -1118,6 +1242,6 @@ def run_compile(args, config: ConfigType) -> bool: genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req] genpkg_cmd += ["--application", str(hex_file), str(dfu_package)] if not run_command_ok(genpkg_cmd, env=env, stream_output=True): - raise EsphomeError("Failed to create adafruit DFU package") + raise EsphomeError("Failed to create the DFU package") return True diff --git a/esphome/components/nrf52/boards.py b/esphome/components/nrf52/boards.py index 564bf560d69..3f5d9d1b0e3 100644 --- a/esphome/components/nrf52/boards.py +++ b/esphome/components/nrf52/boards.py @@ -6,6 +6,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + BOOTLOADER_NRF, ) BOARDS_ZEPHYR = { @@ -33,8 +34,24 @@ BOARDS_ZEPHYR = { BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ] }, + # Nordic nRF52840 Dongle (PCA10059): factory Open DFU bootloader at 0xE0000, kept in place + "nrf52840dongle": {KEY_BOOTLOADER: [BOOTLOADER_NRF]}, } +# Boards with a GPIO wired to their RESET pin: driving it low is a pin reset, which the factory +# bootloader takes as its cue to stay in DFU mode (Nordic's own USB DFU trigger does the same) +SELF_RESET_PINS = { + "nrf52840dongle": "P0.19", +} + +# Bootloaders whose layout has a SoftDevice and the Adafruit bootloader, between which the mcumgr OTA +# image slot is placed +MCUMGR_OTA_BOOTLOADERS = ( + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, +) + # https://github.com/ffenix113/zigbee_home/blob/17bb7b9e9d375e756da9e38913f53303937fb66a/types/board/known_boards.go # https://learn.adafruit.com/introducing-the-adafruit-nrf52840-feather?view=all#hathach-memory-map BOOTLOADER_CONFIG = { @@ -50,4 +67,9 @@ BOOTLOADER_CONFIG = { Section("SoftDevice", 0x0, 0x27000, "flash_primary"), Section("Adafruit_nRF52_Bootloader", 0xF4000, 0xC000, "flash_primary"), ], + # The board's BOARD_HAS_NRF5_BOOTLOADER already reserves the MBR page at 0x0, so the application + # links at 0x1000, where the bootloader loads it; only the bootloader itself is reserved here + BOOTLOADER_NRF: [ + Section("open_bootloader", 0xE0000, 0x20000, "flash_primary"), + ], } diff --git a/esphome/components/nrf52/const.py b/esphome/components/nrf52/const.py index 977ca2252ae..166e79bacc6 100644 --- a/esphome/components/nrf52/const.py +++ b/esphome/components/nrf52/const.py @@ -2,6 +2,7 @@ BOOTLOADER_ADAFRUIT = "adafruit" BOOTLOADER_ADAFRUIT_NRF52_SD132 = "adafruit_nrf52_sd132" BOOTLOADER_ADAFRUIT_NRF52_SD140_V6 = "adafruit_nrf52_sd140_v6" BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 = "adafruit_nrf52_sd140_v7" +BOOTLOADER_NRF = "nrf" EXTRA_ADC = [ "VDD", diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 00796562f42..6c60e58462a 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -19,6 +19,7 @@ from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( create_venv, download_and_extract, + download_with_resume, get_python_env_executable_path, rmdir, run_command_ok, @@ -680,6 +681,98 @@ def _check_and_install(version: str) -> None: _install_toolchain() +# Nordic's nrfutil launcher, one static binary per host; its commands are installed from Nordic's +# package index by the launcher itself. The path carries no version, so the file is pinned by the +# size and digest Nordic's file store reported for launcher 8.2.1 (2026-09-09) +NORDIC_NRFUTIL_URL = "https://files.nordicsemi.com/artifactory/swtools/external/nrfutil/executables/{triple}/{name}" +_NRFUTIL_LAUNCHERS: dict[str, tuple[int, str]] = { + "x86_64-unknown-linux-gnu": ( + 6725408, + "1cf4bd1d3d64f43a6b9602d8d8e8d647bec0d26d517ba72fd154f95546b50260", + ), + "aarch64-unknown-linux-gnu": ( + 5506040, + "e7310f072132766e69b43c833c6a87a53367ea68d5b8a5f7c6fa1a45993ecde4", + ), + "aarch64-apple-darwin": ( + 5263376, + "1b993eece5a6e3e079ce25fa31a134ce4c12fb51735d10114a2ea4a1df048591", + ), + "x86_64-apple-darwin": ( + 6389488, + "a5ffea65325e5d38eae00f612472e18ffe2c036f800240873d9f702c1b53d945", + ), + "x86_64-pc-windows-msvc": ( + 5939032, + "22cb1bd03fc29016670c1fc8408a78bd213286ecc1156c70712485d77b097c75", + ), +} +_NRFUTIL_OS_SUFFIX = { + "linux": "unknown-linux-gnu", + "macos": "apple-darwin", + "windows": "pc-windows-msvc", +} +# The nrf5sdk-tools command gives the launcher the pkg and dfu subcommands; a new version reinstalls +NRF5SDK_TOOLS_VERSION = "1.1.0" + + +def _nrfutil_target_triple() -> str: + sysname, machine, _ = _get_toolchain_platform_info() + triple = f"{machine}-{_NRFUTIL_OS_SUFFIX.get(sysname, sysname)}" + if triple not in _NRFUTIL_LAUNCHERS: + raise EsphomeError(f"Nordic's nrfutil is not available for {sysname} {machine}") + return triple + + +def ensure_nordic_nrfutil(env: dict[str, str]) -> str: + """Nordic's nrfutil with its nrf5sdk-tools command, downloaded into the tools cache on first use. + + The launcher keeps its installed commands under NRFUTIL_HOME, which is set in ``env`` so that + the copy here never touches a user's own ~/.nrfutil. Raises EsphomeError when the download or + the command install fails, for instance without network. + """ + # The downloaded file is a bootstrap launcher: on its first run it installs the real core into + # NRFUTIL_HOME/bin and runs that, so it must not sit there itself or it runs itself forever + root = get_sdk_nrf_tools_path() / "nrfutil" + home = root / "home" + suffix = ".exe" if os.name == "nt" else "" + exe = root / "launcher" / f"nrfutil{suffix}" + command = home / "bin" / f"nrfutil-nrf5sdk-tools{suffix}" + env["NRFUTIL_HOME"] = str(home) + sentinel = root / f".ready-{NRF5SDK_TOOLS_VERSION}" + + # A cache that lost its launcher or command (a partial clean-up) is installed again + def installed() -> bool: + return sentinel.exists() and exe.is_file() and command.is_file() + + if installed(): + return str(exe) + with _install_lock("nrfutil"): + if not installed(): + exe.parent.mkdir(parents=True, exist_ok=True) + _LOGGER.info("Downloading Nordic's nrfutil ...") + triple = _nrfutil_target_triple() + size, sha256 = _NRFUTIL_LAUNCHERS[triple] + download_with_resume( + NORDIC_NRFUTIL_URL.format(triple=triple, name=exe.name), + exe, + sha256=sha256, + size=size, + ) + exe.chmod(0o755) + _LOGGER.info("Installing nrfutil's nrf5sdk-tools command ...") + if ( + not run_command_ok( + [str(exe), "install", f"nrf5sdk-tools={NRF5SDK_TOOLS_VERSION}"], + env=env, + ) + or not command.is_file() + ): + raise EsphomeError("Failed to install nrfutil's nrf5sdk-tools command") + sentinel.touch() + return str(exe) + + def _install_toolchain() -> None: toolchain_version = _get_toolchain_version() toolchains_dir = _get_toolchain_path(toolchain_version) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index ed7069a5403..44c8be9e374 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.boards import MCUMGR_OTA_BOOTLOADERS from esphome.components.nrf52.framework import include_west_project from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( @@ -102,7 +102,7 @@ def _validate_bootloader(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader == BOOTLOADER_MCUBOOT: return - if bootloader not in BOOTLOADER_CONFIG: + if bootloader not in MCUMGR_OTA_BOOTLOADERS: raise cv.Invalid(f"{bootloader} does not support OTA") framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver < cv.Version(2, 9, 2): diff --git a/tests/components/nrf52/test.nrf52-nrf52840dongle.yaml b/tests/components/nrf52/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..c65ad301f8a --- /dev/null +++ b/tests/components/nrf52/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,3 @@ +nrf52: + dcdc: False + dfu: true # the 1200 bps touch reboots into the factory bootloader diff --git a/tests/components/zigbee/test.nrf52-nrf52840dongle.yaml b/tests/components/zigbee/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..ada15caa1ec --- /dev/null +++ b/tests/components/zigbee/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,2 @@ +packages: + zigbee: !include common_nrf52.yaml diff --git a/tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml b/tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..30a65c9739d --- /dev/null +++ b/tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml @@ -0,0 +1,16 @@ +esphome: + name: componenttestnrf52 + friendly_name: $component_name + +nrf52: + board: nrf52840dongle + bootloader: nrf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py index 0f5aae444d0..b694ce74533 100644 --- a/tests/unit_tests/test_download_types.py +++ b/tests/unit_tests/test_download_types.py @@ -96,15 +96,19 @@ def _nrf52_files(tmp_path: Path, *built: str) -> list[str]: [ # Adafruit bootloader with the mcumgr OTA: MCUboot is chained behind it. ( - ["zephyr/zephyr.uf2", "zephyr/app_update.bin"], + ["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"], ["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"], ), - (["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2", "firmware.zip"]), + (["zephyr/zephyr.uf2", "firmware.zip"], ["zephyr/zephyr.uf2", "firmware.zip"]), + # The DFU package is offered only when the build made one + (["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2"]), ( ["zephyr/merged.hex", "zephyr/app_update.bin"], ["zephyr/merged.hex", "zephyr/app_update.bin"], ), (["zephyr/merged.hex"], ["zephyr/merged.hex"]), + # The dongle's Open DFU bootloader has no UF2 family, so its DFU package rides beside the HEX + (["zephyr/merged.hex", "firmware.zip"], ["zephyr/merged.hex", "firmware.zip"]), ([], ["zephyr/zephyr.hex"]), ], ) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 3cba1a7357d..43ad9131a96 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -1275,11 +1275,11 @@ def test_run_compile_stops_on_ninja_failure(setup_core: Path) -> None: @pytest.mark.parametrize("memory_ld_rc", [0, 3]) def test_run_compile_testing_mode_builds_memory_ld_first( - setup_core: Path, memory_ld_rc: int + setup_core: Path, memory_ld_rc: int, monkeypatch: pytest.MonkeyPatch ) -> None: """Testing mode builds and patches memory.ld before the main build.""" _setup_build(setup_core) - CORE.testing_mode = True + monkeypatch.setattr(CORE, "testing_mode", True) # CORE.reset() does not clear it targets: list[tuple[str, ...]] = [] def record(*run_targets: str, **kwargs: object) -> int: diff --git a/tests/unit_tests/test_nrf52_config.py b/tests/unit_tests/test_nrf52_config.py index 4d9d281ebfe..eb13a55e488 100644 --- a/tests/unit_tests/test_nrf52_config.py +++ b/tests/unit_tests/test_nrf52_config.py @@ -1,12 +1,111 @@ """Tests for the nRF52 configuration validation.""" +from collections.abc import Generator + import pytest -from esphome.components.nrf52 import _detect_bootloader +from esphome.components.nrf52 import ( + _default_self_reset_pin, + _detect_bootloader, + _validate_open_dfu_toolchain, + set_core_data, +) +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.const import BOOTLOADER_NRF +from esphome.components.zephyr.const import KEY_PM_STATIC, KEY_ZEPHYR +from esphome.components.zephyr_mcumgr.ota import _validate_bootloader +from esphome.config import path_context import esphome.config_validation as cv +from esphome.const import CONF_INVERTED, CONF_NUMBER, KEY_CORE, Toolchain +from esphome.core import CORE def test_detect_bootloader_reports_a_missing_board() -> None: """The bootloader check runs before the schema, so it reports the missing key.""" with pytest.raises(cv.Invalid, match="'board' is a required option"): _detect_bootloader({}) + + +def test_dongle_defaults_to_nrf_bootloader() -> None: + """The dongle's only bootloader is the factory one, so it is selected without a config line.""" + config = _detect_bootloader({"board": "nrf52840dongle"}) + assert config["bootloader"] == BOOTLOADER_NRF + + +def test_dongle_rejects_foreign_bootloader() -> None: + with pytest.raises(cv.Invalid, match="nrf52840dongle does not support"): + _detect_bootloader({"board": "nrf52840dongle", "bootloader": "mcuboot"}) + + +def _dongle_core_data() -> None: + CORE.data[KEY_CORE] = {} + CORE.testing_mode = ( + False # the real flash, not the larger one faked for grouped tests + ) + set_core_data({"board": "nrf52840dongle", "bootloader": BOOTLOADER_NRF}) + + +@pytest.fixture +def _path_ctx() -> Generator[None]: + """A pin schema reports its errors against the config path, so one has to be set.""" + token = path_context.set(["nrf52"]) + yield + path_context.reset(token) + + +def test_nrf_bootloader_reserves_factory_region() -> None: + """Only the Open DFU bootloader is registered as a static partition; the board's own + nrf5_mbr partition takes the first page, so the app lands at 0x1000 where the bootloader loads it.""" + _dongle_core_data() + registered = {s.name: s for s in CORE.data[KEY_ZEPHYR][KEY_PM_STATIC]} + assert {s.name for s in BOOTLOADER_CONFIG[BOOTLOADER_NRF]} <= set(registered) + assert "mbr" not in registered + assert registered["open_bootloader"].address == 0xE0000 + assert registered["open_bootloader"].end_address == 0x100000 + + +def test_nrf_bootloader_rejects_mcumgr_ota() -> None: + """OTA with mcumgr places its slot against a SoftDevice, which this layout has none of.""" + _dongle_core_data() + with pytest.raises(cv.Invalid, match="does not support OTA"): + _validate_bootloader({}) + + +def test_nrf_bootloader_accepts_the_sdk_nrf_toolchain() -> None: + CORE.toolchain = Toolchain.SDK_NRF + _validate_open_dfu_toolchain({"bootloader": BOOTLOADER_NRF}) + + +def test_nrf_bootloader_rejects_the_platformio_toolchain() -> None: + """The deprecated PlatformIO upload path assumes a touch-reset bootloader.""" + CORE.toolchain = Toolchain.PLATFORMIO + with pytest.raises(cv.Invalid, match="sdk-nrf"): + _validate_open_dfu_toolchain({"bootloader": BOOTLOADER_NRF}) + + +@pytest.mark.usefixtures("_path_ctx") +def test_dongle_dfu_resets_itself_through_the_pin_wired_to_reset() -> None: + """The factory bootloader ignores the touch's register, so P0.19 pulls RESET for it.""" + _dongle_core_data() + config = _default_self_reset_pin({"board": "nrf52840dongle", "dfu": {}}) + pin = config["dfu"]["reset_pin"] + assert pin[CONF_NUMBER] == 19 + assert pin[CONF_INVERTED] is True + + +@pytest.mark.usefixtures("_path_ctx") +def test_dfu_reset_pin_given_by_the_user_is_kept() -> None: + _dongle_core_data() + config = _default_self_reset_pin( + {"board": "nrf52840dongle", "dfu": {"reset_pin": {"number": 5}}} + ) + assert config["dfu"]["reset_pin"] == {"number": 5} + + +@pytest.mark.usefixtures("_path_ctx") +def test_other_boards_get_no_reset_pin() -> None: + _dongle_core_data() + config = _default_self_reset_pin( + {"board": "adafruit_itsybitsy_nrf52840", "dfu": {}} + ) + assert "reset_pin" not in config["dfu"] diff --git a/tests/unit_tests/test_nrf52_nrfutil.py b/tests/unit_tests/test_nrf52_nrfutil.py new file mode 100644 index 00000000000..c544dbfd8d5 --- /dev/null +++ b/tests/unit_tests/test_nrf52_nrfutil.py @@ -0,0 +1,157 @@ +"""Nordic's nrfutil is downloaded into the tools cache and keeps its commands there.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.components.nrf52 import framework +from esphome.components.nrf52.framework import ( + _NRFUTIL_LAUNCHERS, + NRF5SDK_TOOLS_VERSION, + _nrfutil_target_triple, + ensure_nordic_nrfutil, +) +from esphome.core import EsphomeError + +LINUX = ("linux", "x86_64", "tar.xz") + + +@pytest.mark.parametrize( + ("host", "triple"), + [ + (("macos", "aarch64", "tar.xz"), "aarch64-apple-darwin"), + (("macos", "x86_64", "tar.xz"), "x86_64-apple-darwin"), + (LINUX, "x86_64-unknown-linux-gnu"), + (("windows", "x86_64", "7z"), "x86_64-pc-windows-msvc"), + ], +) +def test_launcher_triple_follows_the_host( + host: tuple[str, str, str], triple: str +) -> None: + with patch.object(framework, "_get_toolchain_platform_info", return_value=host): + assert _nrfutil_target_triple() == triple + + +def test_unknown_host_is_a_clear_error() -> None: + with ( + patch.object( + framework, + "_get_toolchain_platform_info", + return_value=("freebsd", "x86_64", "tar.xz"), + ), + pytest.raises(EsphomeError, match="freebsd x86_64"), + ): + _nrfutil_target_triple() + + +# The launcher and its command carry the host's executable suffix +SUFFIX = ".exe" if os.name == "nt" else "" + + +def _fake_download(url: str, dest: Path, **kwargs: object) -> None: + dest.write_bytes(b"launcher") + + +def _fake_install(cmd: list[str], env: dict[str, str]) -> bool: + """The launcher installs its command under NRFUTIL_HOME.""" + command = Path(env["NRFUTIL_HOME"]) / "bin" / f"nrfutil-nrf5sdk-tools{SUFFIX}" + command.parent.mkdir(parents=True, exist_ok=True) + command.write_bytes(b"command") + return True + + +def test_first_use_downloads_and_installs_into_the_cache(tmp_path: Path) -> None: + env: dict[str, str] = {} + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object( + framework, "download_with_resume", side_effect=_fake_download + ) as download, + patch.object(framework, "run_command_ok", side_effect=_fake_install) as install, + ): + exe = ensure_nordic_nrfutil(env) + + # The launcher sits outside the home it installs into, and the command version is pinned + assert exe == str(tmp_path / "nrfutil" / "launcher" / f"nrfutil{SUFFIX}") + assert env["NRFUTIL_HOME"] == str(tmp_path / "nrfutil" / "home") + # The launcher comes from the host's URL and is checked against the pinned digest + size, sha256 = _NRFUTIL_LAUNCHERS["x86_64-unknown-linux-gnu"] + assert download.call_args[0][0].endswith( + f"/x86_64-unknown-linux-gnu/nrfutil{SUFFIX}" + ) + assert download.call_args[1] == {"sha256": sha256, "size": size} + assert install.call_args[0][0] == [ + exe, + "install", + f"nrf5sdk-tools={NRF5SDK_TOOLS_VERSION}", + ] + assert (tmp_path / "nrfutil" / f".ready-{NRF5SDK_TOOLS_VERSION}").exists() + + +def _installed_cache(tmp_path: Path, with_command: bool = True) -> Path: + root = tmp_path / "nrfutil" + (root / "launcher").mkdir(parents=True) + (root / "launcher" / f"nrfutil{SUFFIX}").write_bytes(b"launcher") + if with_command: + (root / "home" / "bin").mkdir(parents=True) + (root / "home" / "bin" / f"nrfutil-nrf5sdk-tools{SUFFIX}").write_bytes( + b"command" + ) + (root / f".ready-{NRF5SDK_TOOLS_VERSION}").touch() + return root + + +def test_installed_cache_is_used_without_network(tmp_path: Path) -> None: + root = _installed_cache(tmp_path) + env: dict[str, str] = {} + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "download_with_resume") as download, + patch.object(framework, "run_command_ok") as install, + ): + assert ensure_nordic_nrfutil(env) == str(root / "launcher" / f"nrfutil{SUFFIX}") + download.assert_not_called() + install.assert_not_called() + assert env["NRFUTIL_HOME"] == str(root / "home") + + +def test_a_cache_that_lost_its_command_is_installed_again(tmp_path: Path) -> None: + _installed_cache(tmp_path, with_command=False) + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object(framework, "download_with_resume", side_effect=_fake_download), + patch.object(framework, "run_command_ok", side_effect=_fake_install) as install, + ): + ensure_nordic_nrfutil({}) + install.assert_called_once() + + +def test_install_without_the_command_file_is_a_failure(tmp_path: Path) -> None: + """The launcher's exit code alone does not prove the command is in place.""" + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object(framework, "download_with_resume", side_effect=_fake_download), + patch.object(framework, "run_command_ok", return_value=True), + pytest.raises(EsphomeError, match="nrf5sdk-tools"), + ): + ensure_nordic_nrfutil({}) + assert not list((tmp_path / "nrfutil").glob(".ready-*")) + + +def test_failed_install_leaves_no_ready_marker(tmp_path: Path) -> None: + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object(framework, "download_with_resume", side_effect=_fake_download), + patch.object(framework, "run_command_ok", return_value=False), + pytest.raises(EsphomeError, match="nrf5sdk-tools"), + ): + ensure_nordic_nrfutil({}) + assert not list((tmp_path / "nrfutil").glob(".ready-*")) diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index 9091b429f63..cd5500caa6e 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -6,7 +6,10 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.nrf52.const import ( + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + BOOTLOADER_NRF, +) from esphome.components.zephyr.const import ( KEY_BOARD, KEY_BOOTLOADER, @@ -217,14 +220,32 @@ class TestUploadProgramSerialPlatformio: # --------------------------------------------------------------------------- +def _prepare_serial_dfu( + tmp_path: Path, bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +) -> tuple[dict, Path]: + """Set up CORE for a serial DFU upload with a built firmware.zip; returns (paths, dfu_package).""" + _setup_nrf52_core(bootloader=bootloader, build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + dfu_package = CORE.relative_pioenvs_path(CORE.name) / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + return _make_paths(tmp_path), dfu_package + + def _enter_serial_dfu_patches( - stack: ExitStack, host: str, tmp_path: Path, paths: dict + stack: ExitStack, + host: str, + tmp_path: Path, + paths: dict, + usb_id: tuple[int, int] | None = None, ) -> MagicMock: """Enter all context managers needed for the serial DFU happy path. Returns the mock for ``run_command_ok`` so callers can inspect calls. comports() returns [] on the first call (port disappeared) and a list - containing the host on every subsequent call (port reappeared). Patches + containing the host on every subsequent call (port reappeared). With + ``usb_id`` set, the port is present with that vid/pid on every call, as it + is for a bootloader that is entered by hand. Patches are applied directly on the real pyserial module attributes so they are visible to the deferred ``import serial[.tools.list_ports] as _x`` statements inside upload_program. @@ -236,11 +257,13 @@ def _enter_serial_dfu_patches( _comports_calls = [0] - def _comports(): + def _comports(**_kwargs): _comports_calls[0] += 1 - if _comports_calls[0] == 1: + if _comports_calls[0] == 1 and usb_id is None: return [] # port disappeared → disappear loop breaks - return [MagicMock(device=host)] # port back → reappear loop breaks + # port back → reappear loop breaks + vid, pid = usb_id or (None, None) + return [MagicMock(device=host, vid=vid, pid=pid)] stack.enter_context( patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL) @@ -254,6 +277,12 @@ def _enter_serial_dfu_patches( patch("esphome.components.nrf52.get_build_env", return_value={}) ) stack.enter_context(patch("time.sleep")) + stack.enter_context( + patch( + "esphome.components.nrf52.ensure_nordic_nrfutil", + return_value="/opt/nordic/nrfutil", + ) + ) # Patch directly on the real pyserial module so the deferred imports inside # upload_program see our mocks regardless of how sys.modules is cached. stack.enter_context(patch.object(serial, "Serial")) @@ -266,25 +295,6 @@ def _enter_serial_dfu_patches( class TestUploadProgramSerialDfu: - def test_unsupported_bootloader_raises( - self, setup_core: Path, tmp_path: Path - ) -> None: - """An unknown bootloader must raise EsphomeError before touching the port.""" - from esphome.components.nrf52 import upload_program - from esphome.upload_targets import PortType - - _setup_nrf52_core( - bootloader="unknown_bootloader", build_path=tmp_path / "build" - ) - CORE.config_path = tmp_path / "test.yaml" - - with ( - patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), - patch("esphome.__main__.check_permissions"), - pytest.raises(EsphomeError, match="Not implemented"), - ): - upload_program(config={}, args=None, host="/dev/ttyACM0") - def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None: """Missing firmware.zip must raise EsphomeError before opening the serial port.""" from esphome.components.nrf52 import upload_program @@ -313,13 +323,7 @@ class TestUploadProgramSerialDfu: """Nordicsemi DFU command must include pkg path, port, and --singlebank.""" from esphome.components.nrf52 import upload_program - _setup_nrf52_core(build_path=tmp_path / "build") - CORE.config_path = tmp_path / "test.yaml" - paths = _make_paths(tmp_path) - build_dir = CORE.relative_pioenvs_path(CORE.name) - dfu_package = build_dir / "firmware.zip" - dfu_package.parent.mkdir(parents=True, exist_ok=True) - dfu_package.touch() + paths, dfu_package = _prepare_serial_dfu(tmp_path) host = "/dev/ttyACM0" with ExitStack() as stack: @@ -338,17 +342,175 @@ class TestUploadProgramSerialDfu: assert host in cmd assert "--singlebank" in cmd + def test_serial_upload_refuses_an_unlisted_bootloader( + self, setup_core: Path, tmp_path: Path + ) -> None: + """A bootloader without a serial DFU path fails before any tool runs.""" + from esphome.components.nrf52 import upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, "future") + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + with pytest.raises(EsphomeError, match="not implemented for the future"): + upload_program(config={}, args=None, host=host) + mock_run.assert_not_called() + + def test_open_dfu_bootloader_skips_the_touch( + self, setup_core: Path, tmp_path: Path + ) -> None: + """The factory Open DFU bootloader has no auto-entry, so no 1200 bps touch is sent.""" + import serial + + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, dfu_package = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=OPEN_DFU_USB_ID + ) + assert upload_program(config={}, args=None, host=host) is True + assert not serial.Serial.mock_calls # pylint: disable=no-member + + # Nordic's tool speaks the Secure DFU protocol of this bootloader + cmd = mock_run.call_args[0][0] + assert cmd[:4] == ["/opt/nordic/nrfutil", "nrf5sdk-tools", "dfu", "usb-serial"] + assert str(dfu_package) in cmd + assert host in cmd + + def test_open_dfu_application_port_is_touched_and_the_bootloader_port_flashed( + self, setup_core: Path, tmp_path: Path + ) -> None: + """An application port gets the 1200 bps touch; the bootloader then appears as its own port.""" + import serial + + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, dfu_package = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + bootloader_port = "/dev/ttyACM1" + calls = [0] + + def _comports(**_kwargs): + calls[0] += 1 + app = MagicMock(device=host, vid=0x2FE3, pid=0x0100) + if calls[0] == 1: + return [app] # still the application when the upload starts + return [ + MagicMock( + device=bootloader_port, + vid=OPEN_DFU_USB_ID[0], + pid=OPEN_DFU_USB_ID[1], + ) + ] + + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=(0x2FE3, 0x0100) + ) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + assert upload_program(config={}, args=None, host=host) is True + serial.Serial.assert_called_once_with(host, baudrate=1200, timeout=1) # pylint: disable=no-member + + cmd = mock_run.call_args[0][0] + assert cmd[cmd.index("-p") + 1] == bootloader_port + assert str(dfu_package) in cmd + + def test_open_dfu_leaves_another_dongle_in_its_bootloader_alone( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Only a bootloader port that appears after the touch is the touched dongle's.""" + import serial + + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + other = MagicMock( + device="/dev/ttyACM2", vid=OPEN_DFU_USB_ID[0], pid=OPEN_DFU_USB_ID[1] + ) + calls = [0] + + def _comports(**_kwargs): + calls[0] += 1 + if calls[0] == 1: + return [MagicMock(device=host, vid=0x2FE3, pid=0x0100), other] + if calls[0] == 2: + return [other] # the touched dongle is rebooting + return [ + other, + MagicMock( + device="/dev/ttyACM1", + vid=OPEN_DFU_USB_ID[0], + pid=OPEN_DFU_USB_ID[1], + ), + ] + + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=(0x2FE3, 0x0100) + ) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + assert upload_program(config={}, args=None, host=host) is True + + cmd = mock_run.call_args[0][0] + assert cmd[cmd.index("-p") + 1] == "/dev/ttyACM1" + + def test_open_dfu_bootloader_that_never_appears_gets_the_reset_hint( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Without ``dfu:`` the touch does nothing; the hint names the RESET button.""" + from esphome.components.nrf52 import upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + clock = [0.0] + + def _monotonic() -> float: + clock[0] += 1.0 + return clock[0] + + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=(0x2FE3, 0x0100) + ) + stack.enter_context(patch("time.monotonic", side_effect=_monotonic)) + with pytest.raises(EsphomeError, match="press the RESET button"): + upload_program(config={}, args=None, host=host) + mock_run.assert_not_called() + + def test_open_dfu_bootloader_needs_nordic_nrfutil( + self, setup_core: Path, tmp_path: Path + ) -> None: + """When Nordic's nrfutil cannot be fetched the upload fails before trying adafruit-nrfutil.""" + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=OPEN_DFU_USB_ID + ) + stack.enter_context( + patch( + "esphome.components.nrf52.ensure_nordic_nrfutil", + side_effect=EsphomeError("Failed to download Nordic's nrfutil"), + ) + ) + with pytest.raises(EsphomeError, match="download Nordic's nrfutil"): + upload_program(config={}, args=None, host=host) + mock_run.assert_not_called() + def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: """A failed nordicsemi DFU must raise EsphomeError.""" from esphome.components.nrf52 import upload_program - _setup_nrf52_core(build_path=tmp_path / "build") - CORE.config_path = tmp_path / "test.yaml" - paths = _make_paths(tmp_path) - build_dir = CORE.relative_pioenvs_path(CORE.name) - dfu_package = build_dir / "firmware.zip" - dfu_package.parent.mkdir(parents=True, exist_ok=True) - dfu_package.touch() + paths, _ = _prepare_serial_dfu(tmp_path) host = "/dev/ttyACM0" with ExitStack() as stack: From 6f6fdd0f8a2d0f6976bdf5f0e7d49db0028c8957 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:55:47 -1000 Subject: [PATCH 434/651] Bump actions/create-github-app-token from 3.2.0 to 3.3.0 (#20815) Signed-off-by: dependabot[bot] --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/codeowner-review-request.yml | 2 +- .github/workflows/external-component-bot.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/sync-dependency-versions.yml | 2 +- .github/workflows/sync-device-classes.yml | 2 +- .github/workflows/sync-integration-durations.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index b49c66b976d..82cf9f28288 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -28,7 +28,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 38a4b8ff0ed..728406e82ff 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -35,7 +35,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index bfe96f3944a..d3c4c5bf029 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0991a6308f..64b025a7427 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,7 +245,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml index 73a3f7dbaa9..76e40a0eba5 100644 --- a/.github/workflows/sync-dependency-versions.yml +++ b/.github/workflows/sync-dependency-versions.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index ba0404011be..33af93b481f 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-integration-durations.yml b/.github/workflows/sync-integration-durations.yml index d09a1cf2428..6bfb492fc9e 100644 --- a/.github/workflows/sync-integration-durations.yml +++ b/.github/workflows/sync-integration-durations.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From 7e564c12dde48d5777a845f1ba5e41020e451d96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:58:07 -1000 Subject: [PATCH 435/651] Bump py7zr from 1.1.3 to 1.1.4 (#20816) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0c1a1fb6235..13625818ad8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ smpclient==7.3.0 smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 -py7zr==1.1.3 +py7zr==1.1.4 platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.1.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 7ed06e0adaca56332d644973ad5855f2e0f827ae Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Thu, 15 Oct 2026 20:19:56 +0000 Subject: [PATCH 436/651] [as734x] [1/3] Add AS7341/AS7343/TCS3448 spectral sensor hub with counts sensors (#17038) 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/as734x/__init__.py | 104 ++++++ esphome/components/as734x/as734x.cpp | 301 ++++++++++++++++++ esphome/components/as734x/as734x.h | 99 ++++++ esphome/components/as734x/sensor.py | 53 +++ esphome/core/defines.h | 2 + tests/component_tests/as734x/__init__.py | 0 tests/component_tests/as734x/test_as734x.py | 94 ++++++ tests/component_tests/as734x/test_as734x.yaml | 47 +++ tests/components/as734x/common.yaml | 44 +++ tests/components/as734x/test.esp32-idf.yaml | 3 + tests/components/as734x/test.esp8266-ard.yaml | 3 + tests/components/as734x/test.rp2040-ard.yaml | 3 + 13 files changed, 754 insertions(+) create mode 100644 esphome/components/as734x/__init__.py create mode 100644 esphome/components/as734x/as734x.cpp create mode 100644 esphome/components/as734x/as734x.h create mode 100644 esphome/components/as734x/sensor.py create mode 100644 tests/component_tests/as734x/__init__.py create mode 100644 tests/component_tests/as734x/test_as734x.py create mode 100644 tests/component_tests/as734x/test_as734x.yaml create mode 100644 tests/components/as734x/common.yaml create mode 100644 tests/components/as734x/test.esp32-idf.yaml create mode 100644 tests/components/as734x/test.esp8266-ard.yaml create mode 100644 tests/components/as734x/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 3cbf36777c0..f2f24f1106f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,6 +49,7 @@ esphome/components/aranet/* @patrickod esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr +esphome/components/as734x/* @latonita @mrgnr esphome/components/async_tcp/* @esphome/core esphome/components/at581x/* @X-Ryl669 esphome/components/atc_mithermometer/* @ahpohl diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py new file mode 100644 index 00000000000..6657d76bf4d --- /dev/null +++ b/esphome/components/as734x/__init__.py @@ -0,0 +1,104 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_GAIN, CONF_ID, CONF_TYPE, CONF_UPDATE_INTERVAL +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita", "@mrgnr"] +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["sensor"] +MULTI_CONF = True +DOMAIN = "as734x" + +CONF_AS734X_ID = "as734x_id" +CONF_ATIME = "atime" +CONF_ASTEP = "astep" + +# Band order is a contract with the readout maps in as734x.cpp: the index selects the counts slot. +BANDS_43 = ( + "f1", + "f2", + "fz", + "f3", + "f4", + "fy", + "f5", + "fxl", + "f6", + "f7", + "f8", + "nir", + "clear", +) +BANDS_41 = tuple(b for b in BANDS_43 if b not in ("fz", "fy", "fxl")) + +as734x_ns = cg.esphome_ns.namespace("as734x") +AS734XComponent = as734x_ns.class_( + "AS734XComponent", cg.PollingComponent, i2c.I2CDevice +) + +Gain = as734x_ns.enum("Gain") +GAINS_41 = { + f"X{name}": getattr(Gain, f"GAIN_{name.replace('.', '_')}X") + for name in ("0.5", "1", "2", "4", "8", "16", "32", "64", "128", "256", "512") +} +GAINS_43 = {**GAINS_41, "X1024": Gain.GAIN_1024X, "X2048": Gain.GAIN_2048X} + + +# One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us +CYCLE_STEP_SECONDS = 2.78e-6 + + +def _validate_exposure(cycles: int): + """Reject an exposure that is empty or longer than the update interval.""" + + def validator(config: ConfigType) -> ConfigType: + atime, astep = config[CONF_ATIME], config[CONF_ASTEP] + if atime == 0 and astep == 0: + raise cv.Invalid(f"{CONF_ATIME} and {CONF_ASTEP} must not both be 0") + measurement = (atime + 1) * (astep + 1) * CYCLE_STEP_SECONDS * cycles + if measurement > config[CONF_UPDATE_INTERVAL].total_seconds: + raise cv.Invalid( + f"A measurement takes {measurement:.1f}s with these {CONF_ATIME} and " + f"{CONF_ASTEP} values, longer than the {CONF_UPDATE_INTERVAL}" + ) + return config + + return validator + + +def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: + return cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(AS734XComponent), + cv.Optional(CONF_GAIN, default="X8"): cv.enum(gains), + cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), + cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(address)), + _validate_exposure(cycles), + ) + + +CONFIG_SCHEMA = cv.typed_schema( + { + "AS7341": _schema(GAINS_41, 0x39, cycles=2), # two SMUX steps per update + "AS7343": _schema(GAINS_43, 0x39, cycles=3), + "TCS3448": _schema(GAINS_43, 0x59, cycles=3), + }, + upper=True, +) + + +async def to_code(config: ConfigType) -> None: + chip = config[CONF_TYPE] + cg.add_define("USE_AS7341" if chip == "AS7341" else "USE_AS7343") + var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&as734x::{chip}_CHIP")) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + cg.add(var.set_gain(config[CONF_GAIN])) + cg.add(var.set_atime(config[CONF_ATIME])) + cg.add(var.set_astep(config[CONF_ASTEP])) diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp new file mode 100644 index 00000000000..2a9de8f3df2 --- /dev/null +++ b/esphome/components/as734x/as734x.cpp @@ -0,0 +1,301 @@ +#include "as734x.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::as734x { + +ESPHOME_LOG_TAG(TAG, "as734x"); + +// Registers and bits shared by both chips +static constexpr uint8_t REG_ENABLE = 0x80; +static constexpr uint8_t REG_ATIME = 0x81; +static constexpr uint8_t REG_ASTATUS = 0x94; +static constexpr uint8_t ENABLE_PON = 0x01; +static constexpr uint8_t ENABLE_SP_EN = 0x02; +static constexpr uint8_t ENABLE_SMUX_EN = 0x10; +static constexpr uint8_t CFG0_REG_BANK = 0x10; +static constexpr uint8_t BANK_SPLIT = 0x80; // registers below this need REG_BANK set +static constexpr uint8_t STATUS2_AVALID = 0x40; +static constexpr uint8_t ASTATUS_ASAT = 0x80; + +static constexpr uint8_t AS7341_CFG6 = 0xAF; +static constexpr uint8_t AS7341_SMUX_CMD_WRITE = 0x10; // copy the SMUX image from RAM to the chain +static constexpr uint8_t AS7341_SMUX_RAM = 0x00; +static constexpr uint8_t AS7343_CHAIN_CMD = 0xE4; +static constexpr uint8_t AS7343_CHAIN_SMUX = 0xE7; +static constexpr uint8_t AS7343_CHAINS = 3; + +// Read one field of a ChipInfo kept in flash +template static T pgm(const T &field) { + T value; + progmem_memcpy(&value, &field, sizeof(T)); + return value; +} + +static constexpr uint8_t NO_SLOT = 0xFF; +static constexpr uint8_t CLEAR_BAND = NUM_BANDS - 1; +static constexpr uint8_t MAX_SMUX_LEN = 20; +static constexpr uint8_t MAX_ADC_SLOTS = 18; +static constexpr uint32_t MIN_COLLECTION_TIMEOUT_MS = 30 * 1000; + +#ifdef USE_AS7341 +static const char AS7341_NAME[] PROGMEM = "AS7341"; +static constexpr uint8_t AS7341_ADC_SLOTS = 6; +static constexpr uint8_t AS7341_SMUX_LEN = 20; +static constexpr uint8_t AS7341_SMUX_STEPS = 2; +static const RegValue AS7341_INIT[] PROGMEM = {{0x70, 0x00}}; // CONFIG: SPM mode, INT_SEL and LED_SEL cleared +static const uint8_t AS7341_SMUX[] PROGMEM = { + 0x30, 0x01, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00, 0x50, 0x00, // step 0: F1 to F4, NIR, Clear + 0x00, 0x00, 0x20, 0x04, 0x00, 0x30, 0x01, 0x50, 0x00, 0x06, // step 0, continued + 0x00, 0x00, 0x00, 0x40, 0x02, 0x00, 0x10, 0x03, 0x50, 0x10, // step 1: F5 to F8, NIR, Clear + 0x03, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x50, 0x00, 0x06, // step 1, continued +}; +static_assert(sizeof(AS7341_SMUX) == AS7341_SMUX_STEPS * AS7341_SMUX_LEN && AS7341_SMUX_LEN <= MAX_SMUX_LEN); +static constexpr uint8_t N = NO_SLOT; +static const uint8_t AS7341_READOUT[] PROGMEM = { + 0, 1, N, 2, 3, N, N, N, N, N, N, N, N, // step 0 + N, N, N, N, N, N, 0, N, 1, 2, 3, 5, 4, // step 1: SMUX routes clear to ADC4 and NIR to ADC5 +}; +static_assert(sizeof(AS7341_READOUT) == AS7341_SMUX_STEPS * NUM_BANDS && AS7341_ADC_SLOTS <= MAX_ADC_SLOTS); +const ChipInfo AS7341_CHIP PROGMEM = { + .name = AS7341_NAME, + .init = AS7341_INIT, + .smux = AS7341_SMUX, + .readout = AS7341_READOUT, + .init_count = 1, + .astep_reg = 0xCA, + .cfg0_reg = 0xA9, + .cfg1_reg = 0xAA, + .status2_reg = 0xA3, + .id_reg = 0x92, + .id_mask = 0xFC, + .id_value = 0x09 << 2, + .adc_slots = AS7341_ADC_SLOTS, + .smux_steps = AS7341_SMUX_STEPS, + .integration_cycles = 1, + .smux_len = AS7341_SMUX_LEN, + .auto_smux = false, + .extra_clear_slot = NO_SLOT, +}; +#endif + +#ifdef USE_AS7343 +static const char AS7343_NAME[] PROGMEM = "AS7343"; +static constexpr uint8_t AS7343_ADC_SLOTS = 18; +static constexpr uint8_t AS7343_SMUX_LEN = 10; +static const char TCS3448_NAME[] PROGMEM = "TCS3448"; +// CFG6, FD_CFG0, CFG10, CFG8, CFG20 (auto_smux, 18 channels in 3 cycles), AGC_GAIN_MAX, FD_TIME_1, FD_TIME_2 +static const RegValue AS7343_INIT[] PROGMEM = {{0xF5, 0x00}, {0xDF, 0xA1}, {0x65, 0xF2}, {0xC9, 0xC8}, + {0xD6, 0x62}, {0xD7, 0x99}, {0xE0, 0x64}, {0xE2, 0x21}}; +static const uint8_t AS7343_SMUX[] PROGMEM = {0x00, 0x04, 0x65, 0x02, 0x00, 0x05, 0x00, 0x01, 0x00, 0x30, + 0x00, 0x00, 0x60, 0x20, 0x04, 0x50, 0x03, 0x00, 0x01, 0x05, + 0x05, 0x00, 0x60, 0x00, 0x30, 0x00, 0x40, 0x10, 0x20, 0x00}; +// Clear is the average of the cycle 1 (slot 4) and cycle 2 (slot 10) readings; cycle 3 always reads 0. +static const uint8_t AS7343_READOUT[] PROGMEM = {12, 6, 0, 7, 8, 1, 15, 2, 9, 13, 14, 3, 10}; +static_assert(sizeof(AS7343_SMUX) == AS7343_CHAINS * AS7343_SMUX_LEN && AS7343_SMUX_LEN <= MAX_SMUX_LEN); +static_assert(sizeof(AS7343_READOUT) == NUM_BANDS && AS7343_ADC_SLOTS <= MAX_ADC_SLOTS); +static constexpr ChipInfo as7343_chip(const char *name) { + return { + .name = name, + .init = AS7343_INIT, + .smux = AS7343_SMUX, + .readout = AS7343_READOUT, + .init_count = sizeof(AS7343_INIT) / sizeof(RegValue), + .astep_reg = 0xD4, + .cfg0_reg = 0xBF, + .cfg1_reg = 0xC6, + .status2_reg = 0x90, + .id_reg = 0x5A, + .id_mask = 0xFF, + .id_value = 0x81, + .adc_slots = AS7343_ADC_SLOTS, + .smux_steps = 1, + .integration_cycles = 3, + .smux_len = AS7343_SMUX_LEN, + .auto_smux = true, + .extra_clear_slot = 4, + }; +} +const ChipInfo AS7343_CHIP PROGMEM = as7343_chip(AS7343_NAME); +const ChipInfo TCS3448_CHIP PROGMEM = as7343_chip(TCS3448_NAME); // AS7343 register map, address 0x59 +#endif + +// Registers below 0x80 are only reachable with the REG_BANK bit of CFG0 set; CFG0 itself always answers. +bool AS734XComponent::select_bank_(uint8_t reg) { + const bool low = reg < BANK_SPLIT; + if (low == this->bank_low_) { + return true; + } + this->bank_low_ = low; + return this->write_byte(pgm(this->chip_->cfg0_reg), low ? CFG0_REG_BANK : 0); +} + +void AS734XComponent::setup() { + const ChipInfo *chip = this->chip_; + const uint8_t id_reg = pgm(chip->id_reg); + uint8_t id = 0; + // The sensor keeps its bank across an MCU reset, so the first access always writes CFG0 + bool ok = this->select_bank_(REG_ENABLE) && this->write_byte(REG_ENABLE, ENABLE_PON); + delayMicroseconds(200); // oscillator start-up + ok = ok && this->select_bank_(id_reg) && this->read_byte(id_reg, &id); + if (ok && (id & pgm(chip->id_mask)) != pgm(chip->id_value)) { + this->mark_failed(LOG_STR("Wrong chip ID")); + return; + } + const RegValue *init = pgm(chip->init); + for (uint8_t i = 0, n = pgm(chip->init_count); ok && i < n; i++) { + RegValue rv; + progmem_memcpy(&rv, &init[i], sizeof(rv)); + ok = this->select_bank_(rv.reg) && this->write_byte(rv.reg, rv.value); + } + // Everything from here on, including the measurement registers, is in the high bank. + ok = ok && this->select_bank_(REG_ATIME) && this->write_byte(REG_ATIME, this->atime_) && + this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) && + this->write_byte(pgm(chip->cfg1_reg), this->gain_); + if (pgm(chip->auto_smux)) { + const uint8_t *smux = pgm(chip->smux); + const uint8_t len = pgm(chip->smux_len); + for (uint8_t i = 0; ok && i < AS7343_CHAINS * len; i++) { + ok = this->write_byte(AS7343_CHAIN_SMUX, progmem_read_byte(&smux[i])) && + this->write_byte(AS7343_CHAIN_CMD, 0x46 + (i / len) * 0x10); + } + } + if (!ok) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + // One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us. + const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000; + this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles); + this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_); + this->disable_loop(); +} + +void AS734XComponent::dump_config() { + const float gain = this->gain_ == GAIN_0_5X ? 0.5f : static_cast(1 << (this->gain_ - 1)); + ESP_LOGCONFIG(TAG, + "AS734x:\n" + " Chip: %s\n" + " Gain: %gx\n" + " ATIME: %u\n" + " ASTEP: %u", + LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + for (auto *sens : this->sensors_) { + LOG_SENSOR(" ", "Counts", sens); + } + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void AS734XComponent::update() { + if (this->state_ != State::STATE_IDLE) { + ESP_LOGW(TAG, "Skipping update, previous measurement still running"); + return; + } + this->step_ = 0; + this->enable_loop(); + if (!this->start_step_()) { + this->abort_measurement_(LOG_STR("Failed to start measurement")); + } +} + +bool AS734XComponent::start_step_() { + this->start_ms_ = App.get_loop_component_start_time(); + if (pgm(this->chip_->auto_smux)) { + this->state_ = State::STATE_READ_DATA; + return this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SP_EN); + } + // Load this step's image into SMUX RAM; SMUX_EN copies it to the chain and clears itself when done. + std::array image{}; + const uint8_t len = pgm(this->chip_->smux_len); + progmem_memcpy(image.data(), pgm(this->chip_->smux) + this->step_ * len, len); + this->state_ = State::STATE_WAIT_SMUX; + return this->write_byte(AS7341_CFG6, AS7341_SMUX_CMD_WRITE) && + this->write_bytes(AS7341_SMUX_RAM, image.data(), len) && + this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SMUX_EN); +} + +void AS734XComponent::loop() { + const uint32_t now = App.get_loop_component_start_time(); + uint8_t value = 0; + switch (this->state_) { + case State::STATE_WAIT_SMUX: + // A failed read counts as busy, so the timeout below catches a chip that stops answering. + if (this->read_byte(REG_ENABLE, &value) && !(value & ENABLE_SMUX_EN)) { + this->start_ms_ = now; + this->state_ = State::STATE_READ_DATA; + if (!this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SP_EN)) { + this->abort_measurement_(LOG_STR("Failed to start integration")); + } + return; + } + break; + case State::STATE_READ_DATA: + if (now - this->start_ms_ < this->integration_ms_) { + return; + } + if (this->read_byte(pgm(this->chip_->status2_reg), &value) && (value & STATUS2_AVALID)) { + this->read_step_(); + return; + } + break; + case State::STATE_IDLE: + this->disable_loop(); + return; + } + if (now - this->start_ms_ > this->timeout_ms_) { + this->abort_measurement_(LOG_STR("Measurement timeout")); + } +} + +void AS734XComponent::read_step_() { + // Reading ASTATUS latches the data, which is only consistent within one burst starting there. + std::array frame{}; + if (!this->read_bytes(REG_ASTATUS, frame.data(), 1 + 2 * pgm(this->chip_->adc_slots)) || + !this->write_byte(REG_ENABLE, ENABLE_PON)) { + this->abort_measurement_(LOG_STR("Failed to read channel data")); + return; + } + if (frame[0] & ASTATUS_ASAT) { + ESP_LOGV(TAG, "Step %u saturated", this->step_); + } + auto slot_value = [&frame](uint8_t slot) { return encode_uint16(frame[2 + 2 * slot], frame[1 + 2 * slot]); }; + const uint8_t *readout = pgm(this->chip_->readout) + this->step_ * NUM_BANDS; + const uint8_t extra_clear = pgm(this->chip_->extra_clear_slot); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const uint8_t slot = progmem_read_byte(&readout[band]); + if (slot == NO_SLOT || this->sensors_[band] == nullptr) { + continue; + } + uint32_t counts = slot_value(slot); + if (band == CLEAR_BAND && extra_clear != NO_SLOT) { + counts = (counts + slot_value(extra_clear)) / 2; + } + this->sensors_[band]->publish_state(counts); + } + if (++this->step_ < pgm(this->chip_->smux_steps)) { + if (!this->start_step_()) { + this->abort_measurement_(LOG_STR("Failed to configure SMUX")); + } + return; + } + this->status_clear_warning(); + this->state_ = State::STATE_IDLE; + this->disable_loop(); +} + +void AS734XComponent::abort_measurement_(const LogString *reason) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(reason)); + this->write_byte(REG_ENABLE, ENABLE_PON); + this->status_set_warning(reason); + this->state_ = State::STATE_IDLE; + this->disable_loop(); +} + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/as734x.h b/esphome/components/as734x/as734x.h new file mode 100644 index 00000000000..184084ca2b7 --- /dev/null +++ b/esphome/components/as734x/as734x.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::as734x { + +enum Gain : uint8_t { + GAIN_0_5X, + GAIN_1X, + GAIN_2X, + GAIN_4X, + GAIN_8X, + GAIN_16X, + GAIN_32X, + GAIN_64X, + GAIN_128X, + GAIN_256X, + GAIN_512X, + GAIN_1024X, // AS7343 only + GAIN_2048X, // AS7343 only +}; + +static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py + +struct RegValue { + uint8_t reg; + uint8_t value; +}; + +/// Per-chip constants, stored in flash. The pointed-to tables are in flash too. +struct ChipInfo { + const char *name; + const RegValue *init; // written in order; the bank is selected per register address + const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images + const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + uint8_t init_count; + uint8_t astep_reg; + uint8_t cfg0_reg; + uint8_t cfg1_reg; + uint8_t status2_reg; + uint8_t id_reg; + uint8_t id_mask; + uint8_t id_value; + uint8_t adc_slots; // 16-bit data registers read after ASTATUS + uint8_t smux_steps; // measurements per update + uint8_t integration_cycles; + uint8_t smux_len; // bytes per SMUX image + bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup + uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none) +}; + +#ifdef USE_AS7341 +extern const ChipInfo AS7341_CHIP; +#endif +#ifdef USE_AS7343 +extern const ChipInfo AS7343_CHIP; +extern const ChipInfo TCS3448_CHIP; +#endif + +class AS734XComponent : public PollingComponent, public i2c::I2CDevice { + public: + explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) {} + + void setup() override; + void dump_config() override; + void update() override; + void loop() override; + + void set_gain(Gain gain) { this->gain_ = gain; } + void set_atime(uint8_t atime) { this->atime_ = atime; } + void set_astep(uint16_t astep) { this->astep_ = astep; } + void set_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->sensors_[band] = sensor; } + + protected: + bool select_bank_(uint8_t reg); + bool start_step_(); + void read_step_(); + void abort_measurement_(const LogString *reason); + + std::array sensors_{}; + const ChipInfo *chip_; // in flash, read field by field with progmem_memcpy + uint32_t start_ms_{0}; + uint32_t integration_ms_{0}; + uint32_t timeout_ms_{0}; + uint16_t astep_{0}; + enum class State : uint8_t { STATE_IDLE, STATE_WAIT_SMUX, STATE_READ_DATA } state_{State::STATE_IDLE}; + Gain gain_{GAIN_1X}; + uint8_t atime_{0}; + uint8_t step_{0}; + bool bank_low_{true}; // unknown until setup() writes CFG0 +}; + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/sensor.py b/esphome/components/as734x/sensor.py new file mode 100644 index 00000000000..aa1487f8a97 --- /dev/null +++ b/esphome/components/as734x/sensor.py @@ -0,0 +1,53 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import UNIT_COUNTS +import esphome.config_validation as cv +from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import BANDS_41, BANDS_43, CONF_AS734X_ID, DOMAIN, AS734XComponent + +DEPENDENCIES = [DOMAIN] + +ICON_COUNTS = "mdi:counter" + +_COUNTS_SENSOR = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_COUNTS, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent), + cv.Optional(CONF_COUNTS, default={}): cv.Schema( + {cv.Optional(band): _COUNTS_SENSOR for band in BANDS_43} + ), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + full = fv.full_config.get() + hub = full.get_config_for_path(full.get_path_for_id(config[CONF_AS734X_ID])[:-1]) + if hub[CONF_TYPE] == "AS7341" and ( + extra := [band for band in config[CONF_COUNTS] if band not in BANDS_41] + ): + raise cv.Invalid( + f"The AS7341 has no {', '.join(extra)} band", path=[CONF_COUNTS, extra[0]] + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AS734X_ID]) + counts = config[CONF_COUNTS] + for i, band in enumerate(BANDS_43): + if (conf := counts.get(band)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_counts_sensor(sens, i)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 79ea459bad8..c8a128e76cb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -43,6 +43,8 @@ // Feature flags #define USE_ALARM_CONTROL_PANEL #define USE_AREAS +#define USE_AS7341 +#define USE_AS7343 #define USE_BINARY_SENSOR #define USE_BINARY_SENSOR_CLICK_TRIGGER #define USE_BINARY_SENSOR_FILTER diff --git a/tests/component_tests/as734x/__init__.py b/tests/component_tests/as734x/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/as734x/test_as734x.py b/tests/component_tests/as734x/test_as734x.py new file mode 100644 index 00000000000..98ae9571693 --- /dev/null +++ b/tests/component_tests/as734x/test_as734x.py @@ -0,0 +1,94 @@ +"""Tests for the as734x band to counts index mapping and band validation. + +The C++ readout maps publish each band from a fixed index, so the order of BANDS_43 is a +contract with as734x.cpp. Reordering either side only moves readings to the wrong entity, +which no build test would notice, so the indices are pinned here. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.as734x import ( + CONF_AS734X_ID, + CONFIG_SCHEMA as HUB_SCHEMA, + sensor as as734x_sensor, +) +from esphome.config import Config +from esphome.const import CONF_COUNTS, CONF_ID, CONF_TYPE, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + +CONFIG = "tests/component_tests/as734x/test_as734x.yaml" + +BAND_INDEX = { + "f1": 0, + "f2": 1, + "fz": 2, + "f3": 3, + "f4": 4, + "fy": 5, + "f5": 6, + "fxl": 7, + "f6": 8, + "f7": 9, + "f8": 10, + "nir": 11, + "clear": 12, +} +AS7341_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "nir", "clear") + + +def test_as7341_bands_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band in AS7341_BANDS: + assert f"hub41->set_counts_sensor(s41_{band}, {BAND_INDEX[band]});" in main_cpp + + +def test_as7343_bands_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band, index in BAND_INDEX.items(): + assert f"hub43->set_counts_sensor(s43_{band}, {index});" in main_cpp + + +@pytest.mark.parametrize("band", ["fz", "fy", "fxl"]) +def test_as7341_rejects_as7343_only_band( + band: str, set_core_config: SetCoreConfigCallable +) -> None: + full = Config() + full["as734x"] = [{CONF_ID: ID("hub41"), CONF_TYPE: "AS7341"}] + full.declare_ids.append((ID("hub41"), ["as734x", 0, CONF_ID])) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + + with pytest.raises(cv.Invalid, match=f"no {band} band"): + as734x_sensor.FINAL_VALIDATE_SCHEMA( + {CONF_AS734X_ID: ID("hub41"), CONF_COUNTS: {"f1": {}, band: {}}} + ) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ({"type": "AS7343", "atime": 0, "astep": 0}, "must not both be 0"), + # about 140 s with three cycles, against the default 60 s interval + ( + {"type": "AS7343", "atime": 255, "astep": 65534}, + "longer than the update_interval", + ), + # about 93 s with two cycles, rejected only if a third cycle were counted + ( + {"type": "AS7341", "atime": 255, "astep": 65534, "update_interval": "100s"}, + None, + ), + ], +) +def test_exposure_bound( + config: dict, error: str | None, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + if error is None: + HUB_SCHEMA(config) + return + with pytest.raises(cv.Invalid, match=error): + HUB_SCHEMA(config) diff --git a/tests/component_tests/as734x/test_as734x.yaml b/tests/component_tests/as734x/test_as734x.yaml new file mode 100644 index 00000000000..0ae5ce2ce10 --- /dev/null +++ b/tests/component_tests/as734x/test_as734x.yaml @@ -0,0 +1,47 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: 21 + scl: 22 + +as734x: + - id: hub41 + type: AS7341 + - id: hub43 + type: AS7343 + address: 0x3A + +sensor: + - platform: as734x + as734x_id: hub41 + counts: + f1: {id: s41_f1, name: s41 f1} + f2: {id: s41_f2, name: s41 f2} + f3: {id: s41_f3, name: s41 f3} + f4: {id: s41_f4, name: s41 f4} + f5: {id: s41_f5, name: s41 f5} + f6: {id: s41_f6, name: s41 f6} + f7: {id: s41_f7, name: s41 f7} + f8: {id: s41_f8, name: s41 f8} + nir: {id: s41_nir, name: s41 nir} + clear: {id: s41_clear, name: s41 clear} + - platform: as734x + as734x_id: hub43 + counts: + f1: {id: s43_f1, name: s43 f1} + f2: {id: s43_f2, name: s43 f2} + fz: {id: s43_fz, name: s43 fz} + f3: {id: s43_f3, name: s43 f3} + f4: {id: s43_f4, name: s43 f4} + fy: {id: s43_fy, name: s43 fy} + f5: {id: s43_f5, name: s43 f5} + fxl: {id: s43_fxl, name: s43 fxl} + f6: {id: s43_f6, name: s43 f6} + f7: {id: s43_f7, name: s43 f7} + f8: {id: s43_f8, name: s43 f8} + nir: {id: s43_nir, name: s43 nir} + clear: {id: s43_clear, name: s43 clear} diff --git a/tests/components/as734x/common.yaml b/tests/components/as734x/common.yaml new file mode 100644 index 00000000000..5fa94d7ef5b --- /dev/null +++ b/tests/components/as734x/common.yaml @@ -0,0 +1,44 @@ +as734x: + - id: as7341_hub + type: AS7341 + i2c_id: i2c_bus + update_interval: 15s + gain: X8 + atime: 120 + astep: 99 + - id: as7343_hub + type: AS7343 + i2c_id: i2c_bus + address: 0x3A + gain: X512 + - id: tcs3448_hub + type: TCS3448 + i2c_id: i2c_bus + gain: X2048 + +sensor: + - platform: as734x + as734x_id: as7341_hub + counts: + f1: + name: AS7341 F1 + nir: + name: AS7341 NIR + clear: + name: AS7341 Clear + - platform: as734x + as734x_id: as7343_hub + counts: + fz: + name: AS7343 FZ + fxl: + name: AS7343 FXL + clear: + name: AS7343 Clear + - platform: as734x + as734x_id: tcs3448_hub + counts: + f1: + name: TCS3448 F1 + fy: + name: TCS3448 FY diff --git a/tests/components/as734x/test.esp32-idf.yaml b/tests/components/as734x/test.esp32-idf.yaml new file mode 100644 index 00000000000..c2f5846bc52 --- /dev/null +++ b/tests/components/as734x/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + as734x: !include common.yaml diff --git a/tests/components/as734x/test.esp8266-ard.yaml b/tests/components/as734x/test.esp8266-ard.yaml new file mode 100644 index 00000000000..b94a2dc317d --- /dev/null +++ b/tests/components/as734x/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + as734x: !include common.yaml diff --git a/tests/components/as734x/test.rp2040-ard.yaml b/tests/components/as734x/test.rp2040-ard.yaml new file mode 100644 index 00000000000..36538043f2a --- /dev/null +++ b/tests/components/as734x/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + as734x: !include common.yaml From 304627efd36bfce05f0742b00484cadfc0d27368 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 22:34:00 +0200 Subject: [PATCH 437/651] [ld2410][ld2412] Move CONF_OUT_PIN_LEVEL to the shared constants (#20820) --- esphome/components/const/__init__.py | 1 + esphome/components/ld2410/select/__init__.py | 2 +- esphome/components/ld2412/select/__init__.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index dcf40d2a07c..3a531978aaa 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -52,6 +52,7 @@ CONF_ON_RECEIVE = "on_receive" CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" +CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select/__init__.py index 936ad83d39d..e1129ac9524 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import select +from esphome.components.const import CONF_OUT_PIN_LEVEL import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -20,7 +21,6 @@ LightOutControlSelect = ld2410_ns.class_("LightOutControlSelect", select.Select) CONF_DISTANCE_RESOLUTION = "distance_resolution" CONF_LIGHT_FUNCTION = "light_function" -CONF_OUT_PIN_LEVEL = "out_pin_level" CONFIG_SCHEMA = { diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index 40a075e5d54..5249e9b97a6 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import select +from esphome.components.const import CONF_OUT_PIN_LEVEL import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -20,7 +21,6 @@ LightOutControlSelect = LD2412_ns.class_("LightOutControlSelect", select.Select) CONF_DISTANCE_RESOLUTION = "distance_resolution" CONF_LIGHT_FUNCTION = "light_function" -CONF_OUT_PIN_LEVEL = "out_pin_level" CONFIG_SCHEMA = { From 27d8d49b17ac8f1f7ab8cd88279f2536b25e8ebe Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 22:41:20 +0200 Subject: [PATCH 438/651] [dfrobot_sen0609] Add number, switch and select settings (#20813) Co-authored-by: Claude Opus 5.5 --- .../components/dfrobot_sen0609/__init__.py | 10 +- .../dfrobot_sen0609/dfrobot_sen0609.cpp | 446 ++++++++++++++++- .../dfrobot_sen0609/dfrobot_sen0609.h | 100 ++++ .../dfrobot_sen0609/number/__init__.py | 194 ++++++++ .../number/dfrobot_sen0609_number.h | 18 + .../dfrobot_sen0609/select/__init__.py | 29 ++ .../select/dfrobot_sen0609_select.h | 17 + .../dfrobot_sen0609/switch/__init__.py | 68 +++ .../switch/dfrobot_sen0609_switch.h | 18 + .../components/dfrobot_sen0609/benchmark.yaml | 3 + .../dfrobot_sen0609/common-speed.yaml | 17 + tests/components/dfrobot_sen0609/common.h | 24 + tests/components/dfrobot_sen0609/common.yaml | 34 ++ .../dfrobot_sen0609_sequencer.cpp | 453 +++++++++++++++++- .../fixtures/uart_mock_dfrobot_sen0609.yaml | 20 +- .../test_uart_mock_dfrobot_sen0609.py | 31 +- 16 files changed, 1434 insertions(+), 48 deletions(-) create mode 100644 esphome/components/dfrobot_sen0609/number/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h create mode 100644 esphome/components/dfrobot_sen0609/select/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h create mode 100644 esphome/components/dfrobot_sen0609/switch/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py index 4825f4b770a..c1d2f806377 100644 --- a/esphome/components/dfrobot_sen0609/__init__.py +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -3,7 +3,7 @@ from collections.abc import Callable import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MODE +from esphome.const import CONF_ID, CONF_MODE, CONF_MODEL from esphome.core import ID import esphome.final_validate as fv from esphome.types import ConfigType @@ -28,10 +28,18 @@ RUN_MODES = { MODE_SPEED_AND_DISTANCE: RunMode.RUN_MODE_SPEED_AND_DISTANCE, } +MODEL_SEN0609 = "SEN0609" +MODEL_SEN0610 = "SEN0610" +# Longest detection range in metres +MAX_RANGES = {MODEL_SEN0609: 25.0, MODEL_SEN0610: 12.0} + CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(DFRobotSEN0609Component), + cv.Optional(CONF_MODEL, default=MODEL_SEN0609): cv.one_of( + *MAX_RANGES, upper=True + ), cv.Optional(CONF_MODE, default=MODE_PRESENCE): cv.one_of( *RUN_MODES, lower=True ), diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index 0e6ff31ffe7..26538998a2f 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -17,6 +17,8 @@ ESPHOME_LOG_TAG(TAG, "dfrobot_sen0609"); static constexpr uint8_t MAX_ATTEMPTS = 3; // The module ignores a command that follows its last answer too closely static constexpr uint32_t COMMAND_GAP_MS = 100; +// Gathers entity changes made close together into one flash write +static constexpr uint32_t COALESCE_MS = 1000; static constexpr uint32_t DEFAULT_TIMEOUT_MS = 1000; static constexpr uint32_t LONG_TIMEOUT_MS = 2000; static constexpr uint32_t GIVE_UP_BACKOFF_MS = 30000; @@ -27,11 +29,34 @@ static constexpr uint8_t FRAME_FIELDS = 5; static constexpr uint8_t LINE_END[] = {'\r', '\n'}; // Indexed by Param; empty for parameters that cannot be read -PROGMEM_STRING_TABLE(ReadCommands, "", "", "getSWV", "getHWV"); +PROGMEM_STRING_TABLE(ReadCommands, "", "", "getRange", "getTrigRange", "getSensitivity", "getLatency", "getInhibit", + "getThrFactor", "getMicroMotion", "getGpioMode 1", "", "", "getSWV", "getHWV"); static_assert(PARAM_COUNT <= 16, "pending_writes_ and pending_reads_ hold one bit per parameter"); static constexpr uint16_t param_bit(Param param) { return static_cast(1U << param); } +// Parameters kept in the module's flash only after saveConfig +static constexpr uint16_t PERSISTENT_PARAMS = + param_bit(PARAM_RANGE) | param_bit(PARAM_TRIG_RANGE) | param_bit(PARAM_SENSITIVITY) | param_bit(PARAM_LATENCY) | + param_bit(PARAM_INHIBIT) | param_bit(PARAM_THR_FACTOR) | param_bit(PARAM_MICRO_MOTION) | param_bit(PARAM_GPIO_MODE); +// The frames depend on these, so their writes are kept until they succeed +static constexpr uint16_t FRAME_PARAMS = param_bit(PARAM_RUN_APP) | param_bit(PARAM_UART_OUTPUT); + +#ifdef USE_SWITCH +static constexpr bool is_led(Param param) { return param == PARAM_RUN_LED || param == PARAM_OUT_LED; } +static constexpr SwitchSetting led_switch(Param param) { + return param == PARAM_RUN_LED ? SWITCH_RUN_LED : SWITCH_OUT_LED; +} +#endif + +#ifdef USE_NUMBER +static_assert(NUMBER_COUNT <= 16, "changed_numbers_ holds one bit per number"); +static constexpr uint8_t NUMBER_PARAMS[NUMBER_COUNT] PROGMEM = { + PARAM_RANGE, PARAM_RANGE, PARAM_TRIG_RANGE, PARAM_SENSITIVITY, PARAM_SENSITIVITY, + PARAM_LATENCY, PARAM_LATENCY, PARAM_INHIBIT, PARAM_THR_FACTOR, +}; +#endif + static bool starts_with(const char *str, ESPHOME_PGM_P prefix) { return ESPHOME_strncmp_P(str, prefix, ESPHOME_strlen_P(prefix)) == 0; } @@ -69,6 +94,8 @@ static bool answer_means_done(Operation operation, const char *line) { return contains(line, ESPHOME_PSTR("stopped already")); case Operation::OPERATION_START: return contains(line, ESPHOME_PSTR("started already")); + case Operation::OPERATION_SAVE: + return starts_with(line, ESPHOME_PSTR("no parameter")); default: return false; } @@ -88,6 +115,8 @@ static const LogString *operation_to_str(Operation operation) { return LOG_STR("start"); case Operation::OPERATION_WRITE: return LOG_STR("write"); + case Operation::OPERATION_SAVE: + return LOG_STR("save"); case Operation::OPERATION_READ: return LOG_STR("read"); default: @@ -95,7 +124,11 @@ static const LogString *operation_to_str(Operation operation) { } } -DFRobotSEN0609Component::DFRobotSEN0609Component(RunMode mode) : mode_(mode) {} +DFRobotSEN0609Component::DFRobotSEN0609Component(RunMode mode) : mode_(mode) { +#ifdef USE_NUMBER + this->number_values_.fill(NAN); +#endif +} void DFRobotSEN0609Component::dump_config() { ESP_LOGCONFIG( @@ -115,9 +148,34 @@ void DFRobotSEN0609Component::dump_config() { LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); LOG_TEXT_SENSOR(" ", "HardwareVersion", this->hardware_version_text_sensor_); #endif +#ifdef USE_NUMBER + for (number::Number *n : this->numbers_) { + LOG_NUMBER(" ", "Number", n); + } +#endif +#ifdef USE_SWITCH + for (switch_::Switch *s : this->switches_) { + LOG_SWITCH(" ", "Switch", s); + } +#endif +#ifdef USE_SELECT + LOG_SELECT(" ", "OutPinLevel", this->out_pin_level_select_); +#endif } -void DFRobotSEN0609Component::setup() { this->queue_configuration_(); } +void DFRobotSEN0609Component::setup() { +#ifdef USE_SWITCH + // The module forgets the LED states at power off + for (uint8_t i = SWITCH_RUN_LED; i <= SWITCH_OUT_LED; i++) { + switch_::Switch *sw = this->switches_[i]; + if (sw == nullptr) + continue; + const bool on = sw->get_initial_state_with_restore_mode().value_or(true); + this->publish_switch_(static_cast(i), on != sw->is_inverted()); + } +#endif + this->queue_configuration_(); +} void DFRobotSEN0609Component::loop() { uint8_t buf[MAX_LINE_LENGTH]; @@ -204,6 +262,8 @@ void DFRobotSEN0609Component::handle_line_(char *line) { if (starts_with(line, ESPHOME_PSTR("Done")) || answer_means_done(this->operation_, line)) { this->finish_operation_(); + } else if (starts_with(line, ESPHOME_PSTR("Response "))) { + this->parse_response_(line + 9); } else if (contains(line, ESPHOME_PSTR("not stopped"))) { // Stop the sensor before the next attempt this->stopped_ = false; @@ -295,11 +355,30 @@ void DFRobotSEN0609Component::publish_target_(float distance, float speed, float } #endif +void DFRobotSEN0609Component::parse_response_(const char *args) { + this->response_count_ = 0; + while (this->response_count_ < MAX_RESPONSE_VALUES) { + char *end; + const float value = strtof(args, &end); + // strtof also takes "nan" and "inf", which no setting can hold + if (end == args || !std::isfinite(value)) + break; + this->response_[this->response_count_++] = value; + args = end; + } +} + void DFRobotSEN0609Component::queue_configuration_() { - // The mode and the UART output are applied at every boot instead of being saved + // The mode, the UART output and the LEDs are applied at every boot instead of being saved uint16_t writes = param_bit(PARAM_RUN_APP); if (this->mode_ == RunMode::RUN_MODE_PRESENCE) writes |= param_bit(PARAM_UART_OUTPUT); +#ifdef USE_SWITCH + if (this->switches_[SWITCH_RUN_LED] != nullptr) + writes |= param_bit(PARAM_RUN_LED); + if (this->switches_[SWITCH_OUT_LED] != nullptr) + writes |= param_bit(PARAM_OUT_LED); +#endif this->pending_writes_ |= writes; this->pending_reads_ |= this->wanted_reads_(); } @@ -311,6 +390,20 @@ uint16_t DFRobotSEN0609Component::wanted_reads_() const { reads |= param_bit(PARAM_SW_VERSION); if (this->hardware_version_text_sensor_ != nullptr) reads |= param_bit(PARAM_HW_VERSION); +#endif +#ifdef USE_NUMBER + for (uint8_t i = 0; i < NUMBER_COUNT; i++) { + if (this->numbers_[i] != nullptr) + reads |= param_bit(static_cast(progmem_read_byte(&NUMBER_PARAMS[i]))); + } +#endif +#ifdef USE_SWITCH + if (this->switches_[SWITCH_MICRO_MOTION] != nullptr) + reads |= param_bit(PARAM_MICRO_MOTION); +#endif +#ifdef USE_SELECT + if (this->out_pin_level_select_ != nullptr) + reads |= param_bit(PARAM_GPIO_MODE); #endif return reads; } @@ -330,6 +423,86 @@ void DFRobotSEN0609Component::check_frames_(uint32_t now) { this->queue_configuration_(); } +void DFRobotSEN0609Component::request_write_(Param param) { + const uint32_t now = App.get_loop_component_start_time(); + this->pending_writes_ |= param_bit(param); + this->last_change_ms_ = now; + if (this->operation_ != Operation::OPERATION_NONE || this->stopped_) + return; + // Start a new sequence a little later, so changes made together end in one flash write. + // A longer hold, such as the back-off from a module that does not answer, is not cut short. + const uint32_t elapsed = now - this->hold_start_ms_; + if (elapsed >= this->hold_ms_ || this->hold_ms_ - elapsed < COALESCE_MS) + this->hold_(COALESCE_MS); +} + +#ifdef USE_NUMBER +void DFRobotSEN0609Component::control_number(NumberSetting setting, float value) { + // The module needs min_range <= trigger_range <= max_range; a value not read yet is NAN and passes + const auto &values = this->number_values_; + const float min_range = setting == NUMBER_MIN_RANGE ? value : values[NUMBER_MIN_RANGE]; + const float max_range = setting == NUMBER_MAX_RANGE ? value : values[NUMBER_MAX_RANGE]; + const float trigger_range = setting == NUMBER_TRIGGER_RANGE ? value : values[NUMBER_TRIGGER_RANGE]; + if (setting <= NUMBER_TRIGGER_RANGE && + (min_range > max_range || trigger_range < min_range || trigger_range > max_range)) { + ESP_LOGW(TAG, "Ranges must keep minimum <= trigger <= maximum, not written"); + if (!std::isnan(this->number_values_[setting])) + this->publish_number_(setting, this->number_values_[setting]); + return; + } + this->changed_numbers_ |= 1U << setting; + this->publish_number_(setting, value); + this->request_write_(static_cast(progmem_read_byte(&NUMBER_PARAMS[setting]))); +} + +void DFRobotSEN0609Component::forget_number_changes_(Param param, bool unknown) { + for (uint8_t i = 0; i < NUMBER_COUNT; i++) { + if (progmem_read_byte(&NUMBER_PARAMS[i]) != param) + continue; + this->changed_numbers_ &= ~(1U << i); + if (unknown) + this->publish_number_(static_cast(i), NAN); + } +} + +void DFRobotSEN0609Component::publish_number_(NumberSetting setting, float value) { + this->number_values_[setting] = value; + if (this->numbers_[setting] != nullptr) + this->numbers_[setting]->publish_state(value); +} +#endif + +#ifdef USE_SWITCH +void DFRobotSEN0609Component::control_switch(SwitchSetting setting, bool state) { + this->publish_switch_(setting, state); + switch (setting) { + case SWITCH_MICRO_MOTION: + this->request_write_(PARAM_MICRO_MOTION); + break; + case SWITCH_RUN_LED: + this->request_write_(PARAM_RUN_LED); + break; + default: + this->request_write_(PARAM_OUT_LED); + break; + } +} + +void DFRobotSEN0609Component::publish_switch_(SwitchSetting setting, bool state) { + this->switch_states_[setting] = state; + if (this->switches_[setting] != nullptr) + this->switches_[setting]->publish_state(state); +} +#endif + +#ifdef USE_SELECT +void DFRobotSEN0609Component::control_out_pin_level(size_t index) { + this->out_pin_level_ = static_cast(index); + this->out_pin_level_select_->publish_state(index); + this->request_write_(PARAM_GPIO_MODE); +} +#endif + void DFRobotSEN0609Component::hold_(uint32_t duration_ms) { this->hold_start_ms_ = App.get_loop_component_start_time(); this->hold_ms_ = duration_ms; @@ -338,7 +511,7 @@ void DFRobotSEN0609Component::hold_(uint32_t duration_ms) { void DFRobotSEN0609Component::run_sequencer_() { const uint32_t now = App.get_loop_component_start_time(); if (this->operation_ != Operation::OPERATION_NONE) { - const bool slow = this->operation_ == Operation::OPERATION_STOP; + const bool slow = this->operation_ == Operation::OPERATION_STOP || this->operation_ == Operation::OPERATION_SAVE; if (now - this->sent_ms_ < (slow ? LONG_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) return; ESP_LOGD(TAG, "No answer to '%s'", this->command_); @@ -348,12 +521,19 @@ void DFRobotSEN0609Component::run_sequencer_() { if (now - this->hold_start_ms_ < this->hold_ms_) return; - const bool work_pending = this->pending_writes_ != 0 || this->pending_reads_ != 0; + const bool work_pending = this->save_pending_ || this->pending_writes_ != 0 || this->pending_reads_ != 0; Operation operation; Param param = PARAM_COUNT; if (!work_pending) { if (!this->stopped_) { this->check_frames_(now); + // Work given up on is tried again in a later sequence, so the sensor runs in between + if ((this->deferred_writes_ != 0 || this->deferred_save_) && now - this->deferred_ms_ >= GIVE_UP_BACKOFF_MS) { + this->pending_writes_ |= this->deferred_writes_; + this->save_pending_ = this->save_pending_ || this->deferred_save_; + this->deferred_writes_ = 0; + this->deferred_save_ = false; + } return; } operation = Operation::OPERATION_START; @@ -362,25 +542,57 @@ void DFRobotSEN0609Component::run_sequencer_() { } else if (this->pending_writes_ != 0) { operation = Operation::OPERATION_WRITE; param = static_cast(__builtin_ctz(this->pending_writes_)); + } else if (this->save_pending_) { + // Changes made within a second of each other share one flash write, also while a sequence runs + if (now - this->last_change_ms_ < COALESCE_MS) + return; + operation = Operation::OPERATION_SAVE; } else { operation = Operation::OPERATION_READ; param = static_cast(__builtin_ctz(this->pending_reads_)); } const uint16_t bit = param_bit(param); - uint16_t &pending = operation == Operation::OPERATION_WRITE ? this->pending_writes_ : this->pending_reads_; - if (!this->build_command_(operation, param)) { - ESP_LOGE(TAG, "No command for %s of parameter %u", LOG_STR_ARG(operation_to_str(operation)), - static_cast(param)); - pending &= ~bit; + bool built = this->build_command_(operation, param); + if (!built && operation == Operation::OPERATION_WRITE) { + // The value this write is paired with is unknown: read it first, once, and keep the write pending + if ((this->paired_reads_ & bit) == 0) + this->pending_reads_ |= this->wanted_reads_() & bit; + if ((this->pending_reads_ & bit) != 0) { + this->paired_reads_ |= bit; + operation = Operation::OPERATION_READ; + built = this->build_command_(operation, param); + } + } + if (!built) { + if (operation == Operation::OPERATION_WRITE) { + ESP_LOGW(TAG, "Setting not written: the value it is paired with could not be read"); + this->pending_writes_ &= ~bit; + this->paired_reads_ &= ~bit; +#ifdef USE_NUMBER + // Both values come from the read that failed, so what the module holds is unknown + this->forget_number_changes_(param, true); +#endif + } else { + ESP_LOGE(TAG, "No command for %s of parameter %u", LOG_STR_ARG(operation_to_str(operation)), + static_cast(param)); + this->pending_reads_ &= ~bit; + } this->sequence_failed_ = true; this->status_set_warning(); return; } - if (operation == Operation::OPERATION_WRITE) - pending &= ~bit; + // Cleared now, so a change made while the write is on its way is written again + if (operation == Operation::OPERATION_WRITE) { + this->pending_writes_ &= ~bit; + this->paired_reads_ &= ~bit; +#ifdef USE_NUMBER + this->forget_number_changes_(param, false); +#endif + } this->operation_ = operation; this->param_ = param; + this->response_count_ = 0; this->echo_seen_ = false; this->version_received_ = false; this->sent_ms_ = now; @@ -399,6 +611,9 @@ bool DFRobotSEN0609Component::build_command_(Operation operation, Param param) { case Operation::OPERATION_START: ESPHOME_strncpy_P(buf, ESPHOME_PSTR("sensorStart"), len); return true; + case Operation::OPERATION_SAVE: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("saveConfig"), len); + return true; case Operation::OPERATION_READ: ESPHOME_strncpy_P(buf, reinterpret_cast(ReadCommands::get_progmem_str(param, 0)), len); return buf[0] != '\0'; @@ -413,10 +628,149 @@ bool DFRobotSEN0609Component::build_command_(Operation operation, Param param) { case PARAM_UART_OUTPUT: ESPHOME_strncpy_P(buf, ESPHOME_PSTR("setUartOutput 1 1 1 1.0"), len); return true; +#ifdef USE_SWITCH + case PARAM_MICRO_MOTION: + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setMicroMotion %u"), + static_cast(this->switch_states_[SWITCH_MICRO_MOTION])); + return true; + case PARAM_RUN_LED: + case PARAM_OUT_LED: { + // 0 turns the LED on + const bool on = this->switch_states_[led_switch(param)]; + this->sent_led_on_ = on; + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setLedMode %u %u"), param == PARAM_RUN_LED ? 1U : 2U, on ? 0U : 1U); + return true; + } +#endif +#ifdef USE_SELECT + case PARAM_GPIO_MODE: + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setGpioMode 1 %u"), static_cast(this->out_pin_level_)); + return true; +#endif + default: + break; + } + +#ifdef USE_NUMBER + const auto &values = this->number_values_; + float first = NAN; + float second = 0.0f; + ESPHOME_PGM_P format = nullptr; + switch (param) { + case PARAM_RANGE: + first = values[NUMBER_MIN_RANGE]; + second = values[NUMBER_MAX_RANGE]; + format = ESPHOME_PSTR("setRange %.2f %.2f"); + break; + case PARAM_TRIG_RANGE: + first = values[NUMBER_TRIGGER_RANGE]; + format = ESPHOME_PSTR("setTrigRange %.2f"); + break; + case PARAM_SENSITIVITY: + first = values[NUMBER_HOLD_SENSITIVITY]; + second = values[NUMBER_TRIGGER_SENSITIVITY]; + format = ESPHOME_PSTR("setSensitivity %.0f %.0f"); + break; + case PARAM_LATENCY: + first = values[NUMBER_ON_LATENCY]; + second = values[NUMBER_OFF_LATENCY]; + format = ESPHOME_PSTR("setLatency %.2f %.1f"); + break; + case PARAM_INHIBIT: + first = values[NUMBER_INHIBIT_TIME]; + format = ESPHOME_PSTR("setInhibit %.2f"); + break; + case PARAM_THR_FACTOR: + first = values[NUMBER_THRESHOLD_FACTOR]; + format = ESPHOME_PSTR("setThrFactor %.0f"); + break; default: - buf[0] = '\0'; return false; } + // Commands that take two values need both read from the module first + if (std::isnan(first) || std::isnan(second)) + return false; + ESPHOME_snprintf_P(buf, len, format, first, second); + return true; +#else + return false; +#endif +} + +bool DFRobotSEN0609Component::apply_read_(Param param) { + const float *r = this->response_; + const uint8_t count = this->response_count_; + // A change still waiting to be written is newer than what the module holds + const bool write_pending = (this->pending_writes_ & param_bit(param)) != 0; +#ifdef USE_NUMBER + // Checked for each number: publishing runs automations, which may change the next one + auto read_number = [this](NumberSetting setting, float value) { + if ((this->changed_numbers_ & (1U << setting)) == 0) + this->publish_number_(setting, value); + }; +#endif + switch (param) { +#ifdef USE_NUMBER + case PARAM_RANGE: + if (count < 2) + return false; + read_number(NUMBER_MIN_RANGE, r[0]); + read_number(NUMBER_MAX_RANGE, r[1]); + return true; + case PARAM_TRIG_RANGE: + if (count < 1) + return false; + read_number(NUMBER_TRIGGER_RANGE, r[0]); + return true; + case PARAM_SENSITIVITY: + if (count < 2) + return false; + read_number(NUMBER_HOLD_SENSITIVITY, r[0]); + read_number(NUMBER_TRIGGER_SENSITIVITY, r[1]); + return true; + case PARAM_LATENCY: + if (count < 2) + return false; + read_number(NUMBER_ON_LATENCY, r[0]); + read_number(NUMBER_OFF_LATENCY, r[1]); + return true; + case PARAM_INHIBIT: + if (count < 1) + return false; + read_number(NUMBER_INHIBIT_TIME, r[0]); + return true; + case PARAM_THR_FACTOR: + if (count < 1) + return false; + read_number(NUMBER_THRESHOLD_FACTOR, r[0]); + return true; +#endif +#ifdef USE_SWITCH + case PARAM_MICRO_MOTION: + if (count < 1) + return false; + if (!write_pending) + this->publish_switch_(SWITCH_MICRO_MOTION, r[0] != 0.0f); + return true; +#endif +#ifdef USE_SELECT + case PARAM_GPIO_MODE: + // Response + if (count < 2 || (r[1] != 0.0f && r[1] != 1.0f)) + return false; + if (!write_pending) { + this->out_pin_level_ = static_cast(r[1]); + this->out_pin_level_select_->publish_state(this->out_pin_level_); + } + return true; +#endif + case PARAM_SW_VERSION: + case PARAM_HW_VERSION: + // The versions are published as their lines arrive + return this->version_received_; + default: + return true; + } } void DFRobotSEN0609Component::finish_operation_() { @@ -432,15 +786,29 @@ void DFRobotSEN0609Component::finish_operation_() { this->sequence_failed_ = false; break; case Operation::OPERATION_WRITE: + this->deferred_writes_ &= ~param_bit(this->param_); + if (PERSISTENT_PARAMS & param_bit(this->param_)) + this->save_pending_ = true; +#ifdef USE_SWITCH + if (is_led(this->param_)) { + const uint8_t bit = 1U << led_switch(this->param_); + this->led_known_ |= bit; + this->led_on_ = this->sent_led_on_ ? (this->led_on_ | bit) : (this->led_on_ & ~bit); + } +#endif + break; + case Operation::OPERATION_SAVE: + this->save_pending_ = false; + this->deferred_save_ = false; break; case Operation::OPERATION_READ: - // The versions are published as their lines arrive - if (!this->version_received_) { - ESP_LOGW(TAG, "No version in the answer to '%s'", this->command_); + if (!this->apply_read_(this->param_)) { + ESP_LOGW(TAG, "Malformed answer to '%s'", this->command_); this->fail_operation_(); return; } this->pending_reads_ &= ~param_bit(this->param_); + this->paired_reads_ &= ~param_bit(this->param_); break; default: return; @@ -460,15 +828,16 @@ void DFRobotSEN0609Component::fail_operation_() { const Operation operation = this->operation_; this->operation_ = Operation::OPERATION_NONE; this->hold_(COMMAND_GAP_MS); - // The writes are the boot configuration, which the frames depend on: keep them until they succeed - if (operation == Operation::OPERATION_WRITE) + const bool keep_write = operation == Operation::OPERATION_WRITE && (FRAME_PARAMS & param_bit(this->param_)) != 0; + const bool retry = ++attempts < MAX_ATTEMPTS; + if (operation == Operation::OPERATION_WRITE && (retry || keep_write)) this->pending_writes_ |= param_bit(this->param_); - if (++attempts < MAX_ATTEMPTS) + if (retry) return; attempts = 0; this->status_set_warning(); - if (aux || operation == Operation::OPERATION_WRITE) { + if (aux || keep_write) { // Without stop, start or the configuration the sensor cannot run as set up; try again later ESP_LOGW(TAG, "Command '%s' failed, trying again later", this->command_); this->hold_ms_ = GIVE_UP_BACKOFF_MS; @@ -476,8 +845,39 @@ void DFRobotSEN0609Component::fail_operation_() { } ESP_LOGW(TAG, "Giving up on %s '%s'", LOG_STR_ARG(operation_to_str(operation)), this->command_); this->sequence_failed_ = true; - if (operation == Operation::OPERATION_READ) - this->pending_reads_ &= ~param_bit(this->param_); + switch (operation) { + case Operation::OPERATION_WRITE: +#ifdef USE_SWITCH + if (is_led(this->param_)) { + // A newer change is written anyway + if ((this->pending_writes_ & param_bit(this->param_)) != 0) + break; + // The LEDs cannot be read: show the state the module last accepted, or write this one again later + const uint8_t bit = 1U << led_switch(this->param_); + if ((this->led_known_ & bit) != 0) { + this->publish_switch_(led_switch(this->param_), (this->led_on_ & bit) != 0); + } else { + this->deferred_writes_ |= param_bit(this->param_); + this->deferred_ms_ = App.get_loop_component_start_time(); + } + break; + } +#endif + // The entity shows a value the module never got; show the module's value again + this->pending_reads_ |= this->wanted_reads_() & param_bit(this->param_); + break; + case Operation::OPERATION_SAVE: + // The entities show the changes as kept, but unsaved they are lost at power off: save in a later sequence + this->save_pending_ = false; + this->deferred_save_ = true; + this->deferred_ms_ = App.get_loop_component_start_time(); + break; + case Operation::OPERATION_READ: + this->pending_reads_ &= ~param_bit(this->param_); + break; + default: + break; + } } } // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h index 7d5baf97a0c..719aa6f5fe0 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -12,22 +12,66 @@ #ifdef USE_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif #include "esphome/components/uart/uart.h" +#include + namespace esphome::dfrobot_sen0609 { static constexpr uint8_t MAX_LINE_LENGTH = 80; static constexpr uint8_t MAX_COMMAND_LENGTH = 40; +static constexpr uint8_t MAX_RESPONSE_VALUES = 3; enum class RunMode : uint8_t { RUN_MODE_PRESENCE = 0, RUN_MODE_SPEED_AND_DISTANCE = 1, }; +/// Settings shown as number entities. +enum NumberSetting : uint8_t { + NUMBER_MIN_RANGE, + NUMBER_MAX_RANGE, + NUMBER_TRIGGER_RANGE, + NUMBER_HOLD_SENSITIVITY, + NUMBER_TRIGGER_SENSITIVITY, + NUMBER_ON_LATENCY, + NUMBER_OFF_LATENCY, + NUMBER_INHIBIT_TIME, + NUMBER_THRESHOLD_FACTOR, + NUMBER_COUNT, +}; + +/// Settings shown as switch entities. +enum SwitchSetting : uint8_t { + SWITCH_MICRO_MOTION, + SWITCH_RUN_LED, + SWITCH_OUT_LED, + SWITCH_COUNT, +}; + /// Module parameters; a write or read of each is one command. Pending work runs in this order. enum Param : uint8_t { PARAM_RUN_APP, PARAM_UART_OUTPUT, + PARAM_RANGE, + PARAM_TRIG_RANGE, + PARAM_SENSITIVITY, + PARAM_LATENCY, + PARAM_INHIBIT, + PARAM_THR_FACTOR, + PARAM_MICRO_MOTION, + PARAM_GPIO_MODE, + PARAM_RUN_LED, + PARAM_OUT_LED, PARAM_SW_VERSION, PARAM_HW_VERSION, PARAM_COUNT, @@ -38,6 +82,7 @@ enum class Operation : uint8_t { OPERATION_STOP, OPERATION_START, OPERATION_WRITE, + OPERATION_SAVE, OPERATION_READ, }; @@ -54,6 +99,9 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice SUB_TEXT_SENSOR(version) SUB_TEXT_SENSOR(hardware_version) #endif +#ifdef USE_SELECT + SUB_SELECT(out_pin_level) +#endif public: explicit DFRobotSEN0609Component(RunMode mode); @@ -62,17 +110,32 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice void loop() override; void dump_config() override; +#ifdef USE_NUMBER + void set_number(NumberSetting setting, number::Number *number) { this->numbers_[setting] = number; } + void control_number(NumberSetting setting, float value); +#endif +#ifdef USE_SWITCH + void set_switch(SwitchSetting setting, switch_::Switch *sw) { this->switches_[setting] = sw; } + void control_switch(SwitchSetting setting, bool state); +#endif +#ifdef USE_SELECT + void control_out_pin_level(size_t index); +#endif + protected: void readline_(uint8_t c); void handle_line_(char *line); void handle_frame_(char *line); void log_bad_frame_(const LogString *problem, const char *line); + void parse_response_(const char *args); void queue_configuration_(); uint16_t wanted_reads_() const; + void request_write_(Param param); void run_sequencer_(); void check_frames_(uint32_t now); bool build_command_(Operation operation, Param param); + bool apply_read_(Param param); void finish_operation_(); void fail_operation_(); void hold_(uint32_t duration_ms); @@ -80,6 +143,13 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice return this->operation_ == Operation::OPERATION_STOP || this->operation_ == Operation::OPERATION_START; } +#ifdef USE_NUMBER + void publish_number_(NumberSetting setting, float value); + void forget_number_changes_(Param param, bool unknown); +#endif +#ifdef USE_SWITCH + void publish_switch_(SwitchSetting setting, bool state); +#endif #ifdef USE_SENSOR void publish_target_(float distance, float speed, float energy); Deduplicator distance_dedup_; @@ -87,26 +157,56 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice Deduplicator energy_dedup_; #endif +#ifdef USE_NUMBER + std::array numbers_{}; + std::array number_values_; +#endif +#ifdef USE_SWITCH + std::array switches_{}; + std::array switch_states_{}; +#endif + char line_[MAX_LINE_LENGTH]; char command_[MAX_COMMAND_LENGTH]{}; + float response_[MAX_RESPONSE_VALUES]{}; uint32_t sent_ms_{0}; uint32_t hold_start_ms_{0}; uint32_t hold_ms_{0}; uint32_t last_frame_ms_{0}; + uint32_t last_change_ms_{0}; + uint32_t deferred_ms_{0}; uint16_t pending_writes_{0}; uint16_t pending_reads_{0}; + // Reads made for a write whose paired value is unknown; the write is dropped when the read fails + uint16_t paired_reads_{0}; + // Writes given up on that are tried again in a later sequence + uint16_t deferred_writes_{0}; +#ifdef USE_NUMBER + // Numbers changed since their last write was sent, one bit per NumberSetting + uint16_t changed_numbers_{0}; +#endif RunMode mode_; Operation operation_{Operation::OPERATION_NONE}; Param param_{PARAM_COUNT}; uint8_t line_pos_{0}; + uint8_t response_count_{0}; + uint8_t out_pin_level_{0}; uint8_t attempts_{0}; uint8_t aux_attempts_{0}; +#ifdef USE_SWITCH + // The module cannot be asked for its LED states: the ones it last accepted, one bit per SwitchSetting + uint8_t led_known_{0}; + uint8_t led_on_{0}; + bool sent_led_on_{false}; +#endif bool line_overflow_{false}; bool read_failed_{false}; bool bad_frame_logged_{false}; bool echo_seen_{false}; bool version_received_{false}; bool stopped_{false}; + bool save_pending_{false}; + bool deferred_save_{false}; bool sequence_failed_{false}; }; diff --git a/esphome/components/dfrobot_sen0609/number/__init__.py b/esphome/components/dfrobot_sen0609/number/__init__.py new file mode 100644 index 00000000000..668ad929c50 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/number/__init__.py @@ -0,0 +1,194 @@ +from typing import NamedTuple + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MAX_RANGE, + CONF_MIN_RANGE, + CONF_MODEL, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + ENTITY_CATEGORY_CONFIG, + ICON_MOTION_SENSOR, + ICON_RULER, + ICON_TIMELAPSE, + UNIT_METER, + UNIT_SECOND, +) +from esphome.core import CORE +from esphome.types import ConfigType + +from .. import ( + CONF_DFROBOT_SEN0609_ID, + MAX_RANGES, + MODE_PRESENCE, + MODE_SPEED_AND_DISTANCE, + DFRobotSEN0609Component, + dfrobot_sen0609_ns, + final_validate_mode, + get_hub_config, +) + +DFRobotSEN0609Number = dfrobot_sen0609_ns.class_("DFRobotSEN0609Number", number.Number) +NumberSetting = dfrobot_sen0609_ns.enum("NumberSetting") + +CONF_HOLD_SENSITIVITY = "hold_sensitivity" +CONF_INHIBIT_TIME = "inhibit_time" +CONF_OFF_LATENCY = "off_latency" +CONF_ON_LATENCY = "on_latency" +CONF_THRESHOLD_FACTOR = "threshold_factor" +CONF_TRIGGER_RANGE = "trigger_range" +CONF_TRIGGER_SENSITIVITY = "trigger_sensitivity" + + +class Setting(NamedTuple): + key: str + setting: cg.MockObj + min_value: float + max_value: float | None # None: the model's longest range + step: float + mode: str | None # None: both modes + schema: cv.Schema + + +def _distance(icon: str = ICON_RULER) -> cv.Schema: + return number.number_schema( + DFRobotSEN0609Number, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=icon, + unit_of_measurement=UNIT_METER, + ) + + +def _duration() -> cv.Schema: + return number.number_schema( + DFRobotSEN0609Number, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_TIMELAPSE, + unit_of_measurement=UNIT_SECOND, + ) + + +def _plain() -> cv.Schema: + return number.number_schema( + DFRobotSEN0609Number, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + ) + + +SETTINGS = ( + Setting( + CONF_MIN_RANGE, + NumberSetting.NUMBER_MIN_RANGE, + 0.6, + None, + 0.1, + None, + _distance(), + ), + Setting( + CONF_MAX_RANGE, + NumberSetting.NUMBER_MAX_RANGE, + 0.6, + None, + 0.1, + None, + _distance(), + ), + Setting( + CONF_TRIGGER_RANGE, + NumberSetting.NUMBER_TRIGGER_RANGE, + 0.6, + None, + 0.1, + MODE_PRESENCE, + _distance(ICON_MOTION_SENSOR), + ), + Setting( + CONF_HOLD_SENSITIVITY, + NumberSetting.NUMBER_HOLD_SENSITIVITY, + 0, + 9, + 1, + MODE_PRESENCE, + _plain(), + ), + Setting( + CONF_TRIGGER_SENSITIVITY, + NumberSetting.NUMBER_TRIGGER_SENSITIVITY, + 0, + 9, + 1, + MODE_PRESENCE, + _plain(), + ), + Setting( + CONF_ON_LATENCY, + NumberSetting.NUMBER_ON_LATENCY, + 0, + 2, + 0.01, + MODE_PRESENCE, + _duration(), + ), + Setting( + CONF_OFF_LATENCY, + NumberSetting.NUMBER_OFF_LATENCY, + 2, + 1500, + 0.5, + MODE_PRESENCE, + _duration(), + ), + Setting( + CONF_INHIBIT_TIME, + NumberSetting.NUMBER_INHIBIT_TIME, + 0.1, + 60, + 0.1, + MODE_PRESENCE, + _duration(), + ), + Setting( + CONF_THRESHOLD_FACTOR, + NumberSetting.NUMBER_THRESHOLD_FACTOR, + 0, + 65535, + 1, + MODE_SPEED_AND_DISTANCE, + _plain(), + ), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + **{cv.Optional(s.key): s.schema for s in SETTINGS}, + } +) + +FINAL_VALIDATE_SCHEMA = final_validate_mode( + {s.key: s.mode for s in SETTINGS if s.mode is not None} +) + + +async def to_code(config: ConfigType) -> None: + hub_id = config[CONF_DFROBOT_SEN0609_ID] + hub = await cg.get_variable(hub_id) + max_range = MAX_RANGES[get_hub_config(CORE.config, hub_id)[CONF_MODEL]] + numbers = number.sub_numbers(config, parent=hub) + for s in SETTINGS: + await numbers( + s.key, + lambda n, setting=s.setting: hub.set_number(setting, n), + s.setting, + min_value=s.min_value, + max_value=max_range if s.max_value is None else s.max_value, + step=s.step, + ) diff --git a/esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h b/esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h new file mode 100644 index 00000000000..d241ec75e10 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class DFRobotSEN0609Number final : public number::Number, public Parented { + public: + explicit DFRobotSEN0609Number(NumberSetting setting) : setting_(setting) {} + + protected: + void control(float value) override { this->parent_->control_number(this->setting_, value); } + + NumberSetting setting_; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/select/__init__.py b/esphome/components/dfrobot_sen0609/select/__init__.py new file mode 100644 index 00000000000..a68ed8c8729 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/select/__init__.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import select +from esphome.components.const import CONF_OUT_PIN_LEVEL +import esphome.config_validation as cv +from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE +from esphome.types import ConfigType + +from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component, dfrobot_sen0609_ns + +OutPinLevelSelect = dfrobot_sen0609_ns.class_("OutPinLevelSelect", select.Select) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_OUT_PIN_LEVEL): select.select_schema( + OutPinLevelSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_SCALE, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + selects = select.sub_selects(config, parent=hub) + # The level the OUT pin has while a target is present; the index is the module's value + await selects( + CONF_OUT_PIN_LEVEL, hub.set_out_pin_level_select, options=["low", "high"] + ) diff --git a/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h b/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h new file mode 100644 index 00000000000..0ea2fdd8974 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class OutPinLevelSelect final : public select::Select, public Parented { + public: + // User provided, not "= default": `new(p) OutPinLevelSelect()` would zero-fill .bss that is already zero. + OutPinLevelSelect() {} + + protected: + void control(size_t index) override { this->parent_->control_out_pin_level(index); } +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/switch/__init__.py b/esphome/components/dfrobot_sen0609/switch/__init__.py new file mode 100644 index 00000000000..3baa9656f4c --- /dev/null +++ b/esphome/components/dfrobot_sen0609/switch/__init__.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_SWITCH, + ENTITY_CATEGORY_CONFIG, + ICON_LIGHTBULB, + ICON_MOTION_SENSOR, +) +from esphome.types import ConfigType + +from .. import ( + CONF_DFROBOT_SEN0609_ID, + MODE_SPEED_AND_DISTANCE, + DFRobotSEN0609Component, + dfrobot_sen0609_ns, + final_validate_mode, +) + +DFRobotSEN0609Switch = dfrobot_sen0609_ns.class_("DFRobotSEN0609Switch", switch.Switch) +SwitchSetting = dfrobot_sen0609_ns.enum("SwitchSetting") + +CONF_MICRO_MOTION = "micro_motion" +CONF_OUT_LED = "out_led" +CONF_RUN_LED = "run_led" + + +def _led() -> cv.Schema: + # The module forgets the LED state at power off, so it is restored from here + return switch.switch_schema( + DFRobotSEN0609Switch, + default_restore_mode="RESTORE_DEFAULT_ON", + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_LIGHTBULB, + ) + + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_MICRO_MOTION): switch.switch_schema( + DFRobotSEN0609Switch, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + ), + cv.Optional(CONF_RUN_LED): _led(), + cv.Optional(CONF_OUT_LED): _led(), +} + +FINAL_VALIDATE_SCHEMA = final_validate_mode( + {CONF_MICRO_MOTION: MODE_SPEED_AND_DISTANCE} +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + switches = switch.sub_switches(config, parent=hub) + for key, setting in ( + (CONF_MICRO_MOTION, SwitchSetting.SWITCH_MICRO_MOTION), + (CONF_RUN_LED, SwitchSetting.SWITCH_RUN_LED), + (CONF_OUT_LED, SwitchSetting.SWITCH_OUT_LED), + ): + await switches( + key, lambda s, setting=setting: hub.set_switch(setting, s), setting + ) diff --git a/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h b/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h new file mode 100644 index 00000000000..dc2def0d809 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class DFRobotSEN0609Switch final : public switch_::Switch, public Parented { + public: + explicit DFRobotSEN0609Switch(SwitchSetting setting) : setting_(setting) {} + + protected: + void write_state(bool state) override { this->parent_->control_switch(this->setting_, state); } + + SwitchSetting setting_; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/tests/components/dfrobot_sen0609/benchmark.yaml b/tests/components/dfrobot_sen0609/benchmark.yaml index 9a41ec7cf26..2a35b04794d 100644 --- a/tests/components/dfrobot_sen0609/benchmark.yaml +++ b/tests/components/dfrobot_sen0609/benchmark.yaml @@ -3,3 +3,6 @@ binary_sensor: sensor: text_sensor: +number: +switch: +select: diff --git a/tests/components/dfrobot_sen0609/common-speed.yaml b/tests/components/dfrobot_sen0609/common-speed.yaml index b4063a6f6b2..623d808fb75 100644 --- a/tests/components/dfrobot_sen0609/common-speed.yaml +++ b/tests/components/dfrobot_sen0609/common-speed.yaml @@ -1,5 +1,6 @@ dfrobot_sen0609: id: sen0609_speed + model: SEN0610 mode: speed_and_distance binary_sensor: @@ -17,3 +18,19 @@ sensor: name: Target Speed target_energy: name: Target Energy + +number: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + min_range: + name: Minimum Range + max_range: + name: Maximum Range + threshold_factor: + name: Threshold Factor + +switch: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + micro_motion: + name: Micro Motion diff --git a/tests/components/dfrobot_sen0609/common.h b/tests/components/dfrobot_sen0609/common.h index 08b9d86366a..7e4ea62f84d 100644 --- a/tests/components/dfrobot_sen0609/common.h +++ b/tests/components/dfrobot_sen0609/common.h @@ -39,6 +39,22 @@ class FakeUART : public uart::UARTComponent { size_t rx_pos{0}; }; +// The hub publishes read values; writes are driven through control_number() directly. +class TestNumber : public number::Number { + protected: + void control(float value) override {} +}; + +class TestSwitch : public switch_::Switch { + protected: + void write_state(bool state) override {} +}; + +class TestSelect : public select::Select { + protected: + void control(size_t index) override {} +}; + class SEN0609Test : public ::testing::Test { protected: explicit SEN0609Test(RunMode mode = RunMode::RUN_MODE_PRESENCE) : hub_(mode) {} @@ -60,6 +76,14 @@ class SEN0609Test : public ::testing::Test { this->run_(); } + // Feeds a presence frame every second, so the frame watchdog stays quiet, for `seconds` or until the hub sends + void feed_frames_(int seconds) { + for (int i = 0; i < seconds && this->uart_.tx.empty(); i++) { + this->feed_("$DFHPD,0, , , *\r\n"); + this->run_(1000); + } + } + // Runs the loop until the hub writes a full command line, and returns it without the line end std::string next_command_(uint32_t max_ms = 5000) { for (uint32_t waited = 0; waited <= max_ms; waited += 16) { diff --git a/tests/components/dfrobot_sen0609/common.yaml b/tests/components/dfrobot_sen0609/common.yaml index 1f3887be631..aca15b906fc 100644 --- a/tests/components/dfrobot_sen0609/common.yaml +++ b/tests/components/dfrobot_sen0609/common.yaml @@ -15,3 +15,37 @@ text_sensor: name: Software Version hardware_version: name: Hardware Version + +number: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + min_range: + name: Minimum Range + max_range: + name: Maximum Range + trigger_range: + name: Trigger Range + hold_sensitivity: + name: Hold Sensitivity + trigger_sensitivity: + name: Trigger Sensitivity + on_latency: + name: On Latency + off_latency: + name: Off Latency + inhibit_time: + name: Inhibit Time + +switch: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + run_led: + name: Run LED + out_led: + name: Out LED + +select: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + out_pin_level: + name: Out Pin Level diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp index 341ba09432f..aa39c4f6a4d 100644 --- a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp @@ -6,8 +6,10 @@ class SEN0609SequencerTest : public SEN0609Test { protected: void SetUp() override { SEN0609Test::SetUp(); + this->hub_.set_number(NUMBER_MIN_RANGE, &this->min_range_); + this->hub_.set_number(NUMBER_MAX_RANGE, &this->max_range_); + this->hub_.set_number(NUMBER_HOLD_SENSITIVITY, &this->hold_sensitivity_); this->hub_.set_version_text_sensor(&this->version_); - this->hub_.set_hardware_version_text_sensor(&this->hardware_version_); } // Runs the boot sequence with a module that answers everything @@ -16,36 +18,97 @@ class SEN0609SequencerTest : public SEN0609Test { this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); this->expect_and_answer_("sensorStart"); } + TestNumber min_range_; + TestNumber max_range_; + TestNumber hold_sensitivity_; text_sensor::TextSensor version_; - text_sensor::TextSensor hardware_version_; }; -TEST_F(SEN0609SequencerTest, BootAppliesModeAndReadsVersions) { +TEST_F(SEN0609SequencerTest, BootReadsSettingsWithoutSaving) { this->boot_(); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 7.0f); EXPECT_EQ(this->version_.state, "V1.0.4"); - EXPECT_EQ(this->hardware_version_.state, "V2.0"); EXPECT_EQ(this->next_command_(), ""); EXPECT_FALSE(this->hub_.status_has_warning()); } +TEST_F(SEN0609SequencerTest, ChangesMadeTogetherShareOneSave) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->hub_.control_number(NUMBER_MAX_RANGE, 10.0f); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + EXPECT_FLOAT_EQ(this->max_range_.state, 10.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 10.00"); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, WriteWaitsBeforeStarting) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + EXPECT_EQ(this->next_command_(500), ""); + EXPECT_EQ(this->next_command_(), "sensorStop"); +} + +TEST_F(SEN0609SequencerTest, NotStoppedStopsAndRetries) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + // The module was started behind our back + this->feed_("setRange 1.00 12.00\r\nsensor is not stopped\r\nDFRobot:/>"); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); +} + +TEST_F(SEN0609SequencerTest, MalformedAnswerIsRetried) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6\r\n"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); +} + +TEST_F(SEN0609SequencerTest, NonFiniteAnswerIsRetried) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response nan 12.0\r\n"); + this->expect_and_answer_("getRange", "Response 0.6 inf\r\n"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); +} + TEST_F(SEN0609SequencerTest, ErrorAnswerIsRetriedThenSkipped) { this->hub_.setup(); this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); for (int i = 0; i < 3; i++) { - ASSERT_EQ(this->next_command_(), "getSWV"); - this->feed_("getSWV\r\nError\r\nDFRobot:/>"); + ASSERT_EQ(this->next_command_(), "getRange"); + this->feed_("getRange\r\nError\r\nDFRobot:/>"); } - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); this->expect_and_answer_("sensorStart"); EXPECT_TRUE(this->hub_.status_has_warning()); - EXPECT_FALSE(this->version_.has_state()); + EXPECT_FALSE(this->min_range_.has_state()); } TEST_F(SEN0609SequencerTest, LateAnswerIsNotTakenForNextCommand) { @@ -53,15 +116,29 @@ TEST_F(SEN0609SequencerTest, LateAnswerIsNotTakenForNextCommand) { this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "getRange"); + ASSERT_EQ(this->next_command_(), "getSensitivity"); + // The answer to the last getRange arrives after it timed out + this->feed_("getRange\r\nResponse 0.6 12.0\r\nDone\r\nDFRobot:/>"); + this->answer_("getSensitivity", "Response 7 5\r\n"); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 7.0f); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); +} + +TEST_F(SEN0609SequencerTest, LateVersionLineIsStillUsed) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); for (int i = 0; i < 3; i++) ASSERT_EQ(this->next_command_(), "getSWV"); - ASSERT_EQ(this->next_command_(), "getHWV"); - // The answer to the last getSWV arrives after it timed out + ASSERT_EQ(this->next_command_(), "sensorStart"); + // The answer to the last getSWV arrives after it timed out; the version line names its value this->feed_("getSWV\r\nSoftwareVersion:V1.0.4\r\nDone\r\nDFRobot:/>"); - this->answer_("getHWV", "HardwareVersion:V2.0\r\n"); - this->expect_and_answer_("sensorStart"); - EXPECT_EQ(this->hardware_version_.state, "V2.0"); - // The late version line names its value, so it is still used + this->answer_("sensorStart"); EXPECT_EQ(this->version_.state, "V1.0.4"); } @@ -70,10 +147,11 @@ TEST_F(SEN0609SequencerTest, MissingVersionLineIsRetried) { this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); EXPECT_EQ(this->version_.state, "V1.0.4"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); this->expect_and_answer_("sensorStart"); EXPECT_FALSE(this->hub_.status_has_warning()); } @@ -91,8 +169,9 @@ TEST_F(SEN0609SequencerTest, FailedConfigurationIsRetriedLater) { ASSERT_EQ(this->next_command_(15000), "setRunApp 0"); this->answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); this->expect_and_answer_("sensorStart"); EXPECT_FALSE(this->hub_.status_has_warning()); } @@ -110,6 +189,157 @@ TEST_F(SEN0609SequencerTest, SilentModuleBacksOffAndKeepsWork) { EXPECT_EQ(this->next_command_(), "setRunApp 0"); } +TEST_F(SEN0609SequencerTest, PairedValueUnknownIsReadBeforeWrite) { + // No boot reads, so the trigger sensitivity paired with hold sensitivity is unknown + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 3.0f); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, PairedValueReadFailureDropsWrite) { + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "getSensitivity"); + this->feed_("getSensitivity\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_TRUE(this->hub_.status_has_warning()); + // Whether the change reached the module is unknown + EXPECT_TRUE(std::isnan(this->hold_sensitivity_.state)); +} + +TEST_F(SEN0609SequencerTest, ChangeAfterFailedBootReadIsWritten) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "getRange"); + this->feed_("getRange\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ChangeBeforeBootReadWaitsForPairedValue) { + this->hub_.setup(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + this->expect_and_answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); +} + +TEST_F(SEN0609SequencerTest, ChangeDuringWriteIsWrittenAgain) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->hub_.control_number(NUMBER_MIN_RANGE, 2.0f); + this->answer_("setRange 1.00 12.00"); + this->expect_and_answer_("setRange 2.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, ChangesDuringSequenceShareOneSave) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->hub_.control_number(NUMBER_MAX_RANGE, 10.0f); + this->answer_("setRange 1.00 12.00"); + this->expect_and_answer_("setRange 1.00 10.00"); + // The save waits a second after the last change + EXPECT_EQ(this->next_command_(500), ""); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, ReadAfterFailedWriteKeepsNewerPartnerChange) { + this->boot_(); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 9.0f); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setSensitivity 9 5"); + this->feed_("setSensitivity 9 5\r\nError\r\nDFRobot:/>"); + } + ASSERT_EQ(this->next_command_(), "getSensitivity"); + this->hub_.control_number(NUMBER_TRIGGER_SENSITIVITY, 4.0f); + this->answer_("getSensitivity", "Response 7 5\r\n"); + // The rejected hold sensitivity is put back, the newer trigger sensitivity is kept + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 7.0f); + this->expect_and_answer_("setSensitivity 7 4"); +} + +TEST_F(SEN0609SequencerTest, AutomationDuringReadKeepsItsChange) { + bool changed = false; + this->min_range_.add_on_state_callback([this, &changed](float) { + if (!changed) { + changed = true; + this->hub_.control_number(NUMBER_MAX_RANGE, 8.0f); + } + }); + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->max_range_.state, 8.0f); + this->expect_and_answer_("setRange 0.60 8.00"); +} + +TEST_F(SEN0609SequencerTest, ChangeDuringReadIsKept) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + ASSERT_EQ(this->next_command_(), "getRange"); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + this->expect_and_answer_("setRange 1.00 12.00"); +} + +TEST_F(SEN0609SequencerTest, FailedWriteShowsModuleValueAgain) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->feed_("setRange 1.00 12.00\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + EXPECT_TRUE(this->hub_.status_has_warning()); +} + TEST_F(SEN0609SequencerTest, AlreadyStoppedCountsAsStopped) { this->hub_.setup(); ASSERT_EQ(this->next_command_(), "sensorStop"); @@ -133,8 +363,9 @@ TEST_F(SEN0609SequencerTest, AlreadyStartedCountsAsStarted) { this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); // As left by an earlier sequence that failed this->hub_.status_set_warning(); ASSERT_EQ(this->next_command_(), "sensorStart"); @@ -165,10 +396,194 @@ TEST_F(SEN0609SequencerTest, MissingPresenceFramesReapplyConfiguration) { this->answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); this->expect_and_answer_("sensorStart"); EXPECT_FALSE(this->hub_.status_has_warning()); } +TEST_F(SEN0609SequencerTest, ChangeDuringBackOffKeepsBackOff) { + this->hub_.setup(); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "sensorStop"); + this->run_(2100); // the last attempt times out + EXPECT_TRUE(this->hub_.status_has_warning()); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + EXPECT_EQ(this->next_command_(20000), ""); + EXPECT_EQ(this->next_command_(15000), "sensorStop"); +} + +TEST_F(SEN0609SequencerTest, FailedSaveIsRetriedLater) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 12.00"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "saveConfig"); + this->feed_("saveConfig\r\nError\r\nDFRobot:/>"); + } + // The sensor runs again meanwhile + this->expect_and_answer_("sensorStart"); + EXPECT_TRUE(this->hub_.status_has_warning()); + this->feed_frames_(28); + EXPECT_EQ(this->uart_.tx, ""); + this->feed_frames_(4); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ErrorMentioningNoParameterIsNotASave) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 12.00"); + ASSERT_EQ(this->next_command_(), "saveConfig"); + this->feed_("saveConfig\r\nError: no parameter saved\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), "saveConfig"); +} + +TEST_F(SEN0609SequencerTest, MinRangeAboveMaxRangeIsRefused) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 13.0f); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + this->hub_.control_number(NUMBER_MAX_RANGE, 0.5f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + EXPECT_EQ(this->next_command_(3000), ""); +} + +TEST_F(SEN0609SequencerTest, TriggerRangeOutsideRangeIsRefused) { + TestNumber trigger_range; + this->hub_.set_number(NUMBER_TRIGGER_RANGE, &trigger_range); + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getTrigRange", "Response 6.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + this->hub_.control_number(NUMBER_TRIGGER_RANGE, 13.0f); + EXPECT_FLOAT_EQ(trigger_range.state, 6.0f); + // Lowering the maximum below the trigger range is refused too + this->hub_.control_number(NUMBER_MAX_RANGE, 5.0f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + this->hub_.control_number(NUMBER_MIN_RANGE, 7.0f); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + EXPECT_EQ(this->next_command_(3000), ""); + this->hub_.control_number(NUMBER_TRIGGER_RANGE, 8.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setTrigRange 8.00"); +} + +class SEN0609SettingsTest : public SEN0609Test { + protected: + void SetUp() override { + SEN0609Test::SetUp(); + this->run_led_.set_restore_mode(switch_::SWITCH_ALWAYS_ON); + this->run_led_.set_inverted(true); + this->out_led_.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + this->out_pin_level_.traits.set_options({"low", "high"}); + this->hub_.set_switch(SWITCH_MICRO_MOTION, &this->micro_motion_); + this->hub_.set_switch(SWITCH_RUN_LED, &this->run_led_); + this->hub_.set_switch(SWITCH_OUT_LED, &this->out_led_); + this->hub_.set_out_pin_level_select(&this->out_pin_level_); + this->hub_.set_hardware_version_text_sensor(&this->hardware_version_); + } + + void boot_() { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + // Inverted and on: the LED is off, which is mode 1 + this->expect_and_answer_("setLedMode 1 1"); + this->expect_and_answer_("setLedMode 2 1"); + this->expect_and_answer_("getMicroMotion", "Response 1\r\n"); + this->expect_and_answer_("getGpioMode 1", "Response 1 1\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + } + + TestSwitch micro_motion_; + TestSwitch run_led_; + TestSwitch out_led_; + TestSelect out_pin_level_; + text_sensor::TextSensor hardware_version_; +}; + +TEST_F(SEN0609SettingsTest, BootWritesLedsAndReadsSettings) { + this->boot_(); + EXPECT_TRUE(this->run_led_.state); + EXPECT_FALSE(this->out_led_.state); + EXPECT_TRUE(this->micro_motion_.state); + EXPECT_EQ(this->out_pin_level_.active_index(), optional(1)); + EXPECT_EQ(this->hardware_version_.state, "V2.0"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SettingsTest, FailedLedWriteShowsAcceptedStateAgain) { + this->boot_(); + this->hub_.control_switch(SWITCH_OUT_LED, true); + EXPECT_TRUE(this->out_led_.state); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setLedMode 2 0"); + this->feed_("setLedMode 2 0\r\nError\r\nDFRobot:/>"); + } + // The LEDs cannot be read back, so the state the module accepted at boot is shown again + EXPECT_FALSE(this->out_led_.state); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_TRUE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SettingsTest, FailedBootLedWriteIsRetriedLater) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setLedMode 1 1"); + this->feed_("setLedMode 1 1\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("setLedMode 2 1"); + this->expect_and_answer_("getMicroMotion", "Response 1\r\n"); + this->expect_and_answer_("getGpioMode 1", "Response 1 1\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + // The sensor runs without the LED state, which is written again later + this->expect_and_answer_("sensorStart"); + EXPECT_TRUE(this->hub_.status_has_warning()); + this->feed_frames_(28); + EXPECT_EQ(this->uart_.tx, ""); + this->feed_frames_(4); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setLedMode 1 1"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SettingsTest, SwitchAndSelectChangesAreWrittenAndSaved) { + this->boot_(); + this->hub_.control_switch(SWITCH_MICRO_MOTION, false); + this->hub_.control_out_pin_level(0); + this->hub_.control_switch(SWITCH_OUT_LED, true); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setMicroMotion 0"); + this->expect_and_answer_("setGpioMode 1 0"); + this->expect_and_answer_("setLedMode 2 0"); + // Answers that refuse a command whose effect is already in place count as done + ASSERT_EQ(this->next_command_(), "saveConfig"); + this->feed_("saveConfig\r\nno parameter has been changed\r\nDFRobot:/>"); + ASSERT_EQ(this->next_command_(), "sensorStart"); + this->feed_("sensorStart\r\nsensor started already\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + } // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml index e1805f81c5d..b3112d231dc 100644 --- a/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml +++ b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml @@ -27,9 +27,20 @@ uart_mock: std::string cmd(data.begin(), data.end()); if (cmd == "\r\n") return; + static float min_range = 0.6f; + static float max_range = 12.0f; std::string reply = cmd + "\r\n"; - if (cmd == "getSWV") + if (cmd == "getRange") { + char buf[48]; + snprintf(buf, sizeof(buf), "Response %.1f %.1f\r\n", min_range, max_range); + reply += buf; + } else if (cmd.rfind("setRange ", 0) == 0) { + sscanf(cmd.c_str() + 9, "%f %f", &min_range, &max_range); + } else if (cmd == "saveConfig") { + ESP_LOGI("sim", "Saved range %.2f %.2f", min_range, max_range); + } else if (cmd == "getSWV") { reply += "SoftwareVersion:V1.0.4\r\n"; + } reply += "Done\r\nDFRobot:/>"; id(mock_uart).inject_to_rx_buffer(std::vector(reply.begin(), reply.end())); @@ -50,6 +61,13 @@ binary_sensor: has_target: name: Has Target +number: + - platform: dfrobot_sen0609 + min_range: + name: Min Range + max_range: + name: Max Range + text_sensor: - platform: dfrobot_sen0609 version: diff --git a/tests/integration/test_uart_mock_dfrobot_sen0609.py b/tests/integration/test_uart_mock_dfrobot_sen0609.py index 1146e27d27f..2931ace2f4d 100644 --- a/tests/integration/test_uart_mock_dfrobot_sen0609.py +++ b/tests/integration/test_uart_mock_dfrobot_sen0609.py @@ -1,6 +1,7 @@ """Integration test for the DFRobot SEN0609 component against a model of the module's command line. -Checks the boot sequence: the software version is read and presence frames are parsed. +Checks the boot sequence (settings read back, presence frames parsed) and that a number change +reaches the module as setRange followed by saveConfig. """ from __future__ import annotations @@ -8,7 +9,7 @@ from __future__ import annotations import asyncio from pathlib import Path -from aioesphomeapi import BinarySensorInfo, EntityState, TextSensorInfo +from aioesphomeapi import BinarySensorInfo, EntityState, NumberInfo, TextSensorInfo import pytest from .state_utils import require_entity @@ -21,7 +22,7 @@ async def test_uart_mock_dfrobot_sen0609( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Boot reads the software version; presence frames reach the binary sensor.""" + """Boot reads the module's settings; a number change is written and saved.""" external_components_path = str( Path(__file__).parent / "fixtures" / "external_components" ) @@ -29,6 +30,13 @@ async def test_uart_mock_dfrobot_sen0609( "EXTERNAL_COMPONENT_PATH", external_components_path ) + loop = asyncio.get_running_loop() + saved = loop.create_future() + + def line_callback(line: str) -> None: + if "Saved range 0.60 8.00" in line and not saved.done(): + saved.set_result(True) + states: dict[int, EntityState] = {} async def wait_for(condition, what: str) -> None: @@ -40,11 +48,13 @@ async def test_uart_mock_dfrobot_sen0609( pytest.fail(f"Timeout waiting for {what}; states: {states}") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() has_target = require_entity(entities, "has_target", BinarySensorInfo) + min_range = require_entity(entities, "min_range", NumberInfo) + max_range = require_entity(entities, "max_range", NumberInfo) version = require_entity(entities, "version", TextSensorInfo) client.subscribe_states(lambda state: states.__setitem__(state.key, state)) @@ -55,3 +65,16 @@ async def test_uart_mock_dfrobot_sen0609( await wait_for(lambda: state_of(has_target) is True, "presence") await wait_for(lambda: state_of(version) == "V1.0.4", "software version") + await wait_for( + lambda: state_of(max_range) == pytest.approx(12.0), "maximum range" + ) + assert state_of(min_range) == pytest.approx(0.6) + + client.number_command(max_range.key, 8.0) + try: + await asyncio.wait_for(saved, timeout=10.0) + except TimeoutError: + pytest.fail("The new range was not written and saved") + await wait_for( + lambda: state_of(max_range) == pytest.approx(8.0), "new maximum range" + ) From a58ce2d6416ad59fbde11d2fe060e163873f90b6 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Thu, 15 Oct 2026 20:48:18 +0000 Subject: [PATCH 439/651] [as734x] [2/3] Add basic counts, calibration and saturation level (#18011) Co-authored-by: J. Nick Koston --- esphome/components/as734x/__init__.py | 94 ++++++- esphome/components/as734x/as734x.cpp | 241 ++++++++++++++++-- esphome/components/as734x/as734x.h | 83 +++++- esphome/components/as734x/sensor.py | 45 +++- tests/component_tests/as734x/test_as734x.py | 28 ++ tests/component_tests/as734x/test_as734x.yaml | 9 + tests/components/as734x/common.yaml | 23 ++ 7 files changed, 486 insertions(+), 37 deletions(-) diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index 6657d76bf4d..bd43f78f01e 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__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_GAIN, CONF_ID, CONF_TYPE, CONF_UPDATE_INTERVAL +from esphome.const import ( + CONF_CALIBRATION, + CONF_GAIN, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ID, + CONF_TYPE, + CONF_UPDATE_INTERVAL, +) from esphome.types import ConfigType CODEOWNERS = ["@latonita", "@mrgnr"] @@ -13,6 +20,9 @@ DOMAIN = "as734x" CONF_AS734X_ID = "as734x_id" CONF_ATIME = "atime" CONF_ASTEP = "astep" +CONF_CHANNEL_CORRECTION = "channel_correction" +CONF_DARK_CURRENT = "dark_current" +CONF_NORMALIZE_BASIC_COUNTS = "normalize_basic_counts" # Band order is a contract with the readout maps in as734x.cpp: the index selects the counts slot. BANDS_43 = ( @@ -44,11 +54,57 @@ GAINS_41 = { } GAINS_43 = {**GAINS_41, "X1024": Gain.GAIN_1024X, "X2048": Gain.GAIN_2048X} +Normalization = as734x_ns.enum("Normalization", True) +NORMALIZATIONS = { + "NONE": Normalization.NORMALIZATION_NONE, + "ALL": Normalization.NORMALIZATION_ALL, + "BANDS": Normalization.NORMALIZATION_BANDS, + "CLEAR": Normalization.NORMALIZATION_CLEAR, +} + +# Per-channel correction for the golden device, measured by the manufacturer. +DEFAULT_CHANNEL_CORRECTION_43 = [ + 1.055464349, + 1.043509797, + 1.029576268, + 1.0175052, + 1.00441899, + 0.987356499, + 0.957597044, + 0.995863485, + 1.014628964, + 0.996500814, + 0.933072749, + 1.052236338, + 0.999570232, +] # One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us CYCLE_STEP_SECONDS = 2.78e-6 +def _float_list(length: int) -> cv.All: + """One float per band, so the list length has to match the chip.""" + return cv.All(cv.ensure_list(cv.float_), cv.Length(min=length, max=length)) + + +def _calibration_schema(bands: tuple[str, ...]) -> cv.Schema: + correction = ( + DEFAULT_CHANNEL_CORRECTION_43 if bands == BANDS_43 else [1.0] * len(bands) + ) + return cv.Schema( + { + cv.Optional(CONF_CHANNEL_CORRECTION, default=correction): _float_list( + len(bands) + ), + cv.Optional(CONF_DARK_CURRENT, default=[0.0] * len(bands)): _float_list( + len(bands) + ), + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.positive_float, + } + ) + + def _validate_exposure(cycles: int): """Reject an exposure that is empty or longer than the update interval.""" @@ -67,7 +123,9 @@ def _validate_exposure(cycles: int): return validator -def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: +def _schema( + gains: dict, address: int, cycles: int, bands: tuple[str, ...] +) -> cv.Schema: return cv.All( cv.Schema( { @@ -75,6 +133,10 @@ def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: cv.Optional(CONF_GAIN, default="X8"): cv.enum(gains), cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), + cv.Optional(CONF_CALIBRATION, default={}): _calibration_schema(bands), + cv.Optional(CONF_NORMALIZE_BASIC_COUNTS, default="NONE"): cv.enum( + NORMALIZATIONS, upper=True + ), } ) .extend(cv.polling_component_schema("60s")) @@ -85,14 +147,25 @@ def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: CONFIG_SCHEMA = cv.typed_schema( { - "AS7341": _schema(GAINS_41, 0x39, cycles=2), # two SMUX steps per update - "AS7343": _schema(GAINS_43, 0x39, cycles=3), - "TCS3448": _schema(GAINS_43, 0x59, cycles=3), + # two SMUX steps per update + "AS7341": _schema(GAINS_41, 0x39, cycles=2, bands=BANDS_41), + "AS7343": _schema(GAINS_43, 0x39, cycles=3, bands=BANDS_43), + "TCS3448": _schema(GAINS_43, 0x59, cycles=3, bands=BANDS_43), }, upper=True, ) +def _per_band( + values: list[float], bands: tuple[str, ...], absent: float +) -> list[float]: + """Spread per-band calibration over the 13 slots the C++ side indexes by band.""" + spread = [absent] * len(BANDS_43) + for value, band in zip(values, bands, strict=True): + spread[BANDS_43.index(band)] = value + return spread + + async def to_code(config: ConfigType) -> None: chip = config[CONF_TYPE] cg.add_define("USE_AS7341" if chip == "AS7341" else "USE_AS7343") @@ -102,3 +175,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_atime(config[CONF_ATIME])) cg.add(var.set_astep(config[CONF_ASTEP])) + cg.add(var.set_basic_counts_normalization(config[CONF_NORMALIZE_BASIC_COUNTS])) + + bands = BANDS_41 if chip == "AS7341" else BANDS_43 + calibration = config[CONF_CALIBRATION] + cg.add( + var.set_channel_correction( + _per_band(calibration[CONF_CHANNEL_CORRECTION], bands, 1.0) + ) + ) + cg.add(var.set_dark_current(_per_band(calibration[CONF_DARK_CURRENT], bands, 0.0))) + cg.add(var.set_glass_attenuation_factor(calibration[CONF_GLASS_ATTENUATION_FACTOR])) diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 2a9de8f3df2..2331d89c5fe 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include @@ -20,6 +21,8 @@ static constexpr uint8_t CFG0_REG_BANK = 0x10; static constexpr uint8_t BANK_SPLIT = 0x80; // registers below this need REG_BANK set static constexpr uint8_t STATUS2_AVALID = 0x40; static constexpr uint8_t ASTATUS_ASAT = 0x80; +static constexpr uint8_t LED_ACT = 0x80; // LED driver on, in the chip's LED register +static constexpr uint8_t CONFIG_LED_SEL = 0x08; static constexpr uint8_t AS7341_CFG6 = 0xAF; static constexpr uint8_t AS7341_SMUX_CMD_WRITE = 0x10; // copy the SMUX image from RAM to the chain @@ -40,6 +43,9 @@ static constexpr uint8_t CLEAR_BAND = NUM_BANDS - 1; static constexpr uint8_t MAX_SMUX_LEN = 20; static constexpr uint8_t MAX_ADC_SLOTS = 18; static constexpr uint32_t MIN_COLLECTION_TIMEOUT_MS = 30 * 1000; +static constexpr uint32_t MAX_ADC_COUNT = 65535; + +PROGMEM_STRING_TABLE(BandNames, "F1", "F2", "FZ", "F3", "F4", "FY", "F5", "FXL", "F6", "F7", "F8", "NIR", "Clear"); #ifdef USE_AS7341 static const char AS7341_NAME[] PROGMEM = "AS7341"; @@ -60,11 +66,25 @@ static const uint8_t AS7341_READOUT[] PROGMEM = { N, N, N, N, N, N, 0, N, 1, 2, 3, 5, 4, // step 1: SMUX routes clear to ADC4 and NIR to ADC5 }; static_assert(sizeof(AS7341_READOUT) == AS7341_SMUX_STEPS * NUM_BANDS && AS7341_ADC_SLOTS <= MAX_ADC_SLOTS); +// This chip has no FZ, FY or FXL band, so those slots are marked absent. +// clang-format off +static const uint16_t AS7341_WAVELENGTHS[] PROGMEM = { + 415, 445, ABSENT_NM, 480, 515, ABSENT_NM, 555, ABSENT_NM, 590, 630, 680, 910, WIDEBAND_NM, +}; +// clang-format on +static_assert(sizeof(AS7341_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); +// Datasheet values for the golden device: gain correction per gain step, the same for every band. +// The last two entries stay at zero because this chip has no 1024x or 2048x gain. +static const float AS7341_GAIN_CORRECTION[] PROGMEM = {1.0577f, 1.0491f, 1.0479f, 1.0491f, 1.0207f, 1.0158f, 1.0109f, + 1.0000f, 1.0003f, 0.9873f, 0.9593f, 0.0f, 0.0f}; +static_assert(sizeof(AS7341_GAIN_CORRECTION) == GAIN_COUNT * sizeof(float)); const ChipInfo AS7341_CHIP PROGMEM = { .name = AS7341_NAME, .init = AS7341_INIT, .smux = AS7341_SMUX, .readout = AS7341_READOUT, + .wavelengths = AS7341_WAVELENGTHS, + .gain_correction = AS7341_GAIN_CORRECTION, .init_count = 1, .astep_reg = 0xCA, .cfg0_reg = 0xA9, @@ -73,11 +93,14 @@ const ChipInfo AS7341_CHIP PROGMEM = { .id_reg = 0x92, .id_mask = 0xFC, .id_value = 0x09 << 2, + .led_reg = 0x74, + .led_sel_reg = 0x70, // CONFIG, whose LED_SEL bit connects the pin to the driver .adc_slots = AS7341_ADC_SLOTS, .smux_steps = AS7341_SMUX_STEPS, .integration_cycles = 1, .smux_len = AS7341_SMUX_LEN, .auto_smux = false, + .gain_correction_per_band = false, .extra_clear_slot = NO_SLOT, }; #endif @@ -97,12 +120,41 @@ static const uint8_t AS7343_SMUX[] PROGMEM = {0x00, 0x04, 0x65, 0x02, 0x00, 0x05 static const uint8_t AS7343_READOUT[] PROGMEM = {12, 6, 0, 7, 8, 1, 15, 2, 9, 13, 14, 3, 10}; static_assert(sizeof(AS7343_SMUX) == AS7343_CHAINS * AS7343_SMUX_LEN && AS7343_SMUX_LEN <= MAX_SMUX_LEN); static_assert(sizeof(AS7343_READOUT) == NUM_BANDS && AS7343_ADC_SLOTS <= MAX_ADC_SLOTS); +// clang-format off +static const uint16_t AS7343_WAVELENGTHS[] PROGMEM = { + 405, 425, 450, 475, 515, 555, 550, 600, 640, 690, 745, 855, WIDEBAND_NM, +}; +// clang-format on +static_assert(sizeof(AS7343_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); +// Datasheet values for the golden device: gain correction per gain step, per band. +// clang-format off +static const float AS7343_GAIN_CORRECTION[] PROGMEM = { + 1.149f, 1.100f, 1.060f, 1.070f, 1.063f, 1.051f, 1.062f, 1.056f, 1.049f, 1.040f, 1.080f, 1.038f, 1.065f, // 0.5x + 1.090f, 1.128f, 1.064f, 1.071f, 1.063f, 1.050f, 1.068f, 1.055f, 1.047f, 1.039f, 1.075f, 1.038f, 1.085f, // 1x + 1.083f, 1.086f, 1.062f, 1.070f, 1.062f, 1.049f, 1.057f, 1.053f, 1.045f, 1.038f, 1.063f, 1.037f, 1.069f, // 2x + 1.059f, 1.068f, 1.056f, 1.066f, 1.058f, 1.046f, 1.051f, 1.051f, 1.044f, 1.036f, 1.059f, 1.035f, 1.053f, // 4x + 1.100f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.076f, 1.088f, // 8x + 1.099f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.075f, 1.087f, // 16x + 1.088f, 1.096f, 1.085f, 1.097f, 1.087f, 1.078f, 1.079f, 1.080f, 1.071f, 1.067f, 1.087f, 1.064f, 1.076f, // 32x + 1.083f, 1.091f, 1.078f, 1.090f, 1.079f, 1.072f, 1.072f, 1.073f, 1.064f, 1.062f, 1.080f, 1.057f, 1.069f, // 64x + 1.076f, 1.084f, 1.072f, 1.085f, 1.074f, 1.066f, 1.062f, 1.067f, 1.055f, 1.056f, 1.074f, 1.051f, 1.061f, // 128x + 1.067f, 1.074f, 1.063f, 1.075f, 1.064f, 1.059f, 1.055f, 1.058f, 1.049f, 1.051f, 1.064f, 1.044f, 1.053f, // 256x + 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, // 512x + 1.033303857f, 1.034763813f, 1.029855013f, 1.036911249f, 0.988150537f, 1.029003501f, 1.021363258f, + 0.98762089f, 1.021713376f, 1.040108204f, 0.987417698f, 1.019481421f, 0.987270057f, // 1024x + 0.986859143f, 0.980877221f, 0.9469769f, 0.985535324f, 0.926243961f, 0.959814787f, 1.007963896f, + 0.942685187f, 1.007630587f, 1.025460005f, 0.928696275f, 1.001296639f, 1.041275263f, // 2048x +}; +// clang-format on +static_assert(sizeof(AS7343_GAIN_CORRECTION) == GAIN_COUNT * NUM_BANDS * sizeof(float)); static constexpr ChipInfo as7343_chip(const char *name) { return { .name = name, .init = AS7343_INIT, .smux = AS7343_SMUX, .readout = AS7343_READOUT, + .wavelengths = AS7343_WAVELENGTHS, + .gain_correction = AS7343_GAIN_CORRECTION, .init_count = sizeof(AS7343_INIT) / sizeof(RegValue), .astep_reg = 0xD4, .cfg0_reg = 0xBF, @@ -111,11 +163,14 @@ static constexpr ChipInfo as7343_chip(const char *name) { .id_reg = 0x5A, .id_mask = 0xFF, .id_value = 0x81, + .led_reg = 0xCD, + .led_sel_reg = 0, // the LED register drives the pin on its own .adc_slots = AS7343_ADC_SLOTS, .smux_steps = 1, .integration_cycles = 3, .smux_len = AS7343_SMUX_LEN, .auto_smux = true, + .gain_correction_per_band = true, .extra_clear_slot = 4, }; } @@ -133,6 +188,34 @@ bool AS734XComponent::select_bank_(uint8_t reg) { return this->write_byte(pgm(this->chip_->cfg0_reg), low ? CFG0_REG_BANK : 0); } +bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) { + uint8_t data = 0; + if (!this->select_bank_(reg) || !this->read_byte(reg, &data)) { + return false; + } + data = value ? (data | mask) : (data & ~mask); + return this->write_byte(reg, data); +} + +// The exposure is applied before every measurement, so a gain, ATIME or ASTEP changed at runtime +// takes effect on the next reading rather than only after a restart. +bool AS734XComponent::write_exposure_() { + const ChipInfo *chip = this->chip_; + if (!this->select_bank_(REG_ATIME) || !this->write_byte(REG_ATIME, this->atime_) || + !this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) || + !this->write_byte(pgm(chip->cfg1_reg), this->gain_)) { + return false; + } + this->reading_gain_ = this->gain_; + this->reading_atime_ = this->atime_; + this->reading_astep_ = this->astep_; + // One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us. + const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000; + this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles); + this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_); + return true; +} + void AS734XComponent::setup() { const ChipInfo *chip = this->chip_; const uint8_t id_reg = pgm(chip->id_reg); @@ -152,9 +235,7 @@ void AS734XComponent::setup() { ok = this->select_bank_(rv.reg) && this->write_byte(rv.reg, rv.value); } // Everything from here on, including the measurement registers, is in the high bank. - ok = ok && this->select_bank_(REG_ATIME) && this->write_byte(REG_ATIME, this->atime_) && - this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) && - this->write_byte(pgm(chip->cfg1_reg), this->gain_); + ok = ok && this->write_exposure_(); if (pgm(chip->auto_smux)) { const uint8_t *smux = pgm(chip->smux); const uint8_t len = pgm(chip->smux_len); @@ -167,10 +248,6 @@ void AS734XComponent::setup() { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - // One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us. - const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000; - this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles); - this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_); this->disable_loop(); } @@ -181,24 +258,61 @@ void AS734XComponent::dump_config() { " Chip: %s\n" " Gain: %gx\n" " ATIME: %u\n" - " ASTEP: %u", - LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_); + " ASTEP: %u\n" + " Glass attenuation factor: %.3f", + LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_, this->glass_attenuation_factor_); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - for (auto *sens : this->sensors_) { - LOG_SENSOR(" ", "Counts", sens); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + char label[24]; + const char *name = LOG_STR_ARG(BandNames::get_log_str(band, BandNames::LAST_INDEX)); + snprintf(label, sizeof(label), "Counts %s", name); + sensor::log_sensor(TAG, " ", label, this->sensors_[band]); + snprintf(label, sizeof(label), "Basic counts %s", name); + sensor::log_sensor(TAG, " ", label, this->basic_counts_sensors_[band]); } + LOG_SENSOR(" ", "Saturation level", this->saturation_level_sensor_); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } } +void AS734XComponent::enable_led(bool enable) { + const uint8_t led_reg = pgm(this->chip_->led_reg); + if (led_reg == 0) { + return; + } + // The LED register only drives the LED while LED_SEL is set, so the pin is handed over before + // switching on and released only after switching off. The other order would leave the LED lit + // with nothing able to turn it off. + const uint8_t led_sel_reg = pgm(this->chip_->led_sel_reg); + bool ok = true; + if (enable && led_sel_reg != 0) { + ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, true); + } + ok = this->update_bits_(led_reg, LED_ACT, enable) && ok; + if (!enable && led_sel_reg != 0) { + ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, false) && ok; + } + // The measurement path assumes the high bank, so leave the chip there for a reading in flight + ok = this->select_bank_(REG_ENABLE) && ok; + if (!ok) { + ESP_LOGW(TAG, "Failed to switch the LED"); + } +} + void AS734XComponent::update() { if (this->state_ != State::STATE_IDLE) { ESP_LOGW(TAG, "Skipping update, previous measurement still running"); return; } + if (!this->write_exposure_()) { + this->abort_measurement_(LOG_STR("Failed to apply measurement settings")); + return; + } this->step_ = 0; + this->peak_raw_count_ = 0; + this->raw_counts_.fill(0); this->enable_loop(); if (!this->start_step_()) { this->abort_measurement_(LOG_STR("Failed to start measurement")); @@ -270,14 +384,18 @@ void AS734XComponent::read_step_() { const uint8_t extra_clear = pgm(this->chip_->extra_clear_slot); for (uint8_t band = 0; band < NUM_BANDS; band++) { const uint8_t slot = progmem_read_byte(&readout[band]); - if (slot == NO_SLOT || this->sensors_[band] == nullptr) { + if (slot == NO_SLOT) { continue; } - uint32_t counts = slot_value(slot); + const uint16_t raw = slot_value(slot); + this->peak_raw_count_ = std::max(this->peak_raw_count_, raw); + uint16_t counts = raw; if (band == CLEAR_BAND && extra_clear != NO_SLOT) { - counts = (counts + slot_value(extra_clear)) / 2; + const uint16_t extra = slot_value(extra_clear); + this->peak_raw_count_ = std::max(this->peak_raw_count_, extra); + counts = (static_cast(raw) + extra) / 2; } - this->sensors_[band]->publish_state(counts); + this->raw_counts_[band] = counts; } if (++this->step_ < pgm(this->chip_->smux_steps)) { if (!this->start_step_()) { @@ -285,11 +403,104 @@ void AS734XComponent::read_step_() { } return; } + this->publish_measurement_(); this->status_clear_warning(); this->state_ = State::STATE_IDLE; this->disable_loop(); } +// A basic count is the raw count scaled to one unit of gain and one millisecond of integration +// time, which makes readings taken with different settings comparable. The manufacturer's +// conversion factors are calibrated against that definition. +void AS734XComponent::calculate_basic_counts_() { + this->basic_counts_.fill(0.0f); + this->max_basic_count_ = 0.0f; + this->max_band_basic_count_ = 0.0f; + this->clear_basic_count_ = 0.0f; + + // The gain enum counts in powers of two starting at 0.5x, so the multiplier is 2^gain / 2. + const float gain_x = static_cast(1 << this->reading_gain_) / 2.0f; + const float integration_ms = (1.0f + this->reading_atime_) * (1.0f + this->reading_astep_) * 2.78e-3f; + if (gain_x <= 0.0f || integration_ms <= 0.0f) { + return; + } + const float inv_exposure = 1.0f / (gain_x * integration_ms); + const ChipInfo *chip = this->chip_; + const uint16_t *wavelengths = pgm(chip->wavelengths); + const float *gain_correction = pgm(chip->gain_correction); + const bool per_band = pgm(chip->gain_correction_per_band); + const uint8_t correction_base = per_band ? this->reading_gain_ * NUM_BANDS : this->reading_gain_; + + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const uint16_t wavelength = pgm(wavelengths[band]); + if (wavelength == ABSENT_NM) { + continue; + } + float basic_count = this->raw_counts_[band] * inv_exposure; + basic_count -= this->dark_current_[band]; + basic_count = std::max(basic_count, 0.0f); + basic_count *= pgm(gain_correction[per_band ? correction_base + band : correction_base]); + basic_count *= this->channel_correction_[band]; + basic_count *= this->glass_attenuation_factor_; + + this->basic_counts_[band] = basic_count; + this->max_basic_count_ = std::max(this->max_basic_count_, basic_count); + if (wavelength == WIDEBAND_NM) { + this->clear_basic_count_ = basic_count; + } else if (wavelength >= VISIBLE_MIN_NM && wavelength <= VISIBLE_MAX_NM) { + this->max_band_basic_count_ = std::max(this->max_band_basic_count_, basic_count); + } + } +} + +float AS734XComponent::normalization_divisor_() const { + float divisor = 1.0f; + switch (this->normalization_) { + case Normalization::NORMALIZATION_ALL: + divisor = this->max_basic_count_; + break; + case Normalization::NORMALIZATION_BANDS: + divisor = this->max_band_basic_count_; + break; + case Normalization::NORMALIZATION_CLEAR: + divisor = this->clear_basic_count_; + break; + case Normalization::NORMALIZATION_NONE: + return 1.0f; + } + // In the dark the reference can be zero, and dividing by that would publish infinities. + return divisor > 0.0f ? divisor : 1.0f; +} + +void AS734XComponent::publish_basic_counts_() { + // Normalization describes the shape of the spectrum rather than its absolute level. Channels + // outside the chosen reference can exceed 1.0 and are left that way rather than clipped: with + // BANDS the near infrared channel legitimately sits well above the visible bands. + const float scale = 1.0f / this->normalization_divisor_(); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + if (this->basic_counts_sensors_[band] != nullptr) { + this->basic_counts_sensors_[band]->publish_state(this->basic_counts_[band] * scale); + } + } +} + +void AS734XComponent::publish_measurement_() { + this->calculate_basic_counts_(); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + if (this->sensors_[band] != nullptr) { + this->sensors_[band]->publish_state(this->raw_counts_[band]); + } + } + this->publish_basic_counts_(); + if (this->saturation_level_sensor_ != nullptr) { + const uint32_t max_adc = + std::min((this->reading_atime_ + 1UL) * (this->reading_astep_ + 1UL), MAX_ADC_COUNT); + const float level = max_adc == 0 ? 0.0f : 100.0f * this->peak_raw_count_ / max_adc; + ESP_LOGV(TAG, "Highest ADC count %u of %u (%.1f%%)", this->peak_raw_count_, static_cast(max_adc), level); + this->saturation_level_sensor_->publish_state(level); + } +} + void AS734XComponent::abort_measurement_(const LogString *reason) { ESP_LOGW(TAG, "%s", LOG_STR_ARG(reason)); this->write_byte(REG_ENABLE, ENABLE_PON); diff --git a/esphome/components/as734x/as734x.h b/esphome/components/as734x/as734x.h index 184084ca2b7..f2b68d380ec 100644 --- a/esphome/components/as734x/as734x.h +++ b/esphome/components/as734x/as734x.h @@ -26,7 +26,26 @@ enum Gain : uint8_t { GAIN_2048X, // AS7343 only }; -static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py +static constexpr uint8_t GAIN_COUNT = 13; // entries in Gain, for sizing the correction tables +static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py + +// What the published basic counts are divided by. The clear channel is wideband, so it is roughly +// the size of all the bands together, and the near infrared channel outgrows them under daylight. +// Dividing by the largest of every channel therefore leaves the visible bands squeezed into the +// bottom of the range. +enum class Normalization : uint8_t { + NORMALIZATION_NONE, // publish absolute basic counts + NORMALIZATION_ALL, // largest of every channel + NORMALIZATION_BANDS, // largest of the visible bands only + NORMALIZATION_CLEAR, // the wideband clear channel +}; + +// Nominal centre wavelengths in nanometres bound the visible bands. The clear channel is wideband +// and has no single centre wavelength, so it is marked with zero instead. +static constexpr uint16_t VISIBLE_MIN_NM = 380; +static constexpr uint16_t VISIBLE_MAX_NM = 700; +static constexpr uint16_t WIDEBAND_NM = 0; +static constexpr uint16_t ABSENT_NM = 0xFFFF; // band this chip does not have struct RegValue { uint8_t reg; @@ -36,9 +55,11 @@ struct RegValue { /// Per-chip constants, stored in flash. The pointed-to tables are in flash too. struct ChipInfo { const char *name; - const RegValue *init; // written in order; the bank is selected per register address - const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images - const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + const RegValue *init; // written in order; the bank is selected per register address + const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images + const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band + const float *gain_correction; // GAIN_COUNT entries, or GAIN_COUNT * NUM_BANDS when per band uint8_t init_count; uint8_t astep_reg; uint8_t cfg0_reg; @@ -47,12 +68,15 @@ struct ChipInfo { uint8_t id_reg; uint8_t id_mask; uint8_t id_value; - uint8_t adc_slots; // 16-bit data registers read after ASTATUS - uint8_t smux_steps; // measurements per update + uint8_t led_reg; // LED driver register (0: this chip has no LED control) + uint8_t led_sel_reg; // register whose LED_SEL bit hands the pin to the driver (0: not needed) + uint8_t adc_slots; // 16-bit data registers read after ASTATUS + uint8_t smux_steps; // measurements per update uint8_t integration_cycles; - uint8_t smux_len; // bytes per SMUX image - bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup - uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none) + uint8_t smux_len; // bytes per SMUX image + bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup + bool gain_correction_per_band; // the correction table is indexed by gain and band, not gain alone + uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none) }; #ifdef USE_AS7341 @@ -65,7 +89,9 @@ extern const ChipInfo TCS3448_CHIP; class AS734XComponent : public PollingComponent, public i2c::I2CDevice { public: - explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) {} + // Channel correction is multiplicative, so it starts at unity rather than zero in case no + // calibration is configured. + explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) { this->channel_correction_.fill(1.0f); } void setup() override; void dump_config() override; @@ -75,21 +101,58 @@ class AS734XComponent : public PollingComponent, public i2c::I2CDevice { void set_gain(Gain gain) { this->gain_ = gain; } void set_atime(uint8_t atime) { this->atime_ = atime; } void set_astep(uint16_t astep) { this->astep_ = astep; } + + /// Turns the sensor's LED driver on or off. Meant for lambdas. + void enable_led(bool enable); + + // Calibration applied while turning raw counts into basic counts. + void set_dark_current(const std::array &values) { this->dark_current_ = values; } + void set_channel_correction(const std::array &values) { this->channel_correction_ = values; } + void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } + + /// Chooses what the published basic counts are divided by. + void set_basic_counts_normalization(Normalization normalization) { this->normalization_ = normalization; } + void set_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->sensors_[band] = sensor; } + void set_basic_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->basic_counts_sensors_[band] = sensor; } + + SUB_SENSOR(saturation_level) protected: bool select_bank_(uint8_t reg); + bool update_bits_(uint8_t reg, uint8_t mask, bool value); + bool write_exposure_(); bool start_step_(); void read_step_(); void abort_measurement_(const LogString *reason); + void calculate_basic_counts_(); + void publish_measurement_(); + void publish_basic_counts_(); + float normalization_divisor_() const; std::array sensors_{}; + std::array basic_counts_sensors_{}; + std::array dark_current_{}; + std::array channel_correction_; + std::array basic_counts_{}; + std::array raw_counts_{}; const ChipInfo *chip_; // in flash, read field by field with progmem_memcpy uint32_t start_ms_{0}; uint32_t integration_ms_{0}; uint32_t timeout_ms_{0}; + float glass_attenuation_factor_{1.0f}; + float max_basic_count_{0.0f}; // largest of every channel + float max_band_basic_count_{0.0f}; // largest of the visible bands only + float clear_basic_count_{0.0f}; // the wideband clear channel uint16_t astep_{0}; + uint16_t peak_raw_count_{0}; // highest ADC value of this measurement, before the clear channels are averaged + // What the running measurement was started with, so the maths matches the data even if the + // configuration is changed while a measurement is in flight. + uint16_t reading_astep_{0}; + Gain reading_gain_{GAIN_1X}; + uint8_t reading_atime_{0}; enum class State : uint8_t { STATE_IDLE, STATE_WAIT_SMUX, STATE_READ_DATA } state_{State::STATE_IDLE}; + Normalization normalization_{Normalization::NORMALIZATION_NONE}; Gain gain_{GAIN_1X}; uint8_t atime_{0}; uint8_t step_{0}; diff --git a/esphome/components/as734x/sensor.py b/esphome/components/as734x/sensor.py index aa1487f8a97..726d15d6fff 100644 --- a/esphome/components/as734x/sensor.py +++ b/esphome/components/as734x/sensor.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv -from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT +from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT import esphome.final_validate as fv from esphome.types import ConfigType @@ -10,7 +10,11 @@ from . import BANDS_41, BANDS_43, CONF_AS734X_ID, DOMAIN, AS734XComponent DEPENDENCIES = [DOMAIN] +CONF_BASIC_COUNTS = "basic_counts" +CONF_SATURATION_LEVEL = "saturation_level" + ICON_COUNTS = "mdi:counter" +ICON_SATURATION = "mdi:weather-sunny-alert" _COUNTS_SENSOR = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, @@ -19,12 +23,31 @@ _COUNTS_SENSOR = sensor.sensor_schema( state_class=STATE_CLASS_MEASUREMENT, ) +# Basic counts are fractional, so they need decimals where raw counts do not. +_BASIC_COUNTS_SENSOR = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_COUNTS, + accuracy_decimals=6, + state_class=STATE_CLASS_MEASUREMENT, +) + +_BAND_KEYS = (CONF_COUNTS, CONF_BASIC_COUNTS) + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent), cv.Optional(CONF_COUNTS, default={}): cv.Schema( {cv.Optional(band): _COUNTS_SENSOR for band in BANDS_43} ), + cv.Optional(CONF_BASIC_COUNTS, default={}): cv.Schema( + {cv.Optional(band): _BASIC_COUNTS_SENSOR for band in BANDS_43} + ), + cv.Optional(CONF_SATURATION_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_SATURATION, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), } ) @@ -32,12 +55,13 @@ CONFIG_SCHEMA = cv.Schema( def _final_validate(config: ConfigType) -> ConfigType: full = fv.full_config.get() hub = full.get_config_for_path(full.get_path_for_id(config[CONF_AS734X_ID])[:-1]) - if hub[CONF_TYPE] == "AS7341" and ( - extra := [band for band in config[CONF_COUNTS] if band not in BANDS_41] - ): - raise cv.Invalid( - f"The AS7341 has no {', '.join(extra)} band", path=[CONF_COUNTS, extra[0]] - ) + if hub[CONF_TYPE] != "AS7341": + return config + for key in _BAND_KEYS: + if extra := [band for band in config.get(key, {}) if band not in BANDS_41]: + raise cv.Invalid( + f"The AS7341 has no {', '.join(extra)} band", path=[key, extra[0]] + ) return config @@ -47,7 +71,14 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS734X_ID]) counts = config[CONF_COUNTS] + basic_counts = config[CONF_BASIC_COUNTS] for i, band in enumerate(BANDS_43): if (conf := counts.get(band)) is not None: sens = await sensor.new_sensor(conf) cg.add(hub.set_counts_sensor(sens, i)) + if (conf := basic_counts.get(band)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_basic_counts_sensor(sens, i)) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_SATURATION_LEVEL, hub.set_saturation_level_sensor) diff --git a/tests/component_tests/as734x/test_as734x.py b/tests/component_tests/as734x/test_as734x.py index 98ae9571693..d668523c816 100644 --- a/tests/component_tests/as734x/test_as734x.py +++ b/tests/component_tests/as734x/test_as734x.py @@ -92,3 +92,31 @@ def test_exposure_bound( return with pytest.raises(cv.Invalid, match=error): HUB_SCHEMA(config) + + +def test_as7343_basic_counts_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band in ("f1", "fz", "clear"): + assert ( + f"hub43->set_basic_counts_sensor(b43_{band}, {BAND_INDEX[band]});" + in main_cpp + ) + + +def test_as7341_calibration_spreads_over_the_band_slots(generate_main) -> None: + """The AS7341 has ten bands, but the C++ side indexes all thirteen slots. + + The three bands it does not have keep the neutral value, so a correction or dark + current given for F3 reaches slot 3 rather than slot 2. + """ + main_cpp = generate_main(CONFIG) + + assert ( + "hub41->set_channel_correction({1.1f, 1.2f, 1.0f, 1.3f, 1.4f, 1.0f, 1.5f, " + "1.0f, 1.6f, 1.7f, 1.8f, 1.9f, 2.0f});" in main_cpp + ) + assert ( + "hub41->set_dark_current({0.1f, 0.2f, 0.0f, 0.3f, 0.4f, 0.0f, 0.5f, " + "0.0f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f});" in main_cpp + ) diff --git a/tests/component_tests/as734x/test_as734x.yaml b/tests/component_tests/as734x/test_as734x.yaml index 0ae5ce2ce10..da014d32e6a 100644 --- a/tests/component_tests/as734x/test_as734x.yaml +++ b/tests/component_tests/as734x/test_as734x.yaml @@ -11,6 +11,10 @@ i2c: as734x: - id: hub41 type: AS7341 + normalize_basic_counts: ALL + calibration: + channel_correction: [1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0] + dark_current: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] - id: hub43 type: AS7343 address: 0x3A @@ -45,3 +49,8 @@ sensor: f8: {id: s43_f8, name: s43 f8} nir: {id: s43_nir, name: s43 nir} clear: {id: s43_clear, name: s43 clear} + basic_counts: + f1: {id: b43_f1, name: b43 f1} + fz: {id: b43_fz, name: b43 fz} + clear: {id: b43_clear, name: b43 clear} + saturation_level: {id: sat43, name: sat 43} diff --git a/tests/components/as734x/common.yaml b/tests/components/as734x/common.yaml index 5fa94d7ef5b..0ad83bac4dc 100644 --- a/tests/components/as734x/common.yaml +++ b/tests/components/as734x/common.yaml @@ -6,15 +6,24 @@ as734x: gain: X8 atime: 120 astep: 99 + normalize_basic_counts: BANDS + calibration: + channel_correction: [1.0, 1.01, 1.02, 1.03, 1.04, 1.05, 1.06, 1.07, 1.08, 1.09] + dark_current: [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] + glass_attenuation_factor: 1.2 - id: as7343_hub type: AS7343 i2c_id: i2c_bus address: 0x3A gain: X512 + normalize_basic_counts: CLEAR - id: tcs3448_hub type: TCS3448 i2c_id: i2c_bus gain: X2048 + calibration: + dark_current: + [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] sensor: - platform: as734x @@ -26,6 +35,13 @@ sensor: name: AS7341 NIR clear: name: AS7341 Clear + basic_counts: + f1: + name: AS7341 F1 basic + clear: + name: AS7341 Clear basic + saturation_level: + name: AS7341 Saturation - platform: as734x as734x_id: as7343_hub counts: @@ -35,6 +51,13 @@ sensor: name: AS7343 FXL clear: name: AS7343 Clear + basic_counts: + fz: + name: AS7343 FZ basic + fy: + name: AS7343 FY basic + saturation_level: + name: AS7343 Saturation - platform: as734x as734x_id: tcs3448_hub counts: From 699e3bd0377868a0de5514862ca2c1c12d3a09c0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:03:27 -0400 Subject: [PATCH 440/651] [usb_uart] Runtime modem pin control for USB and host (#20330) 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/serial_proxy/__init__.py | 4 + .../components/serial_proxy/serial_proxy.cpp | 29 ++++- .../components/serial_proxy/serial_proxy.h | 42 ++++++- esphome/components/uart/__init__.py | 7 ++ .../components/uart/uart_component_host.cpp | 30 +++++ esphome/components/uart/uart_component_host.h | 5 + esphome/components/usb_uart/ch34x.cpp | 17 ++- esphome/components/usb_uart/cp210x.cpp | 18 +++ esphome/components/usb_uart/ft23xx.cpp | 18 ++- esphome/components/usb_uart/pl2303.cpp | 8 +- esphome/components/usb_uart/usb_uart.cpp | 107 ++++++++++++++---- esphome/components/usb_uart/usb_uart.h | 44 +++++-- esphome/core/defines.h | 1 + tests/components/serial_proxy/test.host.yaml | 14 +++ 14 files changed, 302 insertions(+), 42 deletions(-) create mode 100644 tests/components/serial_proxy/test.host.yaml diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index a8767280504..d5baac3c9df 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -109,6 +109,10 @@ async def to_code(config: ConfigType) -> None: channel = await cg.get_variable(config[CONF_UART_ID]) cg.add(var.set_usb_channel(channel)) cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY") + if uart.is_host_uart(config[CONF_UART_ID], CORE.config): + host_uart = await cg.get_variable(config[CONF_UART_ID]) + cg.add(var.set_host_uart(host_uart)) + cg.add_define("USE_SERIAL_PROXY_HOST_UART") cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 03d289225f2..6d3b389e5f6 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -18,6 +18,10 @@ #include "esphome/components/usb_uart/usb_uart.h" #endif +#ifdef USE_SERIAL_PROXY_HOST_UART +#include "esphome/components/uart/uart_component_host.h" +#endif + namespace esphome::serial_proxy { ESPHOME_LOG_TAG(TAG, "serial_proxy"); @@ -379,6 +383,19 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0; ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); + // The UART goes first, so a failure leaves every line as it was. A line with a GPIO pin keeps + // the UART's current state; with both on GPIO there is nothing to send + bool applied = true; + if (this->rts_pin_ == nullptr || this->dtr_pin_ == nullptr) { + this->with_modem_uart_([this, dtr, rts, &applied](auto *uart) { + applied = uart->set_modem_control(this->dtr_pin_ != nullptr ? uart->get_dtr() : dtr, + this->rts_pin_ != nullptr ? uart->get_rts() : rts); + }); + } + if (!applied) { + ESP_LOGW(TAG, "Modem pins did not reach the UART on serial proxy [%" PRIu32 "]", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR; + } if (this->rts_pin_ != nullptr) { this->rts_state_ = rts; this->rts_pin_->digital_write(rts); @@ -455,8 +472,16 @@ void SerialProxy::on_usb_connection_changed_(bool connected) { #endif uint32_t SerialProxy::get_modem_pins() const { - return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | - (this->dtr_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); + bool rts = this->rts_state_; + bool dtr = this->dtr_state_; + this->with_modem_uart_([this, &rts, &dtr](auto *uart) { + if (this->rts_pin_ == nullptr) + rts = uart->get_rts(); + if (this->dtr_pin_ == nullptr) + dtr = uart->get_dtr(); + }); + return (rts ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | + (dtr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); } SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 2b8f933f889..3c7e92baa71 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -29,6 +29,12 @@ struct UsbDeviceInfo; } // namespace esphome::usb_host #endif +#ifdef USE_SERIAL_PROXY_HOST_UART +namespace esphome::uart { +class HostUartComponent; +} // namespace esphome::uart +#endif + // Forward-declare types needed outside the USE_API guard. namespace esphome::api { class APIConnection; @@ -162,10 +168,12 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values uint32_t get_modem_pins() const; - /// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values + /// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values. + /// A line is driven by its GPIO pin when one is configured, otherwise by the UART if it can. uint32_t get_configured_modem_pins() const { - return (this->rts_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | - (this->dtr_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); + const bool uart_lines = this->with_modem_uart_([](auto *) {}); + return (this->rts_pin_ != nullptr || uart_lines ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | + (this->dtr_pin_ != nullptr || uart_lines ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); } /// Flush the serial port (block until all TX data is sent) @@ -183,6 +191,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; } #endif +#ifdef USE_SERIAL_PROXY_HOST_UART + /// Attach the host UART behind this port (from code generation) + void set_host_uart(uart::HostUartComponent *host_uart) { this->host_uart_ = host_uart; } +#endif + #ifdef USE_API /// Send this port's identity to one client void send_identity(api::APIConnection *api_connection); @@ -237,6 +250,24 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } #endif + /// Call f with the UART behind this port if that UART drives the modem lines itself. + /// Returns false when it cannot. Typed pointers, so the calls are direct. + template bool with_modem_uart_(F &&f) const { +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + if (this->usb_channel_ != nullptr) { + f(this->usb_channel_); + return true; + } +#endif +#ifdef USE_SERIAL_PROXY_HOST_UART + if (this->host_uart_ != nullptr) { + f(this->host_uart_); + return true; + } +#endif + return false; + } + /// Time the wire needs for the given number of bytes at the current framing uint32_t wire_time_ms_(size_t bytes) const; @@ -318,6 +349,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// The USB UART channel behind this port; nullptr on non-USB ports usb_uart::USBUartChannel *usb_channel_{nullptr}; #endif + +#ifdef USE_SERIAL_PROXY_HOST_UART + /// The host UART behind this port; nullptr on other ports + uart::HostUartComponent *host_uart_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index d1ac806b5fd..1cf01f4b486 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -55,6 +55,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority import esphome.final_validate as fv +from esphome.types import ConfigType from esphome.yaml_util import make_data_base _LOGGER = getLogger(__name__) @@ -125,6 +126,12 @@ MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True +def is_host_uart(uart_id: ID, full_config: ConfigType) -> bool: + return CORE.is_host and any( + conf[CONF_ID] == uart_id for conf in full_config.get(DOMAIN) or [] + ) + + def validate_raw_data(value): if isinstance(value, str): return value.encode("utf-8") diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 833ccc425d3..2cb9d497241 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -285,6 +285,36 @@ UARTFlushResult HostUartComponent::flush() { return UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } +bool HostUartComponent::set_modem_control(bool dtr, bool rts) { + if (this->file_descriptor_ == -1) { + return false; + } + // One TIOCMSET so the device never sees a state in between the old and the new one + int bits; + if (ioctl(this->file_descriptor_, TIOCMGET, &bits) == -1) { + this->update_error_(strerror(errno)); + return false; + } + bits = (bits & ~(TIOCM_DTR | TIOCM_RTS)) | (dtr ? TIOCM_DTR : 0) | (rts ? TIOCM_RTS : 0); + if (ioctl(this->file_descriptor_, TIOCMSET, &bits) == -1) { + this->update_error_(strerror(errno)); + return false; + } + return true; +} + +bool HostUartComponent::get_dtr() const { return (this->get_modem_bits_() & TIOCM_DTR) != 0; } + +bool HostUartComponent::get_rts() const { return (this->get_modem_bits_() & TIOCM_RTS) != 0; } + +int HostUartComponent::get_modem_bits_() const { + int bits = 0; + if (this->file_descriptor_ != -1) { + ioctl(this->file_descriptor_, TIOCMGET, &bits); + } + return bits; +} + void HostUartComponent::update_error_(const std::string &error) { if (this->first_error_.empty()) { this->first_error_ = error; diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index 27869e969c4..55e89d892a9 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -21,10 +21,15 @@ class HostUartComponent final : public UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override; UARTFlushResult flush() override; + /// Drive DTR/RTS. Returns false when the port is closed or the ioctl fails. + bool set_modem_control(bool dtr, bool rts); + bool get_dtr() const; + bool get_rts() const; void set_name(std::string port_name) { this->port_name_ = std::move(port_name); } protected: void update_error_(const std::string &error); + int get_modem_bits_() const; void check_logger_conflict() override {} std::string port_name_; std::string first_error_; diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index dce07a64a53..ad4e6a2140b 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -10,6 +10,11 @@ namespace esphome::usb_uart { using namespace bytebuffer; +static constexpr uint8_t CH341_REQ_MODEM_CTRL = 0xA4; +// Modem control bits, sent inverted +static constexpr uint8_t CH341_BIT_DTR = 1 << 5; +static constexpr uint8_t CH341_BIT_RTS = 1 << 6; + struct CH34xEntry { const char *name; uint16_t pid; @@ -145,13 +150,23 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo return true; } case 1: - this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypeCH34X::modem_control_transfer(USBUartChannelBase *channel) { + uint8_t request = CH341_REQ_MODEM_CTRL + channel->index_; + if (channel->index_ >= 2) + request += 0xE; + const uint8_t control = (channel->dtr_ ? CH341_BIT_DTR : 0) | (channel->rts_ ? CH341_BIT_RTS : 0); + ESP_LOGD(TAG, "MODEM_CTRL: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + // Integer promotion makes this 16 bits wide, as in Linux: 0xFF9F with both lines on + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, request, static_cast(~control), 0); +} + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 34b76f88541..0629e8f77ad 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -43,6 +43,12 @@ static constexpr uint8_t SET_BAUDRATE = 0x1E; // Set the baud rate. static constexpr uint8_t SET_CHARS = 0x19; // Set special characters. static constexpr uint8_t VENDOR_SPECIFIC = 0xFF; // Vendor specific command. +// SET_MHS value bits. The high byte selects which lines the low byte changes. +static constexpr uint16_t CONTROL_DTR = 0x0001; +static constexpr uint16_t CONTROL_RTS = 0x0002; +static constexpr uint16_t CONTROL_WRITE_DTR = 0x0100; +static constexpr uint16_t CONTROL_WRITE_RTS = 0x0200; + std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev_hdl) { const usb_config_desc_t *config_desc; const usb_device_desc_t *device_desc; @@ -119,10 +125,22 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, baud.get_data()); return true; } + case 3: // Assert DTR+RTS (init only) + if (reload) + return false; + this->modem_control_transfer(channel); + return true; default: return false; } } + +void USBUartTypeCP210X::modem_control_transfer(USBUartChannelBase *channel) { + const uint16_t value = + CONTROL_WRITE_DTR | CONTROL_WRITE_RTS | (channel->dtr_ ? CONTROL_DTR : 0) | (channel->rts_ ? CONTROL_RTS : 0); + ESP_LOGD(TAG, "SET_MHS: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_MHS, value, channel->index_); +} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 805a4adfce3..45acdb08f90 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -13,6 +13,13 @@ namespace esphome::usb_uart { using namespace bytebuffer; +static constexpr uint8_t SIO_SET_MODEM_CTRL = 0x01; +// The high byte selects which lines the low byte changes +static constexpr uint16_t SIO_SET_DTR_MASK = 0x0100; +static constexpr uint16_t SIO_SET_RTS_MASK = 0x0200; +static constexpr uint16_t SIO_DTR_HIGH = 0x0001; +static constexpr uint16_t SIO_RTS_HIGH = 0x0002; + // FTDI chip family identifiers. These map to USB device bcdDevice values // and determine how baudrate divisors and clock sources are calculated. enum FtdiChipType { @@ -373,14 +380,21 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b case 3: // set modem control DTR+RTS (init only) if (reload) return false; - this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, - channel->cdc_dev_.bulk_interface_number + 1); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypeFT23XX::modem_control_transfer(USBUartChannelBase *channel) { + const uint16_t value = + SIO_SET_DTR_MASK | SIO_SET_RTS_MASK | (channel->dtr_ ? SIO_DTR_HIGH : 0) | (channel->rts_ ? SIO_RTS_HIGH : 0); + ESP_LOGD(TAG, "SIO_SET_MODEM_CTRL: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, SIO_SET_MODEM_CTRL, value, + channel->cdc_dev_.bulk_interface_number + 1); +} + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 0430b1eeacb..89bcaa6dad3 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -282,13 +282,19 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b // Assert DTR + RTS (init only) if (reload) return false; - this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypePL2303::modem_control_transfer(USBUartChannelBase *channel) { + const uint16_t value = (channel->dtr_ ? CONTROL_DTR : 0) | (channel->rts_ ? CONTROL_RTS : 0); + ESP_LOGD(TAG, "PL2303: SET_CONTROL_REQUEST DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, value, channel->cdc_dev_.bulk_interface_number); +} + } // namespace esphome::usb_uart #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/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 744b79f4e43..4c48aa1fd5b 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -499,16 +499,17 @@ void USBUartTypeCdcAcm::on_disconnected() { channel->initialised_.store(false); // The descriptors these point into are freed with the device channel->cdc_dev_ = {}; + channel->dtr_ = true; + channel->rts_ = true; } USBClient::on_disconnected(); } +static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; + bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { - static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; - static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; - static constexpr uint16_t CDC_DTR_RTS = 0x0003; // D0=DTR, D1=RTS switch (step) { case 0: { @@ -537,38 +538,61 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // Assert DTR+RTS to signal DTE is present (init only). if (reload) return false; - this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, - channel->cdc_dev_.interrupt_interface_number); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypeCdcAcm::modem_control_transfer(USBUartChannelBase *channel) { + static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; + static constexpr uint16_t CDC_CONTROL_LINE_DTR = 1 << 0; + static constexpr uint16_t CDC_CONTROL_LINE_RTS = 1 << 1; + const uint16_t value = (channel->dtr_ ? CDC_CONTROL_LINE_DTR : 0) | (channel->rts_ ? CDC_CONTROL_LINE_RTS : 0); + ESP_LOGD(TAG, "SET_CONTROL_LINE_STATE: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, value, + channel->cdc_dev_.interrupt_interface_number); +} + void USBUartComponent::enable_channels() { this->cfg_single_ = nullptr; - this->cfg_pending_reload_ = nullptr; + // Init sends every channel's current line settings and DTR/RTS + for (auto *channel : this->channels_) { + channel->reload_pending_ = false; + channel->modem_pending_ = false; + } this->cfg_channel_idx_ = 0; - this->start_config_(false); + this->start_config_(ConfigMode::CONFIG_MODE_INIT); } void USBUartComponent::apply_channel_settings(USBUartChannelBase *channel) { if (this->cfg_active_) { // A config sequence is already running. Defer this reload until it finishes to preserve // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an - // in-flight callback complete against fresh state). The pending slot coalesces multiple + // in-flight callback complete against fresh state). The per-channel flag coalesces multiple // requests; the channel's live settings are read when the reload eventually runs. - // Note: multiple channel reloads are not queued; only one pending reload is supported at a time. - this->cfg_pending_reload_ = channel; + channel->reload_pending_ = true; return; } this->cfg_single_ = channel; - this->start_config_(true); + this->start_config_(ConfigMode::CONFIG_MODE_RELOAD); } -void USBUartComponent::start_config_(bool reload) { - this->cfg_reload_ = reload; - this->cfg_device_phase_ = !reload; +void USBUartComponent::apply_modem_control(USBUartChannelBase *channel) { + if (this->cfg_active_) { + // Deferred like a reload. The transfer sends whatever dtr_/rts_ hold when it runs, so a + // channel that is set several times before then only sends its latest state. + channel->modem_pending_ = true; + return; + } + this->cfg_single_ = channel; + this->start_config_(ConfigMode::CONFIG_MODE_MODEM); +} + +void USBUartComponent::start_config_(ConfigMode mode) { + this->cfg_mode_ = mode; + this->cfg_device_phase_ = mode == ConfigMode::CONFIG_MODE_INIT; this->cfg_step_ = 0; this->cfg_ok_ = true; this->cfg_in_flight_ = false; @@ -621,8 +645,9 @@ bool USBUartComponent::run_config_machine_() { // cfg_ok_ is now synchronized (we only get here on the initial entry or after observing // cfg_done_ with acquire ordering), so it is safe to read. - ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d reload=%d ok=%d", this->cfg_device_phase_, - this->cfg_channel_idx_, this->cfg_step_, this->cfg_reload_, this->cfg_ok_); + const bool init = this->cfg_mode_ == ConfigMode::CONFIG_MODE_INIT; + ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d mode=%d ok=%d", this->cfg_device_phase_, + this->cfg_channel_idx_, this->cfg_step_, static_cast(this->cfg_mode_), this->cfg_ok_); // One-time device-level phase (init only). config_device_step() inspects cfg_ok_ itself. if (this->cfg_device_phase_) { @@ -644,17 +669,24 @@ bool USBUartComponent::run_config_machine_() { if (!this->cfg_ok_) { // A previous step in this channel's sequence failed. Abort the rest. On a full init, // mark the channel uninitialised so data flow isn't started on a misconfigured channel; - // on a reload, leave the already-working channel as it was. - if (!this->cfg_reload_) + // otherwise, leave the already-working channel as it was. + if (init) channel->initialised_.store(false); - } else if (this->config_step(channel, this->cfg_step_, this->cfg_reload_, this->cfg_ok_, this->cfg_response_)) { + } else if (this->cfg_mode_ == ConfigMode::CONFIG_MODE_MODEM) { + // A single transfer, so only the first step issues one + if (this->cfg_step_ == 0) { + this->modem_control_transfer(channel); + this->cfg_in_flight_ = true; + return true; + } + } else if (this->config_step(channel, this->cfg_step_, !init, this->cfg_ok_, this->cfg_response_)) { this->cfg_in_flight_ = true; return true; } } // Channel finished (or aborted). On full init, kick off data flow if still initialised. - if (channel != nullptr && !this->cfg_reload_ && channel->initialised_.load()) { + if (channel != nullptr && init && channel->initialised_.load()) { channel->input_started_.store(false); channel->output_started_.store(false); this->start_input(channel); @@ -672,11 +704,24 @@ bool USBUartComponent::run_config_machine_() { this->report_connected_(); } - // If the machine just went idle and a reload was requested while it was busy, start it now. - if (!this->cfg_active_ && this->cfg_pending_reload_ != nullptr) { - this->cfg_single_ = this->cfg_pending_reload_; - this->cfg_pending_reload_ = nullptr; - this->start_config_(true); + // If the machine just went idle and a run was requested while it was busy, start it now. + // A channel's pending modem change waits for its pending reload: the reload's init-only + // steps are skipped, so it cannot undo the modem lines. + if (!this->cfg_active_) { + for (auto *pending : this->channels_) { + if (pending->reload_pending_) { + pending->reload_pending_ = false; + this->cfg_single_ = pending; + this->start_config_(ConfigMode::CONFIG_MODE_RELOAD); + break; + } + if (pending->modem_pending_) { + pending->modem_pending_ = false; + this->cfg_single_ = pending; + this->start_config_(ConfigMode::CONFIG_MODE_MODEM); + break; + } + } } return true; } @@ -712,6 +757,18 @@ void USBUartChannelBase::load_settings(bool /*dump_config*/) { this->parent_->apply_channel_settings(this); } +bool USBUartChannelBase::set_modem_control(bool dtr, bool rts) { + if (!this->initialised_.load()) + return false; + const bool changed = dtr != this->dtr_ || rts != this->rts_; + this->dtr_ = dtr; + this->rts_ = rts; + // Nothing to send when the request matches the previous one + if (changed) + this->parent_->apply_modem_control(this); + return true; +} + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index ba073404e2f..56525f29159 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -104,6 +104,13 @@ 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"}; +/// What the config state machine is applying +enum class ConfigMode : uint8_t { + CONFIG_MODE_INIT, ///< Full initialisation of every channel after the device connected + CONFIG_MODE_RELOAD, ///< Re-apply the line settings of one already-open channel + CONFIG_MODE_MODEM, ///< Apply the DTR/RTS state of one already-open channel +}; + class RingBuffer { public: RingBuffer(uint16_t buffer_size) : buffer_size_(buffer_size), buffer_(new uint8_t[buffer_size]) {} @@ -200,6 +207,11 @@ class USBUartChannelBase : public uart::UARTComponent, public Parenteddtr_; } + bool get_rts() const { return this->rts_; } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -225,6 +237,12 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented &data = {}); - // (Re)start the config state machine. reload=false runs full init over all channels; - // reload=true re-applies settings to cfg_single_ only. - void start_config_(bool reload); + // (Re)start the config state machine. INIT runs over all channels; the other modes act + // on cfg_single_ only. + void start_config_(ConfigMode mode); // Advance the config state machine; called from loop(). Returns true if it did work. bool run_config_machine_(); @@ -285,6 +306,9 @@ class USBUartComponent : public usb_host::USBClient { // Optional one-time device-level setup run before the per-channel phase on init only // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + // Issue the one control transfer, via config_transfer_(), that puts the channel's dtr_ + // and rts_ on the wire. + virtual void modem_control_transfer(USBUartChannelBase *channel) = 0; // The device is only usable once the config machine has applied every channel's line // settings, so the connected report waits for run_config_machine_() to finish the init @@ -293,14 +317,13 @@ class USBUartComponent : public usb_host::USBClient { std::vector channels_{}; // Config state machine - USBUartChannelBase *cfg_single_{nullptr}; // non-null: reload of a single channel - USBUartChannelBase *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy - std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads - uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + USBUartChannelBase *cfg_single_{nullptr}; // non-null: single-channel run (RELOAD or MODEM) + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) uint8_t cfg_channel_idx_{0}; uint8_t cfg_step_{0}; + ConfigMode cfg_mode_{ConfigMode::CONFIG_MODE_INIT}; bool cfg_active_{false}; - bool cfg_reload_{false}; bool cfg_device_phase_{false}; bool cfg_in_flight_{false}; bool cfg_ok_{true}; @@ -316,6 +339,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { void on_connected() override; void on_disconnected() override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; // Each claimed interface pins one host hardware channel per endpoint; skipping // the comm (interrupt) interface frees one on channel-poor hosts (ESP32-S3: 8). bool claim_comm_interface_{true}; @@ -328,6 +352,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: @@ -341,6 +366,7 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { protected: bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; private: @@ -359,6 +385,7 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; uint8_t chip_type_{255}; }; @@ -382,6 +409,7 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c8a128e76cb..607bc7a42b4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -719,6 +719,7 @@ #define USE_ESPHOME_HOST_MAC_ADDRESS \ { 0x98, 0x35, 0x69, 0xab, 0xf6, 0x79 } #define USE_HTTP_REQUEST_RESPONSE +#define USE_SERIAL_PROXY_HOST_UART // Host only: the uart arm would shadow the native logger UART arms in other envs #define USE_IMPROV_SERIAL_UART #define USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/tests/components/serial_proxy/test.host.yaml b/tests/components/serial_proxy/test.host.yaml new file mode 100644 index 00000000000..c186d71b9b5 --- /dev/null +++ b/tests/components/serial_proxy/test.host.yaml @@ -0,0 +1,14 @@ +# The host UART drives DTR/RTS itself, which exercises the host modem control path +network: + +api: + +uart: + - id: uart_bus + port: "/dev/ttyS0" + baud_rate: 9600 + +serial_proxy: + - id: serial_proxy_1 + name: Test Serial Port + port_type: RS232 From 27c6b7caf5d17c3361619e0047ea1651f297456c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 11:36:36 -1000 Subject: [PATCH 441/651] [ci] Add hardware-test agent skill (#20817) --- .agents/skills/hardware-test/SKILL.md | 123 ++++++++++++++ .../skills/hardware-test/reference/boards.md | 138 +++++++++++++++ .../skills/hardware-test/reference/recipes.md | 117 +++++++++++++ .../hardware-test/scripts/flash_bk72xx.py | 51 ++++++ .../scripts/flash_rtl87xx_kit.py | 87 ++++++++++ .agents/skills/hardware-test/scripts/ports.py | 123 ++++++++++++++ .../hardware-test/scripts/serial_capture.py | 157 ++++++++++++++++++ .../hardware-test/scripts/wifisave_hammer.sh | 133 +++++++++++++++ .../templates/client-wifisave.yaml | 77 +++++++++ .../templates/dut-fallback-ap.yaml | 31 ++++ .../templates/example-client.yaml | 18 ++ .../hardware-test/templates/example-dut.yaml | 16 ++ 12 files changed, 1071 insertions(+) create mode 100644 .agents/skills/hardware-test/SKILL.md create mode 100644 .agents/skills/hardware-test/reference/boards.md create mode 100644 .agents/skills/hardware-test/reference/recipes.md create mode 100644 .agents/skills/hardware-test/scripts/flash_bk72xx.py create mode 100644 .agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py create mode 100644 .agents/skills/hardware-test/scripts/ports.py create mode 100644 .agents/skills/hardware-test/scripts/serial_capture.py create mode 100755 .agents/skills/hardware-test/scripts/wifisave_hammer.sh create mode 100644 .agents/skills/hardware-test/templates/client-wifisave.yaml create mode 100644 .agents/skills/hardware-test/templates/dut-fallback-ap.yaml create mode 100644 .agents/skills/hardware-test/templates/example-client.yaml create mode 100644 .agents/skills/hardware-test/templates/example-dut.yaml diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md new file mode 100644 index 00000000000..8ad7cd5d847 --- /dev/null +++ b/.agents/skills/hardware-test/SKILL.md @@ -0,0 +1,123 @@ +--- +name: hardware-test +description: Test an ESPHome change on real boards over USB. Use this whenever a task asks to test, verify, reproduce, bench, A/B or soak a pull request, a branch or a crash on hardware, or mentions flashing, serial logs, esphome upload, a captive portal or fallback AP test, or results to post on a PR. Covers finding the serial ports, building from the right checkout, flashing each chip family (including the LibreTiny kits that stock tools cannot flash), bounded log capture, proven test flows and how to report results. +allowed-tools: Read, Write, Edit, Bash, Glob, Grep +--- + +# Hardware testing + +The goal of a hardware test is evidence a reviewer can trust: which build ran on which board, what was +driven, what the logs show, and the same flow on a `dev` build when a quirk could be pre-existing. +`$SKILL` below is this directory (`.agents/skills/hardware-test`). + +## Ground rules + +1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that + port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py "`, + naming the port so a capture on another board survives; the bracket keeps the pattern from matching + the shell that runs it. Never run `esphome logs` in a + `while true` loop: the loop respawns it and holds the port. +2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with + `!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs. +3. **Build from the tree under test.** `python -m esphome` puts the current directory first on + `sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by + its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the + checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep + the build's `src/` for the change before trusting any number. +4. **PR build and baseline build get different `name:` values.** Builds share + `.esphome/build/`, so the same name means `esphome upload` can flash the wrong binary. Saved + Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh + name every run. +5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD` + checked out in a throwaway worktree), with the baseline commit named in the table. When a delta + looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it. +6. **Prove a quirk is pre-existing before blaming the PR:** run the identical flow on a `dev` build in + the same session and say so in the results. +7. **Pipe safely.** Set `PYTHONUNBUFFERED=1` on any `esphome run` or `esphome logs` whose output is + piped, or lines land minutes late. Bound every long command with `timeout` (coreutils) or + `perl -e 'alarm shift; exec @ARGV' ` on macOS without it. + +## Workflow + +1. **Find the boards:** `python $SKILL/scripts/ports.py` (any Python with pyserial, for example the + ESPHome venv). Port names follow the USB hub port and change on every replug, so never reuse a name + from an earlier session. CH340 adapters (D1 mini, BW15 kit, BK7238, CB3S) all look the same; tell + them apart with a 10 s capture using `serial_capture.py --reset` (ESP8266 prints its ROM banner at + 74880 baud, LibreTiny boards print `[LT]` and ESPHome lines, BK7238 prints its bootloader version). + `ports.py --wait 2e8a` blocks until a Pico re-enumerates. +2. **Make a scratch directory** outside the checkout, with `secrets.yaml` and the templates you need. + One yaml per board per variant, file name equal to `name:`. +3. **Compile** in the right tree: + `(cd && PYTHONUNBUFFERED=1 timeout 1500 python -m esphome compile > -compile.log 2>&1)`. + Keep the RAM and flash lines at the end. Two ESP-IDF or two LibreTiny builds at the same time race + over tool caches; run them one at a time. +4. **Flash** per chip family (table below). Stop the capture on that port first. +5. **Capture** with `serial_capture.py PORT SECONDS OUT` in the background, then poll the file with + `grep` in an `until ... sleep 2` loop with a cap. USB CDC boards (Pico, nRF52, native USB ESP32) + drop the port on reboot; the script reopens it until its deadline. +6. **Drive** the test: a second board running a client config, `curl`, an aioesphomeapi script that + presses a template button and subscribes to logs, MQTT publishes, or `interval:` and `on_boot:` + automations in the config itself. +7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines + that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`). +8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result, + plus the `dev` control row and the exact flow in a `

` block. No credentials, no port names, + no IP addresses. + +## Flashing by chip family + +| Board | Flash | Log | +|---|---|---| +| ESP8266 D1 mini (CH340) | `python -m esphome upload --device ` | `serial_capture.py` at 115200; `esphome logs` on some CH340s emits garbage after the RTS reset. A failed esptool write can wedge the adapter until the board is replugged. `esptool erase_flash` before a test that needs an unprovisioned device: the preference sector survives a reflash | +| ESP32 devkit (CP2102 or CH340) | `esphome upload --device `, ESP-IDF or Arduino | `serial_capture.py`. Default Wi-Fi light sleep delays API driven actions by seconds; judge timing from device logs. `power_save_mode: none` for OTA overlap tests | +| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device .local` | +| Pico W / Pico 2 W | `esphome upload --device ` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture | +| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d /.pioenvs//*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` | +| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d ` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test | +| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash | +| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second | +| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port | +| Any reachable device | `esphome upload --device `; `--file ` for a prebuilt image | `esphome logs --device ` bounded with `timeout` | + +ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA port is 8892. + +## Scripts in `$SKILL/scripts` + +- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`, + `--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and + `--serial-of PORT` (the serial number behind a path). +- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped + capture, ANSI stripped, `LOG_SECRET` text written as ``, reopens a vanished port, exits at the + deadline. OUT is truncated unless `--append` is given, so counts over it cover one run. +- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot + strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not. +- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. +- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`: + the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the + templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0), + `RESULT FAIL` (exit 3: a crash, a reboot after the AP came up, or the device never joined the network), + `RESULT INVALID` + (exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run) + or exits 1 when a build, flash or port step failed. + +## Templates in `$SKILL/templates` + +- `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and + serves the captive portal. `example-dut.yaml` is the per-run file with the platform block. +- `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real + credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a + plain provisioning check. + +## When something looks wrong + +- A capture stopped early or a line is missing: check the timestamps before trusting it. +- The client never joins the AP: another board may still run an AP with the same SSID, or the device + booted with saved credentials because the name was reused. One AP device at a time on the bench. +- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). +- Multicast from the host never arrives on some access points: test it device to device. Broadcast + is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate. +- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for + soak tests. + +`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven +flows. Add new findings there. diff --git a/.agents/skills/hardware-test/reference/boards.md b/.agents/skills/hardware-test/reference/boards.md new file mode 100644 index 00000000000..fd2cd448785 --- /dev/null +++ b/.agents/skills/hardware-test/reference/boards.md @@ -0,0 +1,138 @@ +# Board facts + +Ports are not listed on purpose: they follow the USB hub port and change on every replug. Run +`scripts/ports.py`. + +## ESP8266 (D1 mini, NodeMCU, CH340 or CP2102) + +- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset` + pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`). +- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset; + a plain pyserial capture at 115200 is reliable. +- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug. +- The preference sector survives a reflash: a provisioned API key or saved Wi-Fi credentials stay + active. `esptool erase_flash` gives a clean device. +- `Panic core_esp8266_main.cpp:191 __yield` with `ctx: sys` means a yield from lwIP context (a web + handler, an lwIP callback). Decode stacks with + `xtensa-lx106-elf-addr2line -e /.pioenvs//firmware.elf -f -C -i ` from the + toolchain under `~/.platformio/packages/toolchain-xtensa/bin/` (PlatformIO builds) or the ESPHome + cache directory (native toolchain builds). +- Constant data lives in RAM unless marked `PROGMEM`; with 80 KB of RAM a few hundred bytes are worth a + row in the PR. +- AsyncMqttClient drops inbound topics over 128 characters; topics of 255 or more wrap a counter and + stall the link until the broker drops TCP. + +## ESP32 classic devkit + +- Takes ESP-IDF and Arduino. Default Wi-Fi light sleep delays API driven actions by up to 6 s; judge + timings from device logs, not from the host. `power_save_mode: none` for OTA overlap tests (an IDF web + upload of 739 KB took 107 s with power save and 45 s without). +- Receives only a few percent of IPv6 multicast on some access points with power save on; a raw lwIP + socket loses the same, so that is the AP or driver, not a PR. +- Decode with `xtensa-esp32-elf-addr2line` from the IDF tools directory against + `/.pioenvs//firmware.elf` (PlatformIO) or `/build/.elf` (IDF native). +- Clean reboot between runs with the capture stopped: `esptool --after hard_reset chip_id`. +- Trigger a forced crash only after the app is marked valid (45 s after boot is plenty), never on an + image still pending OTA rollback. +- Signed OTA testing: build with `esp32.framework.advanced.signed_ota_verification.verification_keys` + under a different name, sign with `python -m espsecure sign_data --version 2 --keyfile + tests/components/esp32/dummy_signing_key.pem`, upload with `esphome upload --file`. The verifier + refuses unsigned images afterwards, so restore with a signed plain build. + +## ESP32-S3, C3, C6, H2 with native USB + +- `/dev/cu.usbmodem*`, esptool works, `logger: hardware_uart: USB_SERIAL_JTAG`. +- The USB console can die at the moment the app takes over the console during a reboot (log stops after + `Disabling RNG early entropy source`, then 0 bytes, esptool reports `No serial data received`). Only a + USB replug fixes it. Fallback: `esphome logs --device .local` (API log stream, which drops lines + while Wi-Fi is off). +- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry). + +## RP2040 / RP2350 (Pico W, Pico 2 W) + +- The CDC port re-enumerates after every reboot and after `esphome upload` (1200 baud touch). Re-detect + with `ports.py --wait 2e8a` before capturing; `serial_capture.py` reopens on its own if the port + vanishes mid-capture. +- First flash ever: BOOTSEL, then `esphome upload --device /Volumes/RPI-RP2` (or `RP2350`). +- arduino-pico creates the lwIP netif in `WiFi.begin()`, so any IGMP join during component setup fails; + joins must wait for `network::is_connected()`. +- Parallel Pico builds race on package extraction after a PlatformIO package wipe; one at a time. +- An RTS pulse from pyserial does not reset the board; reboot with `App.safe_reboot()` or the touch. + +## BW15 kit (RTL8720CF, LibreTiny rtl87xx) + +- The kit's CH340 wires RTS to CEN and DTR to PA00 (boot strap). A plain pyserial open asserts both and + holds the chip in reset, which is why `esphome upload`, plain `ltchiptool` and `esphome logs` all fail + ("Timeout while linking", or silence). `scripts/flash_rtl87xx_kit.py` sets DTR high and RTS released + on every open, pulses RTS once so the ROM answers `ping`, and after the command drops DTR and pulses + RTS to boot the app. Run it with the PlatformIO LibreTiny penv Python. +- `flash write -d ` takes no family argument; `flash read -d -s 0 -l + realtek-ambz2 ` does. +- Capture with `serial_capture.py --released`. The ESPHome log is on the flashing UART already. +- The macOS CH340 driver only pushes DTR/RTS to the wire once a session has driven them, so the first + plain open after a replug streams logs and the second is silent; always open with the lines released. +- Driver quirks seen on `dev`: `WiFi.begin` can refuse the real network on the first try after bogus + credentials (it connects after two cooldowns); right after connecting while the AP is still up the + driver can bus-fault in `rtw_is_adapter_up` (`R0 = 0xdeadbeef`) and reboot; the AP restarts on every + STA retry, so stations cannot stay joined; `WiFi.begin` fails synchronously for an absent network and + lands in a cooldown at once; connecting blocks the loop 2.5 to 4.1 s. +- Decode faults with `arm-none-eabi-addr2line -e /.pioenvs//raw_firmware.elf -f -C -i ` + from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`). + Fault dumps print PC, LR and R0 to R3. +- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`. + +## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN) + +- Stock ltchiptool closes and reopens the port after its reset, and pyserial raises both lines on open, + holding CEN low through the BootROM window. `scripts/flash_bk72xx.py` opens with both released and + pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset + reaches the downloader; no software reboot does. +- ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. +- Opening the port resets the chip (bootloader banner on every open, even with DTR/RTS low), so keep one + capture open for the whole test and never reopen mid-phase. +- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms; + radio enable 160 to 278 ms, disable about 200 ms. + +## BW16 kit (RTL8720DN, AmebaD) + +- The kit's own USB-C (CH340) is on LOG_UART and flashes fine. Its two-transistor circuit resets while + RTS alone is asserted and straps BURN while DTR alone is; both asserted does nothing. ROM entry is + esptool's classic sequence (`dtr:false,rts:true` 100 ms, `dtr:true,rts:false` 400 ms, both false); the + first pulse after a replug can be lost, retry up to three times. +- Any reopen resets the chip, 460800 is flaky, 115200 is solid: one open per session, in-place baud + changes only. The ROM and the loader NAK continuously while idle, so ACK scans must drain first. +- The AmebaD core has no mDNS responder (`mdns: disabled: true`). + +## LN882H + +- ltchiptool forces 1 Mbaud for writes; adapters that cannot hold it fail with "Timeout while linking" + or die mid-write. Patch `LN882H_YM_BAUDRATE` in the penv's `ltchiptool/soc/ln882h/util/ln882htool.py` + to 115200 (lost on reprovision). Weak 3.3 V from the adapter: feed the module's 5 V pad instead. Short + BOOT to GND while the tool connects. OTA afterwards. +- `USE_WIFI_AP_EXCLUSIVE` (one radio mode at a time) exists only on LN882x; compile-check changes there. + +## nRF52840 + +- Nordic PCA10059 dongle: the factory Open DFU bootloader enumerates as `1915:521F` on its own port + name; the app as `2FE3:0100` with two CDC ports, ESPHome log on the second. Only Nordic + `nrfutil nrf5sdk-tools` works (`pkg generate --hw-version 52 --sd-req 0x00 --application-version 1 + --application zephyr.hex app.zip`, then `dfu usb-serial -pkg app.zip -p `); ESPHome downloads + it on the first build. GPREGRET does not enter the bootloader; a pin reset through P0.19 does, which + `dfu: true` wires up on this board. A pulsing red LED means bootloader; the side RESET button + re-enters it. +- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume, + adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24), + button P0.18. + +## ESP Thread Border Router board v1.2 (S3 host + H2 RCP) + +- S3 host on USB JTAG serial, H2 RCP on its own USB. Spinel UART1 from the host: `rx_pin: GPIO17`, + `tx_pin: GPIO18`, RCP reset `GPIO7` active low, 460800. The config needs `network: enable_ipv6: true`. +- The shipped H2 firmware fails `CheckRadioCapabilities` with IDF 5.5; build `examples/openthread/ot_rcp` + for esp32h2 with ESPHome's own IDF (`IDF_PATH` at the cached framework, its penv and tools, + `IDF_PYTHON_CHECK_CONSTRAINTS=no`) and flash with esptool at 0x0, 0x8000 and 0x10000. +- A crash-looping S3 pulses the H2 reset every boot, so the H2 cannot be flashed and the S3 will not + enter its bootloader over USB. Hold S3 BOOT, tap S3 RST, flash the H2, release, then replug both. +- `dns-sd -L _meshcop._udp local.` shows the `omr` prefix; it disappears when routing is dead. + Keep one capture per chip; never reset the H2 over USB mid-test unless that is the test (the host + logs `Unexpected RCP reset: RESET_OTHER` and recovers). diff --git a/.agents/skills/hardware-test/reference/recipes.md b/.agents/skills/hardware-test/reference/recipes.md new file mode 100644 index 00000000000..32944ce75c4 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes.md @@ -0,0 +1,117 @@ +# Proven test flows + +Each flow names the pull requests it was used on, so their result tables show what to post. + +## Captive portal `/wifisave` hammer (#20802, #20784) + +Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board. +Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`, +`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the +device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown +is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on +ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md). + +Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash +markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`. +A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters +the adapter restart), no panic, and the real credentials connect the device within about 10 s. + +Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in +`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag +(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive +portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection +refused proves the portal's server is gone. + +## `reboot_timeout` with a fallback AP (#9958) + +Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network, +`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and +ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes: +`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles +are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot +with no station joined, no reboot for minutes while one is, then a reboot within a second of the last +disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way. + +## MQTT (#20433, #17935, #20762, #20767, #20768) + +A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives +each subscription's QoS, the device address (`New client connected from as -`) and exact +subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription. +Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right +after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a +leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish. +`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a +paho script publishes and polls the serial capture file. + +## UDP, multicast, sockets (#20556 to #20559, #20585, #20590) + +A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device +multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock +listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate +(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi +reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect` +(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action +rejects `%.*s`; use a lambda. + +## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037) + +All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release +after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A +refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot. +`pkill -f "a\|b"` does not alternate; use two pkills. + +## DHCP lease cache (#17767) + +Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the +`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep +sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use +`upload` then `logs`. + +## HDMI-CEC two-board wire (#9189) + +Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:` +filter on the reply handler, template buttons for each target plus lambdas calling +`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms, +round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep +lags the press). + +## LD2450 polygon zones (#20154) + +To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and +switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after. +Writing those numbers blocks the ld2450 loop about 50 ms. + +## Scheduler and loop wake A/B (#20356) + +An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266; +the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout +logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus +`button_command`). Build the same yaml twice with different names, one per tree. + +## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233) + +Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over +two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with +`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml. + +## ESP-NOW on the ESP8266 (#14407) + +The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is +up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only +mode the device transmits from the soft-AP MAC. + +## Wi-Fi Easy Connect (DPP) enrollee (#17934) + +Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's +QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG. +`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the +supplicant state machine. + +## Thread border router (#18104) + +See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR. + +## nRF52840 dongle boards (#19009, #20814) + +Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a +10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first. diff --git a/.agents/skills/hardware-test/scripts/flash_bk72xx.py b/.agents/skills/hardware-test/scripts/flash_bk72xx.py new file mode 100644 index 00000000000..1f63179325a --- /dev/null +++ b/.agents/skills/hardware-test/scripts/flash_bk72xx.py @@ -0,0 +1,51 @@ +"""ltchiptool for boards whose RTS drives CEN: reset and link on one open port. + +Stock ltchiptool closes and reopens the port after its DTR/RTS reset, and +pyserial raises both lines on open, holding CEN low through the BootROM's +listen window. Open with both lines released and pulse the reset right +before the LinkChecks instead. +""" + +import sys + +import bk7231tools.serial.main as bk_main +from ltchiptool.__main__ import cli +from ltchiptool.soc.bk72xx.flash import BK72XXFlash +from serial import Serial + + +class ReleasedSerial(Serial): + def __init__(self, port=None, **kwargs): + super().__init__(None, **kwargs) + self.dtr = False + self.rts = False + self.port = port + self.open() + + +bk_main.Serial = ReleasedSerial + +_connect = BK72XXFlash.flash_connect + + +def flash_hw_reset(self) -> None: + self._reset_pending = True + + +def flash_connect(self) -> None: + pending = getattr(self, "_reset_pending", False) + if self.bk and self.conn.linked and not pending: + return + self.flash_build_protocol() + if pending: + self._reset_pending = False + self.bk.hw_reset() + self.conn.linked = False # the chip rebooted, so link again below + _connect(self) + + +BK72XXFlash.flash_hw_reset = flash_hw_reset +BK72XXFlash.flash_connect = flash_connect + +sys.argv = ["ltchiptool", *sys.argv[1:]] +cli() diff --git a/.agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py b/.agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py new file mode 100644 index 00000000000..55752ec0bb0 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py @@ -0,0 +1,87 @@ +"""ltchiptool for the BW15 kit whose CH340 drives CEN (RTS) and PA00 (DTR). + +A plain open asserts both lines and holds the chip in reset. Here every open keeps DTR high and RTS +released; the first open pulses RTS so the ROM comes up; after the command DTR low plus an RTS pulse +boots the app. The exit status is ltchiptool's; 2 means the flash succeeded but the app was not +booted (boot pulse failed, or no -d PORT to send it to), so the board sits in the ROM until reset. + +Usage: python flash_rtl87xx_kit.py flash write -d firmware.uf2 +""" + +import sys +import time + +import serial + +_first = [True] +_orig_open = serial.Serial.open + + +def _open(self): + self.dtr = True # PA00 high: ROM download mode on the next reset + self.rts = False # CEN released + _orig_open(self) + if _first[0]: + _first[0] = False + self.rts = True + time.sleep(0.1) + self.rts = False + time.sleep(0.3) # reset into the ROM + self.reset_input_buffer() + + +serial.Serial.open = _open + +from ltchiptool.__main__ import cli_entrypoint # noqa: E402 + + +def port_argument(argv: list[str]) -> str | None: + for i, a in enumerate(argv): + if a in ("-d", "--device") and i + 1 < len(argv): + return argv[i + 1] + if a.startswith(("-d=", "--device=")): + return a.split("=", 1)[1] + if a.startswith("-d") and len(a) > 2: + return a[2:] + return None + + +def boot_app(port: str) -> None: + s = serial.Serial(port=None) + s.port = port + s.dtr = False + s.rts = False + _orig_open(s) + s.rts = True + time.sleep(0.1) + s.rts = False + s.close() + + +def main() -> int: + rc = 1 + try: + result = cli_entrypoint.main(args=sys.argv[1:], standalone_mode=False) + rc = int(result) if isinstance(result, int) else 0 + finally: + port = port_argument(sys.argv) + if not port: + print( + "[flash_rtl87xx_kit] no -d PORT given, the application was not booted", + file=sys.stderr, + ) + if rc == 0: + rc = 2 + else: + try: + boot_app(port) + print("[flash_rtl87xx_kit] application booted") + except (serial.SerialException, OSError) as err: + print(f"[flash_rtl87xx_kit] boot pulse failed: {err}", file=sys.stderr) + if rc == 0: + rc = 2 # flashed, but the board is still in the ROM: press reset + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/hardware-test/scripts/ports.py b/.agents/skills/hardware-test/scripts/ports.py new file mode 100644 index 00000000000..b7aa730a364 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/ports.py @@ -0,0 +1,123 @@ +"""List the boards on USB, or wait for one to enumerate. + + ports.py table: port, vid:pid, product, serial number, guess + ports.py --wait 2e8a print the first port with VID 0x2e8a once it appears (default 60 s) + ports.py --wait 1a86:7523 --timeout 30 + ports.py --product "USB Serial" first port whose USB product string matches (case-insensitive) + ports.py --wait 2e8a --serial SN the port of one specific board, whatever name it comes back under + ports.py --serial-of PORT print the USB serial number behind a port name + +Run with a Python that has pyserial (any ESPHome venv). Port names follow the hub port on macOS, so +re-run this after every replug instead of trusting a name from an earlier session. +""" + +import argparse +import sys +import time + +from serial.tools import list_ports + +GUESS = [ + ( + (0x1A86, 0x7523), + "CH340: D1 mini, BW15 kit, BK7238/CB3S or LN882H adapter; identify by the boot banner", + ), + ((0x1A86, 0x55D3), "CH343: ESP32-P4 devkit console"), + ((0x1A86, 0x55D4), "CH343: ESP32 devkit"), + ((0x1A86, None), "WCH CH34x USB serial bridge"), + ((0x10C4, 0xEA60), "CP2102: classic ESP32 devkit (esp32dev)"), + ( + (0x303A, 0x1001), + "Espressif USB JTAG serial: S3/C3/C6/H2 native USB (logger hardware_uart USB_SERIAL_JTAG)", + ), + ( + (0x2E8A, None), + "Raspberry Pi Pico W / Pico 2 W CDC; the port vanishes and comes back on every reboot", + ), + ((0x1915, 0x521F), "nRF52840 Dongle (PCA10059) in the Open DFU bootloader"), + ( + (0x2FE3, 0x0100), + "nRF52840 Dongle running a Zephyr app; two CDC ports, ESPHome log on the second", + ), + ((0x1915, 0xCAFE), "Makerdiary nRF52840 MDK dongle running a Zephyr app"), + ((0x239A, None), "Adafruit style nRF52 board (UF2 bootloader family)"), +] + + +def guess(vid: int, pid: int) -> str: + for (gv, gp), text in GUESS: + if vid == gv and (gp is None or pid == gp): + return text + return "" + + +def parse_id(text: str) -> tuple[int, int | None]: + vid, _, pid = text.partition(":") + return int(vid, 16), (int(pid, 16) if pid else None) + + +def matches( + port, vid: int | None, pid: int | None, product: str | None, serial: str | None +) -> bool: + if vid is not None and (port.vid != vid or (pid is not None and port.pid != pid)): + return False + if product and (port.product or "").lower() != product.lower(): + return False + return not serial or (port.serial_number or "") == serial + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument( + "--wait", + metavar="VID[:PID]", + help="block until a matching port appears, print it", + ) + ap.add_argument("--product", help="USB product string to match") + ap.add_argument("--serial", help="USB serial number to match") + ap.add_argument( + "--serial-of", metavar="PORT", help="print the USB serial number of PORT" + ) + ap.add_argument("--timeout", type=float, default=60) + args = ap.parse_args() + + if args.serial_of: + for p in list_ports.comports(): + if p.device == args.serial_of and p.serial_number: + print(p.serial_number) + return 0 + print(f"no USB serial number for {args.serial_of}", file=sys.stderr) + return 1 + + vid = pid = None + if args.wait: + vid, pid = parse_id(args.wait) + + if args.wait or args.product or args.serial: + deadline = time.time() + args.timeout + while True: + for p in sorted(list_ports.comports(), key=lambda p: p.device): + if p.vid and matches(p, vid, pid, args.product, args.serial): + print(p.device) + return 0 + if time.time() >= deadline: + print("no matching port", file=sys.stderr) + return 1 + time.sleep(1) + + rows = [p for p in list_ports.comports() if p.vid] + if not rows: + print("no USB serial ports") + return 1 + for p in sorted(rows, key=lambda p: p.device): + print( + f"{p.device:32} {p.vid:04x}:{p.pid:04x} {p.product or '?':34} " + f"{p.serial_number or '':18} {guess(p.vid, p.pid)}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/hardware-test/scripts/serial_capture.py b/.agents/skills/hardware-test/scripts/serial_capture.py new file mode 100644 index 00000000000..d93b3ca720f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/serial_capture.py @@ -0,0 +1,157 @@ +"""Bounded, timestamped serial capture that always exits and closes the port. + + serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] + + default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico. + --released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin + (DTR) and reset (RTS), so a plain open holds the chip in reset. + --reset pulse reset after opening (DTR low, RTS high 100 ms, RTS low) to capture a fresh boot. + --baud 115200 by default; 74880 shows the ESP8266 ROM boot messages. + --append keep what OUTFILE already holds; by default it is truncated so counts cover this run only. + +Exit status 1 when nothing was received (wrong port name, or a port another capture still holds). +Text ESPHome marks as secret (LOG_SECRET, the Wi-Fi password) is written as . +Lines are written as [HH:MM:SS]text with ANSI colour stripped and flushed one by one, so a shell +loop can grep the file while the capture runs. If the port is missing or disappears (USB CDC boards +re-enumerate on reboot) the script retries until the deadline, and follows the board by its USB serial +number when it comes back under another device path. + +Never run an open-ended serial session: every capture has a deadline, and a flash on the same port +needs the capture stopped first (pkill -f "[s]erial_capture.py" does not match the killing shell). +""" + +import argparse +import contextlib +import pathlib +import re +import sys +import time + +import serial +from serial.tools import list_ports + +ANSI = re.compile(rb"\x1b\[[0-9;]*[A-Za-z]") +# ESPHome's LOG_SECRET span (a Wi-Fi password); a span cut off before its end marker is secret to the end +SECRET = re.compile(rb"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL) +PORT_ERRORS = (serial.SerialException, OSError) + + +def identify(port: str) -> tuple[str | None, int]: + """USB serial number of the port and its position among that device's ports (a dongle has two).""" + ports = sorted(list_ports.comports(), key=lambda p: p.device) + serial_number = next((p.serial_number for p in ports if p.device == port), None) + siblings = [ + p.device for p in ports if serial_number and p.serial_number == serial_number + ] + return serial_number, siblings.index(port) if port in siblings else 0 + + +def current_path(port: str, serial_number: str | None, index: int) -> str: + """The port's current device path: a USB CDC board can come back under a new name after a reboot.""" + if serial_number: + ports = sorted(list_ports.comports(), key=lambda p: p.device) + siblings = [p.device for p in ports if p.serial_number == serial_number] + if index < len(siblings): + return siblings[index] + return port + + +def open_port(args: argparse.Namespace, path: str) -> serial.Serial: + s = serial.Serial(port=None, baudrate=args.baud, timeout=0.2) + s.port = path + if args.released: + s.dtr = False + s.rts = False + s.open() + if args.reset: + try: + s.dtr = False + s.rts = True + time.sleep(0.1) + s.rts = False + s.reset_input_buffer() + except PORT_ERRORS: + s.close() + raise + return s + + +def write_line(out, tag: bytes, data: bytes) -> None: + stamp = time.strftime("[%H:%M:%S]").encode() + out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"", data)) + b"\n") + out.flush() + + +def note(out, text: str) -> None: + write_line(out, b"[capture] ", text.encode()) + + +def capture(args: argparse.Namespace, out) -> int: + """Returns the number of bytes received.""" + deadline = time.time() + args.seconds + buf = b"" + s = None + received = 0 + serial_number, index = None, 0 + while time.time() < deadline: + if s is None: + path = current_path(args.port, serial_number, index) + try: + s = open_port(args, path) + args.reset = False # only reset on the first open + if serial_number is None: + serial_number, index = identify(path) + if path != args.port: + note(out, f"reopened as {path}") + except PORT_ERRORS as err: + note(out, f"open failed: {err}") + time.sleep(1) + continue + try: + data = s.read(4096) + except PORT_ERRORS as err: + if buf: # keep the cut-off line apart from the next boot's first line + write_line(out, b"[partial]", buf) + buf = b"" + note(out, f"port lost: {err}") + with contextlib.suppress(*PORT_ERRORS): + s.close() + s = None + time.sleep(1) + continue + if not data: + continue + received += len(data) + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + write_line(out, b"", line.rstrip(b"\r")) + if buf: + write_line(out, b"[partial]", buf) + if s is not None: + s.close() + return received + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("port") + ap.add_argument("seconds", type=float) + ap.add_argument("outfile") + ap.add_argument("--baud", type=int, default=115200) + ap.add_argument("--released", action="store_true") + ap.add_argument("--reset", action="store_true") + ap.add_argument("--append", action="store_true") + args = ap.parse_args() + with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out: + received = capture(args, out) + if received == 0: + print(f"[capture] nothing received from {args.port}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/hardware-test/scripts/wifisave_hammer.sh b/.agents/skills/hardware-test/scripts/wifisave_hammer.sh new file mode 100755 index 00000000000..4ef0d67e2f8 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/wifisave_hammer.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# wifisave_hammer.sh: the device under test (DUT) falls back to its AP; a client board joins it and posts +# /wifisave repeatedly, then the real credentials. Compiles, flashes, captures both serial logs and +# prints counts. Configs come from templates/example-dut.yaml and templates/example-client.yaml. +# +# Usage: wifisave_hammer.sh DUT_KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE_S] +# DUT_KIND esp (esphome upload), pico (upload, then re-find the CDC port by USB serial number), +# bw15 (flash_rtl87xx_kit.py) or bk (flash_bk72xx.py) +# *_YAML absolute path; the file name without .yaml must equal the config's `name` +# *_TREE checkout or worktree whose esphome code must build that yaml (cwd for python -m esphome) +# SETTLE_S seconds to keep capturing after the client reports the real save (default 40; BW15 wants 90) +# Env: PY (python with esphome; default DUT_TREE/venv/bin/python, else python3 on PATH; an explicit PY must exist), LTPY +# (LibreTiny penv python with ltchiptool; default ~/.platformio/penv/.libretiny/bin/python) +# Output next to each yaml: NAME-compile.log, NAME-upload.log, NAME-serial.log, NAME-capture.err, and DUT NAME-events.txt +# (addresses redacted so the extract can go on a PR; the serial logs themselves stay local). +# Exit status: 0 RESULT PASS (no crash marker or reboot after the AP came up, the device joined the network, +# and real stimulus: saves answered and a cooldown exit); 3 RESULT FAIL (the counts show +# a failure); 2 RESULT INVALID (a precondition or the stimulus was missed, or a capture did not last the run); +# 1 when a build, flash or port step fails (nothing is reported). +set -u +SKILL=$(cd "$(dirname "$0")/.." && pwd) +KIND=$1; DPORT=$2; DYAML=$3; DTREE=$4; CPORT=$5; CYAML=$6; CTREE=$7; SETTLE=${8:-40} +if [ -n "${PY:-}" ]; then [ -x "$PY" ] || { echo "[hammer] FAILED: PY=$PY is not executable"; exit 1; } +else PY=$DTREE/venv/bin/python; [ -x "$PY" ] || PY=$(command -v python3) || { echo "[hammer] FAILED: no python found, set PY"; exit 1; }; fi +LTPY=${LTPY:-$HOME/.platformio/penv/.libretiny/bin/python} +DDIR=$(dirname "$DYAML"); DNAME=$(basename "$DYAML" .yaml) +CDIR=$(dirname "$CYAML"); CNAME=$(basename "$CYAML" .yaml) +DLOG=$DDIR/$DNAME-serial.log; CLOG=$CDIR/$CNAME-serial.log +INVALID="" +AP_WAIT=120; UPLOAD_WAIT=300; SAVE_WAIT=240 +# Crash signatures (extended regex), not bare words: "aborting connection attempt" is a normal Wi-Fi retry line +CRASH_RE='Panic |abort\(\)|Fatal exception|Exception \(|Guru Meditation|HardFault|Bus Fault|Backtrace:|Soft WDT reset|wdt reset|Brownout|rst cause:[24]' +# Boot banners and port losses are counted apart: a Pico reboot shows as a port loss (and maybe a banner too), +# while on a UART board a port loss comes from the adapter or the hub, not the chip +BANNER_RE='Log initialized'; LOSS_RE='\[capture\] port lost' +DUT_CAPTURE=$((AP_WAIT + UPLOAD_WAIT + SAVE_WAIT + SETTLE + 60)); CLIENT_CAPTURE=$((SAVE_WAIT + SETTLE + 60)) + +bound() { local t=$1; shift; if command -v timeout >/dev/null; then timeout "$t" "$@"; else perl -e 'alarm shift; exec @ARGV' "$t" "$@"; fi; } +count() { LC_ALL=C grep -a -c "$1" "$2"; } +wait_for() { # wait_for FILE PATTERN SECONDS -> prints elapsed, status 1 on timeout + local n=0; until LC_ALL=C grep -a -q "$2" "$1"; do sleep 2; n=$((n+2)); [ $n -ge "$3" ] && { echo "$n"; return 1; }; done; echo "$n"; } +# Only this run's captures: another capture on the host (a soak on a third board) must survive +stop_tails() { pkill -f "[s]erial_capture.py $DPORT " 2>/dev/null; pkill -f "[s]erial_capture.py $CPORT " 2>/dev/null; sleep 1; } +fail() { echo "[hammer] FAILED: $1"; stop_tails; exit 1; } +port_never_opened() { # an open failure and no line that is not a capture note: the port, not the firmware + LC_ALL=C grep -a -q '\[capture\] open failed' "$1" && ! LC_ALL=C grep -a -q -v '^\[[0-9:]*\]\[capture\]' "$1"; } +compile() { # compile YAML TREE + (cd "$2" && PYTHONUNBUFFERED=1 bound 1500 "$PY" -m esphome compile "$1" > "${1%.yaml}-compile.log" 2>&1); local rc=$? + echo "[hammer] compile $(basename "$1"): rc=$rc"; return $rc; } +esp_upload() { # esp_upload YAML PORT TREE + (cd "$3" && bound $UPLOAD_WAIT "$PY" -m esphome upload "$1" --device "$2" > "${1%.yaml}-upload.log" 2>&1) + local ok; ok=$(count 'Successfully uploaded' "${1%.yaml}-upload.log"); echo "[hammer] upload $(basename "$1"): ok=$ok"; [ "$ok" -ge 1 ]; } + +stop_tails +compile "$DYAML" "$DTREE" || fail "DUT build failed, see $DDIR/$DNAME-compile.log" +compile "$CYAML" "$CTREE" || fail "client build failed, see $CDIR/$CNAME-compile.log" + +TAILOPT="" +case $KIND in + esp) esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed" ;; + pico) + SN=$("$PY" "$SKILL/scripts/ports.py" --serial-of "$DPORT") || fail "no USB serial number for $DPORT" + esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed" + # The Pico drops its CDC port on reboot and may already be back: take whatever path this board has now; + # the capture follows the serial number if the path changes again + DPORT=$("$PY" "$SKILL/scripts/ports.py" --wait 2e8a --serial "$SN" --timeout 60) || fail "pico did not re-enumerate" + echo "[hammer] pico port after upload: $DPORT"; sleep 2 ;; + bw15|bk) + UF2=$(ls "$DDIR/.esphome/build/$DNAME/.pioenvs/$DNAME/"*.uf2 2>/dev/null | head -1) + [ -n "$UF2" ] || fail "no uf2 under $DDIR/.esphome/build/$DNAME" + echo "[hammer] uf2: ${UF2##*/}" + W=$SKILL/scripts/flash_rtl87xx_kit.py; [ "$KIND" = bk ] && W=$SKILL/scripts/flash_bk72xx.py + (cd "$DDIR" && bound 400 "$LTPY" "$W" flash write -d "$DPORT" "$UF2" > "$DDIR/$DNAME-upload.log" 2>&1); rc=$? + echo "[hammer] $KIND flash: rc=$rc"; [ $rc -eq 0 ] || fail "DUT flash failed, see $DDIR/$DNAME-upload.log" + TAILOPT="--released" ;; + *) echo "unknown DUT_KIND $KIND"; exit 1 ;; +esac + +: > "$DLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$DPORT" $DUT_CAPTURE "$DLOG" $TAILOPT 2> "${DLOG%-serial.log}-capture.err" &) +if ! n=$(wait_for "$DLOG" "Captive portal\|Starting fallback AP" $AP_WAIT); then + [ -s "$DLOG" ] || fail "the DUT capture produced nothing (the capture itself failed), see ${DLOG%-serial.log}-capture.err" + port_never_opened "$DLOG" && fail "could not open $DPORT (held by another process, or the wrong port), see $DLOG" + fail "DUT never raised its AP within ${n}s, see $DLOG" +fi +echo "[hammer] DUT fallback AP after ${n}s"; sleep 2 +# reboots are counted from here; the first banner may predate the capture +BANNER_BASE=$(count "$BANNER_RE" "$DLOG"); LOSS_BASE=$(count "$LOSS_RE" "$DLOG") + +esp_upload "$CYAML" "$CPORT" "$CTREE" || fail "client upload failed" +: > "$CLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$CPORT" $CLIENT_CAPTURE "$CLOG" 2> "${CLOG%-serial.log}-capture.err" &) +if n=$(wait_for "$CLOG" "real save status\|posting the real" $SAVE_WAIT); then + echo "[hammer] client posted the real credentials after ${n}s; settling ${SETTLE}s"; sleep "$SETTLE" +else + [ -s "$CLOG" ] || fail "the client capture produced nothing (the capture itself failed), see ${CLOG%-serial.log}-capture.err" + port_never_opened "$CLOG" && fail "could not open $CPORT (held by another process, or the wrong port), see $CLOG" + INVALID="client never reached the real save within ${n}s"; echo "[hammer] WARNING: $INVALID" +fi + +# A verdict needs complete evidence: both captures must have lasted the run and logged no error of their own +for spec in "$DPORT:${DLOG%-serial.log}-capture.err" "$CPORT:${CLOG%-serial.log}-capture.err"; do + port=${spec%%:*}; err=${spec#*:} + pgrep -f "[s]erial_capture.py $port " >/dev/null || INVALID="${INVALID:+$INVALID; }the capture on $port ended before the run did, see $err" + [ -s "$err" ] && INVALID="${INVALID:+$INVALID; }the capture on $port reported an error, see $err" +done +CRASHES=$(LC_ALL=C grep -a -c -E "$CRASH_RE" "$DLOG") +BANNERS=$(( $(count "$BANNER_RE" "$DLOG") - BANNER_BASE )); LOSSES=$(( $(count "$LOSS_RE" "$DLOG") - LOSS_BASE )) +if [ "$KIND" = pico ]; then + REBOOTS=$BANNERS; [ "$LOSSES" -gt "$REBOOTS" ] && REBOOTS=$LOSSES # one reboot can show as both, so take the larger +else + REBOOTS=$BANNERS; [ "$LOSSES" -gt 0 ] && INVALID="${INVALID:+$INVALID; }the DUT port dropped $LOSSES time(s) mid-run (adapter or hub, not the chip)" +fi +STA=$(LC_ALL=C grep -a 'IP Address:' "$DLOG" | grep -v -c '192\.168\.4\.'); REAL=$(LC_ALL=C grep -a -o 'real save status [-0-9]*' "$CLOG" | tail -1 | awk '{print $4}') +SAVES=$(count 'Requested WiFi Settings Change' "$DLOG"); EXITS=$(count 'cooldown early' "$DLOG"); ANSWERED=$(count 'bogus save status 200' "$CLOG") +echo "[hammer] $DNAME: saves requested $SAVES, cooldown exits $EXITS, crash markers $CRASHES, after the AP came up: boot banners $BANNERS, port losses $LOSSES, reboots $REBOOTS; STA addresses $STA" +FAILED=$(count 'bogus save status -1' "$CLOG") +echo "[hammer] $CNAME: bogus saves answered $ANSWERED, failed $FAILED, real save status ${REAL:-none}" +# Only whole messages, matched where the message starts, so a credential line or an odd SSID never gets in +LC_ALL=C grep -a -E "^\[[0-9:]+\](\[[A-Z]\]\[[a-z_]+:[0-9]+\]: *(Exiting cooldown early|Starting fallback AP|Captive portal|Requested WiFi Settings Change|Retry phase|Connected\$|IP Address: )|\[capture\] port lost|($CRASH_RE))" "$DLOG" | LC_ALL=C tr -cd '\11\12\15\40-\176' | sed -E 's/(IP Address: )[^ ]+/\1/' | cut -c1-110 | head -80 > "$DDIR/$DNAME-events.txt" +echo "[hammer] events: $DDIR/$DNAME-events.txt" +stop_tails +[ -z "$INVALID" ] || { echo "[hammer] RESULT INVALID: $INVALID"; exit 2; } +# Provisioning is proven by the device joining the network; the client's answer to the real save can time +# out because the device leaves the AP before replying, so its status is information only +if [ "$CRASHES" -gt 0 ] || [ "$REBOOTS" -gt 0 ] || [ "$STA" -lt 1 ]; then + echo "[hammer] RESULT FAIL: crash markers $CRASHES, reboots $REBOOTS, STA addresses $STA, real save status ${REAL:-none}"; exit 3 +fi +# A pass needs the stimulus the flow is about: at least half the configured bogus saves answered and one cooldown exit +CONFIGURED=$(LC_ALL=C grep -a -E '^[[:space:]]*bogus_saves:' "$CYAML" | head -1 | tr -dc '0-9'); CONFIGURED=${CONFIGURED:-15} +if [ "$EXITS" -lt 1 ] || [ $((ANSWERED * 2)) -lt "$CONFIGURED" ]; then + echo "[hammer] RESULT INVALID: insufficient stimulus (bogus saves answered $ANSWERED of $CONFIGURED, cooldown exits $EXITS)"; exit 2 +fi +[ "$FAILED" -eq 0 ] || echo "[hammer] WARNING: $FAILED of $CONFIGURED bogus saves got no answer" +echo "[hammer] RESULT PASS"; exit 0 diff --git a/.agents/skills/hardware-test/templates/client-wifisave.yaml b/.agents/skills/hardware-test/templates/client-wifisave.yaml new file mode 100644 index 00000000000..c65581ba65f --- /dev/null +++ b/.agents/skills/hardware-test/templates/client-wifisave.yaml @@ -0,0 +1,77 @@ +# Package for the client board: it joins the device under test's AP (priority 10), posts +# `bogus_saves` bogus /wifisave requests `save_interval` apart, then posts the real credentials once. +# Include from a per-run yaml that holds the platform block and the substitutions (example-client.yaml); +# real_ssid and real_psk come from the scratch directory's secrets.yaml and must be URL safe. +# Set bogus_saves to 1 for a plain provisioning check. Log lines the driver script counts: +# "bogus save N", "bogus save status 200", "posting the real credentials", "real save status 200" +# The hammer is a single-mode script, so a reconnect to the AP (the device under test rebooted) does not +# start a second series on top of the first. +substitutions: + ap_password: hwtestpass + bogus_saves: "15" + save_interval: 3s + bogus_ssid: hwtest-no-such-network + bogus_psk: nosuchpassword + +esphome: + name: ${name} + +logger: + level: DEBUG + +http_request: + id: http + verify_ssl: false + timeout: 10s + +wifi: + id: wifi_id + networks: + - ssid: ${ap_ssid} + password: ${ap_password} + priority: 10 + - ssid: ${real_ssid} + password: ${real_psk} + on_connect: + - lambda: |- + char b[40]; + ESP_LOGI("test", "connected, ip %s", id(wifi_id).get_ip_addresses()[0].str_to(b)); + - if: + condition: + lambda: |- + char b[40]; + return strncmp(id(wifi_id).get_ip_addresses()[0].str_to(b), "192.168.4.", 10) == 0; + then: + - script.execute: hammer + +script: + - id: hammer + mode: single + then: + - delay: 3s + - logger.log: "hammering /wifisave with bogus credentials" + - repeat: + count: ${bogus_saves} + then: + - lambda: ESP_LOGI("test", "bogus save %u", (unsigned) iteration + 1); + - http_request.get: + url: "http://192.168.4.1/wifisave?ssid=${bogus_ssid}&psk=${bogus_psk}" + capture_response: true + on_response: + - lambda: ESP_LOGI("test", "bogus save status %d", response->status_code); + on_error: + - lambda: ESP_LOGW("test", "bogus save status -1"); + - delay: ${save_interval} + - logger.log: "posting the real credentials" + - http_request.get: + url: "http://192.168.4.1/wifisave?ssid=${real_ssid}&psk=${real_psk}" + capture_response: true + on_response: + - lambda: ESP_LOGI("test", "real save status %d", response->status_code); + on_error: + - lambda: ESP_LOGW("test", "real save status -1"); + +interval: + - interval: 5s + then: + - lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000)); diff --git a/.agents/skills/hardware-test/templates/dut-fallback-ap.yaml b/.agents/skills/hardware-test/templates/dut-fallback-ap.yaml new file mode 100644 index 00000000000..4c6e4d10d4a --- /dev/null +++ b/.agents/skills/hardware-test/templates/dut-fallback-ap.yaml @@ -0,0 +1,31 @@ +# Package for a device under test that never finds its network, falls back to its AP and serves the +# captive portal. Include it from a per-run yaml that holds the platform block and the substitutions +# (see example-dut.yaml). Bump `name` on every run: saved credentials are keyed by it, and a reused +# name boots straight onto the real network and never raises the AP. +substitutions: + ap_password: hwtestpass + ap_timeout: 15s + bogus_ssid: hwtest-no-such-network + bogus_psk: nosuchpassword + +esphome: + name: ${name} + +logger: + level: DEBUG + +wifi: + networks: + - ssid: ${bogus_ssid} + password: ${bogus_psk} + ap: + ssid: ${ap_ssid} + password: ${ap_password} + ap_timeout: ${ap_timeout} + +captive_portal: + +interval: + - interval: 5s + then: + - lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000)); diff --git a/.agents/skills/hardware-test/templates/example-client.yaml b/.agents/skills/hardware-test/templates/example-client.yaml new file mode 100644 index 00000000000..7b31309ba32 --- /dev/null +++ b/.agents/skills/hardware-test/templates/example-client.yaml @@ -0,0 +1,18 @@ +# Client board for the /wifisave hammer. Copy into a scratch directory next to a secrets.yaml; +# ap_ssid must match the device under test, real_ssid and real_psk must be URL safe. +substitutions: + name: hwtest-client-1 + ap_ssid: hwtest-ap + real_ssid: !secret wifi_ssid + real_psk: !secret wifi_password + bogus_saves: "15" + +esp32: + board: esp32dev + framework: + type: esp-idf + +# esp8266: {board: d1_mini} + +packages: + hwtest: !include client-wifisave.yaml diff --git a/.agents/skills/hardware-test/templates/example-dut.yaml b/.agents/skills/hardware-test/templates/example-dut.yaml new file mode 100644 index 00000000000..b15236ad019 --- /dev/null +++ b/.agents/skills/hardware-test/templates/example-dut.yaml @@ -0,0 +1,16 @@ +# Device under test for the /wifisave hammer. Copy into a scratch directory, keep the file name equal +# to `name`, pick one platform block, bump `name` every run. +substitutions: + name: hwtest-dut-1 + ap_ssid: hwtest-ap + +esp8266: + board: d1_mini + +# esp32: {board: esp32dev, framework: {type: esp-idf}} +# rtl87xx: {board: bw15} +# bk72xx: {board: generic-bk7238} # plus logger: {hardware_uart: UART1}, BK72xx logs on UART2 by default +# rp2040: {board: rpipico2w} + +packages: + hwtest: !include dut-fallback-ap.yaml From c0d8a0dfe9df6cd015e227b87f95aae589f9c33a Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Thu, 15 Oct 2026 21:38:15 +0000 Subject: [PATCH 442/651] [as734x] [3/3] Add illuminance, photopic irradiance, PAR, PPFD, colour temperature and RGB (#18012) Co-authored-by: J. Nick Koston --- esphome/components/as734x/as734x.cpp | 162 ++++++++++++++++++++ esphome/components/as734x/as734x.h | 47 +++++- esphome/components/as734x/color_helpers.cpp | 63 ++++++++ esphome/components/as734x/color_helpers.h | 22 +++ esphome/components/as734x/sensor.py | 62 +++++++- esphome/components/as734x/text_sensor.py | 28 ++++ esphome/core/defines.h | 1 + tests/components/as734x/common.yaml | 20 +++ 8 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 esphome/components/as734x/color_helpers.cpp create mode 100644 esphome/components/as734x/color_helpers.h create mode 100644 esphome/components/as734x/text_sensor.py diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 2331d89c5fe..5337b3918eb 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -1,10 +1,12 @@ #include "as734x.h" +#include "color_helpers.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" #include +#include namespace esphome::as734x { @@ -78,6 +80,41 @@ static_assert(sizeof(AS7341_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); static const float AS7341_GAIN_CORRECTION[] PROGMEM = {1.0577f, 1.0491f, 1.0479f, 1.0491f, 1.0207f, 1.0158f, 1.0109f, 1.0000f, 1.0003f, 0.9873f, 0.9593f, 0.0f, 0.0f}; static_assert(sizeof(AS7341_GAIN_CORRECTION) == GAIN_COUNT * sizeof(float)); +// clang-format off +// The three bands this chip does not have contribute nothing to the weighted sums. +static const ChannelContribution AS7341_CONTRIBUTIONS[] PROGMEM = { + {0.005261294f, 5.936230324f, 0.020518273f}, // F1 + {0.242615252f, 3.572528495f, 0.013525490f}, // F2 + {0.0f, 0.0f, 0.0f}, // FZ, absent + {0.174482552f, 2.914113437f, 0.011585145f}, // F3 + {1.479499075f, 2.452851227f, 0.010604986f}, // F4 + {0.0f, 0.0f, 0.0f}, // FY, absent + {1.906818555f, 1.940656433f, 0.008890531f}, // F5 + {0.0f, 0.0f, 0.0f}, // FXL, absent + {1.162058142f, 1.533976606f, 0.007441544f}, // F6 + {0.462374845f, 1.819838250f, 0.009393590f}, // F7 + {-0.017794572f, 1.232942274f, 0.006890812f}, // F8 + {-0.260730901f, -1.102368000f, -0.004647911f}, // NIR + {-0.022604075f, -0.033304813f, -0.000129106f}, // Clear +}; +static const ChannelTristimulus AS7341_TRISTIMULUS[] PROGMEM = { + {0.39814f, 0.01396f, 1.95010f}, // F1 + {1.29540f, 0.16748f, 6.45490f}, // F2 + {0.0f, 0.0f, 0.0f}, // FZ, absent + {0.36956f, 0.23538f, 2.78010f}, // F3 + {0.10902f, 1.42750f, 0.18501f}, // F4 + {0.0f, 0.0f, 0.0f}, // FY, absent + {0.71942f, 1.88670f, 0.15325f}, // F5 + {0.0f, 0.0f, 0.0f}, // FXL, absent + {1.78180f, 1.14200f, 0.09539f}, // F6 + {1.10110f, 0.46497f, 0.10563f}, // F7 + {-0.03991f, -0.02702f, 0.08866f}, // F8 + {-0.27597f, -0.24468f, -0.61140f}, // NIR + {-0.02347f, -0.01993f, -0.00938f}, // Clear +}; +// clang-format on +static_assert(sizeof(AS7341_CONTRIBUTIONS) == NUM_BANDS * sizeof(ChannelContribution)); +static_assert(sizeof(AS7341_TRISTIMULUS) == NUM_BANDS * sizeof(ChannelTristimulus)); const ChipInfo AS7341_CHIP PROGMEM = { .name = AS7341_NAME, .init = AS7341_INIT, @@ -85,6 +122,8 @@ const ChipInfo AS7341_CHIP PROGMEM = { .readout = AS7341_READOUT, .wavelengths = AS7341_WAVELENGTHS, .gain_correction = AS7341_GAIN_CORRECTION, + .contributions = AS7341_CONTRIBUTIONS, + .tristimulus = AS7341_TRISTIMULUS, .init_count = 1, .astep_reg = 0xCA, .cfg0_reg = 0xA9, @@ -147,6 +186,40 @@ static const float AS7343_GAIN_CORRECTION[] PROGMEM = { }; // clang-format on static_assert(sizeof(AS7343_GAIN_CORRECTION) == GAIN_COUNT * NUM_BANDS * sizeof(float)); +// clang-format off +static const ChannelContribution AS7343_CONTRIBUTIONS[] PROGMEM = { + {-0.028559089f, 0.450293995f, 0.001266925f}, // F1 + {-0.042150948f, 3.566379984f, 0.012515925f}, // F2 + {0.000661721f, 2.877424672f, 0.010243005f}, // FZ + {-0.108791278f, 2.273837581f, 0.009376157f}, // F3 + {-0.167172180f, 1.519298503f, 0.006017556f}, // F4 + {5.726660294f, 4.199345859f, 0.019357905f}, // FY + {0.0f, 0.0f, 0.0f}, // F5 + {-0.241492014f, 1.185259160f, 0.006516325f}, // FXL + {-0.047665296f, 1.759469826f, 0.009780714f}, // F6 + {-0.012516990f, 1.702006611f, 0.009950228f}, // F7 + {-0.026424974f, -1.059196137f, -0.005425656f}, // F8 + {-0.007565221f, -0.064827881f, -0.000232267f}, // NIR + {0.0f, 0.0f, 0.0f}, // Clear +}; +static const ChannelTristimulus AS7343_TRISTIMULUS[] PROGMEM = { + {-0.07879f, -0.03269f, -0.31295f}, // F1 + {-0.12235f, -0.01297f, -0.57885f}, // F2 + {1.99879f, -0.04011f, 10.00197f}, // FZ + {-0.33364f, -0.06889f, -0.31281f}, // F3 + {-0.06795f, -0.17453f, -0.19657f}, // F4 + {-0.45419f, 5.69083f, -0.11077f}, // FY + {0.0f, 0.0f, 0.0f}, // F5 + {5.27242f, -0.22956f, -0.06132f}, // FXL + {-0.05072f, -0.04762f, -0.03536f}, // F6 + {-0.04666f, -0.01295f, -0.06025f}, // F7 + {-0.03931f, -0.02797f, -0.12174f}, // F8 + {0.0f, 0.0f, 0.0f}, // NIR + {0.0f, 0.0f, 0.0f}, // Clear +}; +// clang-format on +static_assert(sizeof(AS7343_CONTRIBUTIONS) == NUM_BANDS * sizeof(ChannelContribution)); +static_assert(sizeof(AS7343_TRISTIMULUS) == NUM_BANDS * sizeof(ChannelTristimulus)); static constexpr ChipInfo as7343_chip(const char *name) { return { .name = name, @@ -155,6 +228,8 @@ static constexpr ChipInfo as7343_chip(const char *name) { .readout = AS7343_READOUT, .wavelengths = AS7343_WAVELENGTHS, .gain_correction = AS7343_GAIN_CORRECTION, + .contributions = AS7343_CONTRIBUTIONS, + .tristimulus = AS7343_TRISTIMULUS, .init_count = sizeof(AS7343_INIT) / sizeof(RegValue), .astep_reg = 0xD4, .cfg0_reg = 0xBF, @@ -272,6 +347,14 @@ void AS734XComponent::dump_config() { sensor::log_sensor(TAG, " ", label, this->basic_counts_sensors_[band]); } LOG_SENSOR(" ", "Saturation level", this->saturation_level_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Irradiance photopic", this->irradiance_photopic_sensor_); + LOG_SENSOR(" ", "Irradiance PAR", this->irradiance_par_sensor_); + LOG_SENSOR(" ", "PPFD", this->ppfd_sensor_); + LOG_SENSOR(" ", "Color temperature", this->color_temperature_sensor_); +#ifdef USE_AS734X_RGB + LOG_TEXT_SENSOR(" ", "RGB hex", this->rgb_hex_sensor_); +#endif if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } @@ -484,6 +567,83 @@ void AS734XComponent::publish_basic_counts_() { } } +// Each band contributes a fixed amount per basic count to every integrated quantity, so the +// totals are a weighted sum over the bands. +// The weighted sums can go negative under light the tables were not fitted for, such as pure blue or +// far red, so the published values are floored at zero. +void AS734XComponent::publish_light_metrics_() { + float irradiance_photopic = 0.0f; + float irradiance_par = 0.0f; + float ppfd = 0.0f; + + const ChannelContribution *contributions = pgm(this->chip_->contributions); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const float basic_count = this->basic_counts_[band]; + const ChannelContribution contribution = pgm(contributions[band]); + + irradiance_photopic += contribution.irradiance_photopic * basic_count; + irradiance_par += contribution.irradiance_par * basic_count; + ppfd += contribution.ppfd * basic_count; + } + irradiance_photopic = std::max(irradiance_photopic, 0.0f); + irradiance_par = std::max(irradiance_par, 0.0f); + ppfd = std::max(ppfd, 0.0f) * 1e3f; + const float illuminance = irradiance_photopic * LUMENS_PER_WATT; + + ESP_LOGV(TAG, "Photopic irradiance %.4f, PAR %.4f, PPFD %.4f, illuminance %.2f lx", irradiance_photopic, + irradiance_par, ppfd, illuminance); + if (this->illuminance_sensor_ != nullptr) { + this->illuminance_sensor_->publish_state(illuminance); + } + if (this->irradiance_photopic_sensor_ != nullptr) { + this->irradiance_photopic_sensor_->publish_state(irradiance_photopic); + } + if (this->irradiance_par_sensor_ != nullptr) { + this->irradiance_par_sensor_->publish_state(irradiance_par); + } + if (this->ppfd_sensor_ != nullptr) { + this->ppfd_sensor_->publish_state(ppfd); + } +} + +void AS734XComponent::publish_color_() { + float tri_x = 0.0f; + float tri_y = 0.0f; + float tri_z = 0.0f; + + const ChannelTristimulus *table = pgm(this->chip_->tristimulus); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const float basic_count = this->basic_counts_[band]; + const ChannelTristimulus tristimulus = pgm(table[band]); + + tri_x += tristimulus.x * basic_count; + tri_y += tristimulus.y * basic_count; + tri_z += tristimulus.z * basic_count; + } + + const float color_temperature = tristimulus_to_cct(tri_x, tri_y, tri_z); + ESP_LOGV(TAG, "XYZ %.4f, %.4f, %.4f -> CCT %.0f K", tri_x, tri_y, tri_z, color_temperature); + if (this->color_temperature_sensor_ != nullptr) { + this->color_temperature_sensor_->publish_state(color_temperature); + } + +#ifdef USE_AS734X_RGB + if (this->rgb_hex_sensor_ == nullptr) { + return; + } + // Convert to chromaticity first, so the colour describes the hue rather than the brightness. + // Absolute tristimulus values clamp to white under anything but dim light. + char rgb_hex[7] = "000000"; // no usable chromaticity reads as black rather than an empty string + const float sum = tri_x + tri_y + tri_z; + if (sum > MIN_TRISTIMULUS_SUM) { + uint8_t rgb[3]; + tristimulus_to_rgb(tri_x / sum, tri_y / sum, tri_z / sum, rgb[0], rgb[1], rgb[2]); + format_hex_to(rgb_hex, rgb, sizeof(rgb)); + } + this->rgb_hex_sensor_->publish_state(rgb_hex); +#endif +} + void AS734XComponent::publish_measurement_() { this->calculate_basic_counts_(); for (uint8_t band = 0; band < NUM_BANDS; band++) { @@ -492,6 +652,8 @@ void AS734XComponent::publish_measurement_() { } } this->publish_basic_counts_(); + this->publish_light_metrics_(); + this->publish_color_(); if (this->saturation_level_sensor_ != nullptr) { const uint32_t max_adc = std::min((this->reading_atime_ + 1UL) * (this->reading_astep_ + 1UL), MAX_ADC_COUNT); diff --git a/esphome/components/as734x/as734x.h b/esphome/components/as734x/as734x.h index f2b68d380ec..95c694da9ab 100644 --- a/esphome/components/as734x/as734x.h +++ b/esphome/components/as734x/as734x.h @@ -7,6 +7,9 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" +#ifdef USE_AS734X_RGB +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::as734x { @@ -47,6 +50,24 @@ static constexpr uint16_t VISIBLE_MAX_NM = 700; static constexpr uint16_t WIDEBAND_NM = 0; static constexpr uint16_t ABSENT_NM = 0xFFFF; // band this chip does not have +// Luminous efficacy at 555 nm, for turning photopic irradiance into lux. +static constexpr float LUMENS_PER_WATT = 683.002f; + +// How much one basic count of a band contributes to each integrated quantity. The factors come +// from the manufacturer's golden-device calibration, convolved for a reconstructed spectrum. +struct ChannelContribution { + float irradiance_photopic; // weighted by the CIE 1931 V(lambda) curve + float irradiance_par; // 400-700 nm only + float ppfd; // 400-700 nm only, scaled by 1e3 before publishing +}; + +// CIE 1931 tristimulus contribution of one basic count of a band. +struct ChannelTristimulus { + float x; + float y; + float z; +}; + struct RegValue { uint8_t reg; uint8_t value; @@ -55,11 +76,13 @@ struct RegValue { /// Per-chip constants, stored in flash. The pointed-to tables are in flash too. struct ChipInfo { const char *name; - const RegValue *init; // written in order; the bank is selected per register address - const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images - const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) - const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band - const float *gain_correction; // GAIN_COUNT entries, or GAIN_COUNT * NUM_BANDS when per band + const RegValue *init; // written in order; the bank is selected per register address + const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images + const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band + const float *gain_correction; // GAIN_COUNT entries, or GAIN_COUNT * NUM_BANDS when per band + const ChannelContribution *contributions; // per band + const ChannelTristimulus *tristimulus; // per band uint8_t init_count; uint8_t astep_reg; uint8_t cfg0_reg; @@ -117,6 +140,15 @@ class AS734XComponent : public PollingComponent, public i2c::I2CDevice { void set_basic_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->basic_counts_sensors_[band] = sensor; } SUB_SENSOR(saturation_level) + SUB_SENSOR(illuminance) + SUB_SENSOR(irradiance_photopic) + SUB_SENSOR(irradiance_par) + SUB_SENSOR(ppfd) + SUB_SENSOR(color_temperature) + +#ifdef USE_AS734X_RGB + void set_rgb_hex_sensor(text_sensor::TextSensor *sensor) { this->rgb_hex_sensor_ = sensor; } +#endif protected: bool select_bank_(uint8_t reg); @@ -126,6 +158,8 @@ class AS734XComponent : public PollingComponent, public i2c::I2CDevice { void read_step_(); void abort_measurement_(const LogString *reason); void calculate_basic_counts_(); + void publish_light_metrics_(); + void publish_color_(); void publish_measurement_(); void publish_basic_counts_(); float normalization_divisor_() const; @@ -157,6 +191,9 @@ class AS734XComponent : public PollingComponent, public i2c::I2CDevice { uint8_t atime_{0}; uint8_t step_{0}; bool bank_low_{true}; // unknown until setup() writes CFG0 +#ifdef USE_AS734X_RGB + text_sensor::TextSensor *rgb_hex_sensor_{nullptr}; +#endif }; } // namespace esphome::as734x diff --git a/esphome/components/as734x/color_helpers.cpp b/esphome/components/as734x/color_helpers.cpp new file mode 100644 index 00000000000..b456038eda7 --- /dev/null +++ b/esphome/components/as734x/color_helpers.cpp @@ -0,0 +1,63 @@ +#include "color_helpers.h" + +#include +#include + +namespace esphome::as734x { + +namespace { + +// Range McCamy's approximation is meaningful over. +constexpr float CCT_MIN_K = 1000.0f; +constexpr float CCT_MAX_K = 25000.0f; + +#ifdef USE_AS734X_RGB +// sRGB transfer function. +float gamma_correct(float channel) { + if (channel <= 0.0031308f) { + return 12.92f * channel; + } + return 1.055f * std::pow(channel, 1.0f / 2.4f) - 0.055f; +} + +uint8_t to_byte(float channel) { return static_cast(std::clamp(channel, 0.0f, 1.0f) * 255.0f); } +#endif + +} // namespace + +float tristimulus_to_cct(float x, float y, float z) { + const float sum = x + y + z; + // The conversion matrices carry negative coefficients, so a spectrum unlike the one they were + // calibrated against can drive the sum to zero or below. Chromaticity is undefined there, and + // McCamy's formula would still return a plausible looking number from it. + if (sum <= MIN_TRISTIMULUS_SUM) { + return NAN; + } + const float chroma_x = x / sum; + const float chroma_y = y / sum; + + // McCamy's approximation: cct = 437 n^3 + 3601 n^2 + 6861 n + 5517. The pole at + // chroma_y = 0.1858 sends the cubic off to either infinity, and it stays meaningless well + // either side of that, so the answer is only reported where the approximation holds. + const float denominator = 0.1858f - chroma_y; + if (std::abs(denominator) < 1e-4f) { + return NAN; + } + const float n = (chroma_x - 0.3320f) / denominator; + const float cct = ((437.0f * n + 3601.0f) * n + 6861.0f) * n + 5517.0f; + return (cct >= CCT_MIN_K && cct <= CCT_MAX_K) ? cct : NAN; +} + +#ifdef USE_AS734X_RGB +void tristimulus_to_rgb(float x, float y, float z, uint8_t &r, uint8_t &g, uint8_t &b) { + const float r_linear = 3.2406f * x - 1.5372f * y - 0.4986f * z; + const float g_linear = -0.9689f * x + 1.8758f * y + 0.0415f * z; + const float b_linear = 0.0557f * x - 0.2040f * y + 1.0570f * z; + + r = to_byte(gamma_correct(r_linear)); + g = to_byte(gamma_correct(g_linear)); + b = to_byte(gamma_correct(b_linear)); +} +#endif + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/color_helpers.h b/esphome/components/as734x/color_helpers.h new file mode 100644 index 00000000000..1bf382c4bc6 --- /dev/null +++ b/esphome/components/as734x/color_helpers.h @@ -0,0 +1,22 @@ +#pragma once +#include + +#include "esphome/core/defines.h" + +namespace esphome::as734x { + +// Smallest tristimulus sum that still carries a usable hue. +constexpr float MIN_TRISTIMULUS_SUM = 0.001f; + +// Correlated colour temperature in kelvin from CIE 1931 tristimulus values, using McCamy's +// approximation. Returns NAN when the input carries no usable hue, or when the result falls +// outside the range the approximation holds over. +float tristimulus_to_cct(float x, float y, float z); + +#ifdef USE_AS734X_RGB +// sRGB components for CIE 1931 tristimulus values. Pass chromaticity (x + y + z == 1) rather than +// absolute values, otherwise anything but dim light clamps to white. +void tristimulus_to_rgb(float x, float y, float z, uint8_t &r, uint8_t &g, uint8_t &b); +#endif + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/sensor.py b/esphome/components/as734x/sensor.py index 726d15d6fff..85fa6635320 100644 --- a/esphome/components/as734x/sensor.py +++ b/esphome/components/as734x/sensor.py @@ -2,7 +2,18 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv -from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT +from esphome.const import ( + CONF_COLOR_TEMPERATURE, + CONF_COUNTS, + CONF_ILLUMINANCE, + CONF_TYPE, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_IRRADIANCE, + STATE_CLASS_MEASUREMENT, + UNIT_KELVIN, + UNIT_LUX, + UNIT_PERCENT, +) import esphome.final_validate as fv from esphome.types import ConfigType @@ -11,10 +22,21 @@ from . import BANDS_41, BANDS_43, CONF_AS734X_ID, DOMAIN, AS734XComponent DEPENDENCIES = [DOMAIN] CONF_BASIC_COUNTS = "basic_counts" +CONF_IRRADIANCE_PAR = "irradiance_par" +CONF_IRRADIANCE_PHOTOPIC = "irradiance_photopic" +CONF_PPFD = "ppfd" CONF_SATURATION_LEVEL = "saturation_level" +UNIT_IRRADIANCE = "W/m\u00b2" +UNIT_PPFD = "\u00b5mol/m\u00b2/s" + ICON_COUNTS = "mdi:counter" ICON_SATURATION = "mdi:weather-sunny-alert" +ICON_ILLUMINANCE = "mdi:weather-sunny" +ICON_IRRADIANCE_PHOTOPIC = "mdi:sun-wireless-outline" +ICON_PAR = "mdi:sprout" +ICON_PPFD = "mdi:sprout-outline" +ICON_COLOR_TEMPERATURE = "mdi:sun-thermometer-outline" _COUNTS_SENSOR = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, @@ -48,6 +70,39 @@ CONFIG_SCHEMA = cv.Schema( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + icon=ICON_ILLUMINANCE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_IRRADIANCE_PHOTOPIC): sensor.sensor_schema( + unit_of_measurement=UNIT_IRRADIANCE, + icon=ICON_IRRADIANCE_PHOTOPIC, + accuracy_decimals=3, + device_class=DEVICE_CLASS_IRRADIANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_IRRADIANCE_PAR): sensor.sensor_schema( + unit_of_measurement=UNIT_IRRADIANCE, + icon=ICON_PAR, + accuracy_decimals=3, + device_class=DEVICE_CLASS_IRRADIANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PPFD): sensor.sensor_schema( + unit_of_measurement=UNIT_PPFD, + icon=ICON_PPFD, + accuracy_decimals=3, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_KELVIN, + icon=ICON_COLOR_TEMPERATURE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), } ) @@ -82,3 +137,8 @@ async def to_code(config: ConfigType) -> None: sensors = sensor.sub_sensors(config) await sensors(CONF_SATURATION_LEVEL, hub.set_saturation_level_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_IRRADIANCE_PHOTOPIC, hub.set_irradiance_photopic_sensor) + await sensors(CONF_IRRADIANCE_PAR, hub.set_irradiance_par_sensor) + await sensors(CONF_PPFD, hub.set_ppfd_sensor) + await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor) diff --git a/esphome/components/as734x/text_sensor.py b/esphome/components/as734x/text_sensor.py new file mode 100644 index 00000000000..d010f2d6a42 --- /dev/null +++ b/esphome/components/as734x/text_sensor.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_AS734X_ID, DOMAIN, AS734XComponent + +DEPENDENCIES = [DOMAIN] + +CONF_RGB_HEX = "rgb_hex" + +ICON_PALETTE = "mdi:palette" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent), + cv.Optional(CONF_RGB_HEX): text_sensor.text_sensor_schema(icon=ICON_PALETTE), + } +) + + +async def to_code(config: ConfigType) -> None: + if (conf := config.get(CONF_RGB_HEX)) is None: + return + hub = await cg.get_variable(config[CONF_AS734X_ID]) + cg.add_define("USE_AS734X_RGB") + sens = await text_sensor.new_text_sensor(conf) + cg.add(hub.set_rgb_hex_sensor(sens)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 607bc7a42b4..c901ff3e844 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -45,6 +45,7 @@ #define USE_AREAS #define USE_AS7341 #define USE_AS7343 +#define USE_AS734X_RGB #define USE_BINARY_SENSOR #define USE_BINARY_SENSOR_CLICK_TRIGGER #define USE_BINARY_SENSOR_FILTER diff --git a/tests/components/as734x/common.yaml b/tests/components/as734x/common.yaml index 0ad83bac4dc..48f77ac907e 100644 --- a/tests/components/as734x/common.yaml +++ b/tests/components/as734x/common.yaml @@ -42,6 +42,16 @@ sensor: name: AS7341 Clear basic saturation_level: name: AS7341 Saturation + illuminance: + name: AS7341 Illuminance + irradiance_photopic: + name: AS7341 Photopic irradiance + irradiance_par: + name: AS7341 PAR + ppfd: + name: AS7341 PPFD + color_temperature: + name: AS7341 Colour temperature - platform: as734x as734x_id: as7343_hub counts: @@ -58,6 +68,10 @@ sensor: name: AS7343 FY basic saturation_level: name: AS7343 Saturation + illuminance: + name: AS7343 Illuminance + color_temperature: + name: AS7343 Colour temperature - platform: as734x as734x_id: tcs3448_hub counts: @@ -65,3 +79,9 @@ sensor: name: TCS3448 F1 fy: name: TCS3448 FY + +text_sensor: + - platform: as734x + as734x_id: as7343_hub + rgb_hex: + name: AS7343 RGB From c101b3c101fce1f341c2b59eb1980c465c4e175a Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:38:28 +0100 Subject: [PATCH 443/651] [mipi_spi] Honour show_test_card when pages or a lambda are set (#20807) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/display/display.cpp | 1 - esphome/components/mipi_spi/mipi_spi.h | 6 +- tests/components/display/test_test_card.cpp | 80 +++++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/components/display/test_test_card.cpp diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index f006c64be76..ba8f85463f4 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -843,7 +843,6 @@ constexpr uint8_t TESTCARD_FONT[3][8] PROGMEM = {{0x41, 0x7F, 0x7F, 0x09, 0x19, void Display::test_card() { int w = get_width(), h = get_height(), image_w, image_h; this->clear(); - this->show_test_card_ = false; image_w = std::min(w - 20, 310); image_h = std::min(h - 20, 255); int shift_x = (w - image_w) / 2; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 3a772862f31..f55ae723780 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -570,12 +570,12 @@ class MipiSpiBuffer if (this->auto_clear_enabled_) { this->clear(); } - if (this->page_ != nullptr) { + if (this->show_test_card_) { + this->test_card(); + } else if (this->page_ != nullptr) { this->page_->get_writer()(*this); } else if (this->writer_.has_value()) { (*this->writer_)(*this); - } else { - this->test_card(); } #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE esph_log_v(TAG, "Drawing from line %d took %dms", this->start_line_, millis() - lap); diff --git a/tests/components/display/test_test_card.cpp b/tests/components/display/test_test_card.cpp new file mode 100644 index 00000000000..555611bc093 --- /dev/null +++ b/tests/components/display/test_test_card.cpp @@ -0,0 +1,80 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/display/display.h" +#include "esphome/core/application.h" + +namespace esphome::display::testing { + +// Records the colour of every pixel written, so a drawn test card shows up as non-black pixels. +class TestCardDisplay : public Display { + public: + void draw_pixel_at(int x, int y, Color color) override { this->colors_.push_back(color.raw_32); } + void update() override { this->do_update_(); } + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + + bool drew_test_card() const { + return std::any_of(this->colors_.begin(), this->colors_.end(), [](uint32_t c) { return c != 0; }); + } + void reset() { this->colors_.clear(); } + + protected: + int get_width_internal() override { return 64; } + int get_height_internal() override { return 64; } + std::vector colors_; +}; + +namespace { +class TestCard : public ::testing::Test { + protected: + void SetUp() override { + // test_card() stops the poller, which needs the scheduler; the test main does not construct App. + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + } + int writer_calls_{0}; + display_writer_t count_writer_() { + return [this](Display &) { this->writer_calls_++; }; + } +}; +} // namespace + +TEST_F(TestCard, DrawnOnEveryUpdateInsteadOfLambda) { + TestCardDisplay display; + display.set_writer(this->count_writer_()); + display.show_test_card(); + for (int i = 0; i != 3; i++) { + display.reset(); + display.update(); + EXPECT_TRUE(display.drew_test_card()) << "update " << i; + } + EXPECT_EQ(this->writer_calls_, 0); +} + +TEST_F(TestCard, DrawnOnEveryUpdateInsteadOfPages) { + TestCardDisplay display; + DisplayPage page(this->count_writer_()); + display.set_pages({&page}); + display.show_test_card(); + for (int i = 0; i != 3; i++) { + display.reset(); + display.update(); + EXPECT_TRUE(display.drew_test_card()) << "update " << i; + } + EXPECT_EQ(this->writer_calls_, 0); +} + +TEST_F(TestCard, LambdaRunsWithoutTestCard) { + TestCardDisplay display; + display.set_writer(this->count_writer_()); + display.update(); + display.update(); + EXPECT_FALSE(display.drew_test_card()); + EXPECT_EQ(this->writer_calls_, 2); +} + +} // namespace esphome::display::testing From 5b234bcc201b44774a678bccf4adf1f39000b2bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 12:53:51 -1000 Subject: [PATCH 444/651] [wifi] Leave the cooldown from the loop after a credential save, not from the caller's context (#20802) --- esphome/components/wifi/wifi_component.cpp | 28 ++++++++-------------- esphome/components/wifi/wifi_component.h | 2 -- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 156ba6e9ffa..c37ec44384b 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -854,10 +854,11 @@ void WiFiComponent::loop() { switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { this->status_set_warning(LOG_STR("waiting to reconnect")); - // Skip cooldown if new credentials were provided while connecting + // Credentials not tried yet: retry whatever the driver reports. The flag outlives an adapter restart, + // which only lands back here, and start_connecting() clears it once they are tried if (this->skip_cooldown_next_cycle_) { - this->skip_cooldown_next_cycle_ = false; - this->check_connecting_finished(now); + ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); + this->retry_connect(); break; } // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP @@ -1183,7 +1184,7 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->init_sta(1); this->add_sta(ap); this->selected_sta_index_ = 0; - // When new credentials are set (e.g., from improv), skip cooldown to retry immediately + // New credentials end the next cooldown early; start_connecting() clears this once they are tried 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 @@ -1252,29 +1253,18 @@ void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) { WiFiAP sta{}; sta.set_ssid(ssid); sta.set_password(password); + // No retry here: a captive portal save runs in lwIP's context on the ESP8266, where retry_connect() yields and + // panics. set_sta() flags the credentials and loop() tries them this->set_sta(sta); - - // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected) - this->connect_soon_(); -} - -void WiFiComponent::connect_soon_() { #ifdef USE_WIFI_AP_EXCLUSIVE - // 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. + // The AP pauses for the new credentials once its answer is out, and the STA state machine picks them up if (this->ap_setup_) { this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { if (this->ap_setup_) this->pause_exclusive_ap_(); }); - return; } #endif - // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing - if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { - ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); - this->retry_connect(); - } } void WiFiComponent::start_connecting(const WiFiAP &ap) { @@ -1366,6 +1356,8 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { // enters COOLDOWN without calling start_connecting(), and ESP8266 clears it again // after wifi_station_connect(), whose callbacks fire synchronously. this->error_from_callback_ = false; + // Every attempt passes through here, so the credentials set_sta() flagged are tried now + this->skip_cooldown_next_cycle_ = false; if (!this->wifi_sta_connect_(ap)) { ESP_LOGE(TAG, "wifi_sta_connect_ failed"); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 8cfdaaff00c..1048df19c39 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -822,8 +822,6 @@ class WiFiComponent final : public Component { return true; } - void connect_soon_(); - bool wifi_loop_(); #ifdef USE_ESP8266 void process_pending_callbacks_(); From 69215deb588e56e19571d89c10f85d582e32e6b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 12:54:34 -1000 Subject: [PATCH 445/651] [core] Make the YAML load listener hook public (#20805) --- esphome/yaml_util.py | 17 ++++++++++------- tests/unit_tests/test_yaml_util.py | 24 ++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 6b7e49d3802..d887cab959f 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -54,8 +54,11 @@ _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]] = [] +# Called with every file the YAML loader opens; ``track_yaml_loads`` builds on it. +# Used by device-builder (esphome/device-builder), whose listener filters by +# thread because the hook is process-global; keep the name and the per-file +# ``Path`` signature. Not otherwise a supported API: use at your own risk. +load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] @@ -127,11 +130,11 @@ def track_yaml_loads() -> Generator[list[Path]]: def _on_load(fname: Path) -> None: loaded.append(Path(fname).resolve()) - _load_listeners.append(_on_load) + load_listeners.append(_on_load) try: yield loaded finally: - _load_listeners.remove(_on_load) + load_listeners.remove(_on_load) class ESPHomeDataBase: @@ -667,7 +670,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: secrets.add(Path(fname).resolve()) with track_yaml_loads() as loaded: - _load_listeners.append(_capture_secret) + load_listeners.append(_capture_secret) try: try: data = load_yaml(config_path) @@ -679,7 +682,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: data, warn_on_unresolved=False, warn_on_load_error=False ) finally: - _load_listeners.remove(_capture_secret) + load_listeners.remove(_capture_secret) # Deduplicate while preserving first-seen order. seen: set[Path] = set() @@ -1120,7 +1123,7 @@ def load_yaml( def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> Any: """Load a YAML file.""" - for listener in _load_listeners: + for listener in tuple(load_listeners): listener(fname) try: with fname.open(encoding="utf-8") as f_handle: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 2a4b5f8e5c5..8ce554f0a52 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -431,12 +431,32 @@ def test_track_yaml_loads_empty_outside_context(tmp_path: Path) -> None: def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None: """Listener is removed even if the body raises.""" - before = len(yaml_util._load_listeners) + before = len(yaml_util.load_listeners) with pytest.raises(RuntimeError), yaml_util.track_yaml_loads(): raise RuntimeError("boom") - assert len(yaml_util._load_listeners) == before + assert len(yaml_util.load_listeners) == before + + +def test_load_listener_removing_itself_does_not_skip_the_next( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A listener that removes itself while notified does not skip the one after it.""" + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("key: value\n") + listeners: list[Callable[[Path], None]] = [] + monkeypatch.setattr(yaml_util, "load_listeners", listeners) + seen: list[Path] = [] + + def first(fname: Path) -> None: + listeners.remove(first) + + listeners.extend((first, seen.append)) + yaml_util.load_yaml(yaml_file) + + assert seen == [yaml_file.resolve()] + assert listeners == [seen.append] def test_track_yaml_loads_no_duplicate_load_on_top_level_include_failure( From c838d4826e1143993f717c049a174aee3c65fd89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 13:09:02 -1000 Subject: [PATCH 446/651] [core] Add ESPHOME_strstr_P (#20811) --- esphome/core/progmem.h | 2 ++ tests/components/core/test_progmem_string_table.cpp | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 677ef0e2afb..02ec4778237 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -26,6 +26,7 @@ #define ESPHOME_strncmp_P strncmp_P #define ESPHOME_strncasecmp_P strncasecmp_P #define ESPHOME_strlen_P strlen_P +#define ESPHOME_strstr_P strstr_P // Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) using ProgmemStr = const __FlashStringHelper *; // Storage class for PROGMEM_STRING_TABLE data; LOG_STR_ARG treats a LogString as PGM_P @@ -43,6 +44,7 @@ using ProgmemStr = const __FlashStringHelper *; #define ESPHOME_strncmp_P strncmp #define ESPHOME_strncasecmp_P strncasecmp #define ESPHOME_strlen_P strlen +#define ESPHOME_strstr_P strstr // Type for pointers to strings (no PROGMEM on non-ESP8266 platforms) using ProgmemStr = const char *; // No-op on non-ESP8266 platforms where PROGMEM itself is a no-op. diff --git a/tests/components/core/test_progmem_string_table.cpp b/tests/components/core/test_progmem_string_table.cpp index 4069bd4d2ab..e6820ec50de 100644 --- a/tests/components/core/test_progmem_string_table.cpp +++ b/tests/components/core/test_progmem_string_table.cpp @@ -62,4 +62,12 @@ TEST(ProgmemStringTable, LargeTableIsScanned) { "3123456789012345678901234567890123456789"); } +TEST(ProgmemStringTable, StrstrFindsTableEntryInRamString) { + const char *line = "MODE HEAT ON"; + const auto *heat = reinterpret_cast(SmallStrings::get_progmem_str(1, SmallStrings::LAST_INDEX)); + const auto *cool = reinterpret_cast(SmallStrings::get_progmem_str(2, SmallStrings::LAST_INDEX)); + EXPECT_EQ(ESPHOME_strstr_P(line, heat), line + 5); + EXPECT_EQ(ESPHOME_strstr_P(line, cool), nullptr); +} + } // namespace esphome::core::testing From 1e89abdb134e402a88d0e05b7e3cc236902830dd Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 16 Oct 2026 01:17:46 +0200 Subject: [PATCH 447/651] [http_request] Allow a per-request timeout (#20821) Co-authored-by: p1ngb4ck Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/http_request/__init__.py | 5 +++ .../components/http_request/http_request.h | 33 ++++++++++++++----- .../http_request/http_request_arduino.cpp | 12 ++++--- .../http_request/http_request_arduino.h | 3 +- .../http_request/http_request_host.cpp | 3 +- .../http_request/http_request_host.h | 3 +- .../http_request/http_request_idf.cpp | 13 +++++--- .../http_request/http_request_idf.h | 3 +- .../components/http_request/http_request.yaml | 4 +++ 9 files changed, 57 insertions(+), 22 deletions(-) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 89fe5f8b1cc..a97aee14622 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -270,6 +270,7 @@ HTTP_REQUEST_ACTION_SCHEMA = cv.Schema( cv.Optional(CONF_ON_RESPONSE): automation.validate_automation(single=True), cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), cv.Optional(CONF_MAX_RESPONSE_BUFFER_SIZE, default="1kB"): cv.validate_bytes, + cv.Optional(CONF_TIMEOUT): cv.templatable(cv.positive_time_period_milliseconds), } ) HTTP_REQUEST_GET_ACTION_SCHEMA = automation.maybe_conf( @@ -347,6 +348,10 @@ async def http_request_action_to_code( cg.add(var.set_max_response_buffer_size(config[CONF_MAX_RESPONSE_BUFFER_SIZE])) + if CONF_TIMEOUT in config: + template_ = await cg.templatable(config[CONF_TIMEOUT], args, cg.uint32) + cg.add(var.set_timeout(template_)) + if CONF_BODY in config: template_ = await cg.templatable(config[CONF_BODY], args, cg.std_string) cg.add(var.set_body(template_)) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 424fcf84e86..713de247c5b 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -252,6 +252,8 @@ class HttpContainer : public Parented { virtual void end() = 0; void set_secure(bool secure) { this->secure_ = secure; } + void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; } + uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; } void set_chunked(bool chunked) { this->is_chunked_ = chunked; } size_t get_bytes_read() const { return this->bytes_read_; } @@ -283,6 +285,7 @@ class HttpContainer : public Parented { protected: size_t bytes_read_{0}; + uint32_t watchdog_timeout_{0}; bool secure_{false}; bool is_chunked_{false}; ///< True if response uses chunked transfer encoding std::vector
response_headers_{}; @@ -339,6 +342,11 @@ class HttpRequestComponent : public Component { uint32_t get_timeout() const { return this->timeout_; } void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; } uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; } + uint32_t get_watchdog_timeout(uint32_t timeout_ms) const { + if (this->watchdog_timeout_ == 0 || timeout_ms <= this->timeout_) + return this->watchdog_timeout_; + return this->watchdog_timeout_ + 3 * (timeout_ms - this->timeout_); + } void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; } @@ -412,7 +420,7 @@ class HttpRequestComponent : public Component { std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers) { // Call perform() directly to avoid ambiguity with the deprecated overloads - return this->perform(url, method, body, request_headers, {}); + return this->perform(url, method, body, request_headers, {}, this->timeout_); } std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers) { @@ -437,7 +445,7 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url.c_str(), method.c_str(), body, request_headers, lower); + return this->perform(url.c_str(), method.c_str(), body, request_headers, lower, this->timeout_); } // Remove before 2027.1.0 @@ -453,7 +461,7 @@ class HttpRequestComponent : public Component { lower.push_back(str_lower_case(h)); // NOLINT } return this->perform(url.c_str(), method.c_str(), body, - std::vector
(request_headers.begin(), request_headers.end()), lower); + std::vector
(request_headers.begin(), request_headers.end()), lower, this->timeout_); } // Remove before 2027.1.0 @@ -463,13 +471,19 @@ class HttpRequestComponent : public Component { const std::vector &lower_case_collect_headers) { return this->perform(url.c_str(), method.c_str(), body, std::vector
(request_headers.begin(), request_headers.end()), - lower_case_collect_headers); + lower_case_collect_headers, this->timeout_); } std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { - return this->perform(url, method, body, request_headers, lower_case_collect_headers); + return this->perform(url, method, body, request_headers, lower_case_collect_headers, this->timeout_); + } + std::shared_ptr start(const char *url, const char *method, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) { + return this->perform(url, method, body, request_headers, lower_case_collect_headers, timeout_ms); } std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers, @@ -480,7 +494,8 @@ class HttpRequestComponent : public Component { protected: virtual std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) = 0; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; uint16_t redirect_limit_{}; @@ -494,6 +509,7 @@ template class HttpRequestSendAction final : public Action class HttpRequestSendAction final : public Actiontimeout_.has_value() ? this->timeout_.value(x...) : this->parent_->get_timeout(); auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, - request_headers, this->lower_case_collect_headers_); + request_headers, this->lower_case_collect_headers_, timeout); auto captured_args = std::make_tuple(x...); @@ -565,7 +582,7 @@ template class HttpRequestSendAction final : public Actionparent_->get_timeout(); + const uint32_t read_timeout = timeout; while (container->get_bytes_read() < max_length) { int read_or_error = container->read(buf + read_index, std::min(max_length - read_index, 512)); App.feed_wdt(); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index deb9c0fec25..2062122adf6 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -26,7 +26,8 @@ static constexpr int ESP8266_SSL_ERR_OOM = -1000; std::shared_ptr HttpRequestArduino::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) { + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) { if (!network::is_connected()) { this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); @@ -40,8 +41,9 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons bool secure = strstr(url, "https:") != nullptr; container->set_secure(secure); + container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); - watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(container->get_watchdog_timeout()); if (this->follow_redirects_) { container->client_.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS); @@ -90,7 +92,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons } container->client_.setReuse(true); - container->client_.setTimeout(this->timeout_); + container->client_.setTimeout(timeout_ms); if (this->useragent_ != nullptr) { container->client_.setUserAgent(this->useragent_); @@ -203,7 +205,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons // and sets content_length = bytes_read_ so is_read_complete() returns true. int HttpContainerArduino::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); WiFiClient *stream_ptr = this->client_.getStreamPtr(); if (stream_ptr == nullptr) { @@ -398,7 +400,7 @@ int HttpContainerArduino::read_chunked_(uint8_t *buf, size_t max_len, WiFiClient } void HttpContainerArduino::end() { - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); this->client_.end(); } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 62737f4d0d1..d5e3f499da3 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -56,7 +56,8 @@ class HttpRequestArduino final : public HttpRequestComponent { protected: std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) override; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) override; #ifdef USE_ESP8266 uint16_t tls_buffer_size_rx_{512}; uint16_t tls_buffer_size_tx_{512}; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 1856b97e6e9..65d9fb0255e 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -20,7 +20,8 @@ ESPHOME_LOG_TAG(TAG, "http_request"); std::shared_ptr HttpRequestHost::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) { + const std::vector &lower_case_collect_headers, + uint32_t) { if (!network::is_connected()) { this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 0ae9f2e27b9..245aba7340e 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -20,7 +20,8 @@ class HttpRequestHost final : public HttpRequestComponent { public: std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) override; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } protected: diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index e429df9b971..879abb662b7 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -52,7 +52,8 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { std::shared_ptr HttpRequestIDF::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) { + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) { if (!network::is_connected()) { this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); @@ -82,7 +83,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch config.url = url; config.method = method_idf; - config.timeout_ms = this->timeout_; + config.timeout_ms = timeout_ms; config.disable_auto_redirect = !this->follow_redirects_; config.max_redirection_count = this->redirect_limit_; config.auth_type = HTTP_AUTH_TYPE_BASIC; @@ -104,7 +105,8 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch config.buffer_size_tx = this->buffer_size_tx_; const uint32_t start = millis(); - watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); + const uint32_t watchdog_timeout = this->get_watchdog_timeout(timeout_ms); + watchdog::WatchdogManager wdm(watchdog_timeout); config.event_handler = http_event_handler; @@ -117,6 +119,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch std::shared_ptr container = std::make_shared(client); container->set_parent(this); + container->set_watchdog_timeout(watchdog_timeout); container->set_secure(secure); @@ -261,7 +264,7 @@ bool HttpContainerIDF::is_read_complete() const { // This is passed through as a negative return value, which callers treat as an error. int HttpContainerIDF::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); // Check if we've already read all expected content (non-chunked and no-body only). // Use the base class check here, NOT the override: esp_http_client_is_complete_data_received() @@ -304,7 +307,7 @@ void HttpContainerIDF::end() { if (this->client_ == nullptr) { return; // Already cleaned up } - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); esp_http_client_close(this->client_); esp_http_client_cleanup(this->client_); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index f84dc9576bd..9e7864bb56c 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -43,7 +43,8 @@ class HttpRequestIDF final : public HttpRequestComponent { protected: std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) override; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE uint16_t buffer_size_rx_{}; uint16_t buffer_size_tx_{}; diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index b82c0581d1b..180db583469 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -31,6 +31,10 @@ esphome: request_headers: Content-Type: application/json body: "Some data" + timeout: 15s + - http_request.get: + url: https://esphome.io + timeout: !lambda "return 10000;" - http_request.post: url: https://esphome.io request_headers: From cfdaf958e907b39206d472c8e24a5720a15c01d9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 15 Oct 2026 19:32:35 -0400 Subject: [PATCH 448/651] [audio][i2s_audio] Fix 16-bit mono playback at 44.1 kHz on the original ESP32 (#20806) --- .../audio/audio_transfer_buffer.cpp | 17 ++++++++ .../components/audio/audio_transfer_buffer.h | 6 +++ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 10 ++++- .../i2s_audio/speaker/i2s_audio_speaker.h | 6 ++- .../speaker/i2s_audio_speaker_standard.cpp | 40 ++++++++++++++----- .../speaker/i2s_audio_speaker_standard.h | 3 ++ 6 files changed, 68 insertions(+), 14 deletions(-) diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index 01fd4bb68a6..b129efd7f42 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -268,6 +268,23 @@ void RingBufferAudioSource::clear_buffered_data() { this->ring_buffer_->reset(); } +size_t RingBufferAudioSource::expose_padded_partial(size_t frame_bytes) { + if (this->current_available_ > 0 || this->queued_length_ > 0) { + return 0; + } + // Moves any trailing bytes still inside a held item into the splice buffer + this->release_item_(); + const size_t real_bytes = this->splice_length_; + if (real_bytes == 0 || frame_bytes == 0 || real_bytes % frame_bytes != 0) { + return 0; + } + std::memset(this->splice_buffer_ + real_bytes, 0, this->alignment_bytes_ - real_bytes); + this->splice_length_ = 0; + this->current_data_ = this->splice_buffer_; + this->current_available_ = this->alignment_bytes_; + return real_bytes; +} + bool RingBufferAudioSource::has_buffered_data() const { // splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion // bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports. diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index 074684f0686..d28328138db 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -254,6 +254,12 @@ class RingBufferAudioSource : public AudioReadableBuffer { /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread. void clear_buffered_data(); + /// @brief Zero-pads held whole frames short of an alignment unit and exposes them, so a stream's tail is not + /// stranded. A held partial frame is left to wait for its remaining bytes. + /// @param frame_bytes Bytes per audio frame + /// @return Real (unpadded) bytes exposed, or 0 if nothing is exposable + size_t expose_padded_partial(size_t frame_bytes); + /// @brief Returns a mutable pointer to the currently exposed audio data. /// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame /// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index c644ece5428..85c0e6358a2 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -372,10 +372,18 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea this->current_stream_info_.bytes_to_samples(bytes_read)); } +bool I2SAudioSpeakerBase::esp32_mono_swap_needed_() const { +#ifdef USE_ESP32_VARIANT_ESP32 + return this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16; +#else + return false; +#endif // USE_ESP32_VARIANT_ESP32 +} + void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { #ifdef USE_ESP32_VARIANT_ESP32 // For ESP32 16-bit mono mode, adjacent samples need to be swapped. - if (this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16) { + if (this->esp32_mono_swap_needed_()) { int16_t *samples = reinterpret_cast(data); size_t sample_count = bytes_read / sizeof(int16_t); for (size_t i = 0; i + 1 < sample_count; i += 2) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index d73d2da8794..07b7e5d1905 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -160,9 +160,11 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); + /// @brief Whether the output is 16-bit mono on the original ESP32, which sends each pair of samples swapped. + bool esp32_mono_swap_needed_() const; + /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. - /// Only applies when running on original ESP32 with 16-bit mono output. Operates on the data that is - /// handed to the I2S peripheral, so the check uses the output (post-narrowing) stream info. + /// Only applies when esp32_mono_swap_needed_() is true. `data` must start on a DMA word and hold whole pairs. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 1a6b021fffa..d6c77d28b42 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -44,10 +44,10 @@ static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURAT // it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back // from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a // noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths. -static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) { +static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info, uint32_t frame_multiple) { const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1); - return std::min(frames_from_duration, max_frames); + return (std::min(frames_from_duration, max_frames) / frame_multiple) * frame_multiple; } void I2SAudioSpeaker::dump_config() { @@ -85,10 +85,12 @@ void I2SAudioSpeaker::run_speaker_task() { // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); - // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and - // avoids unnecessary single-frame splices. + // Hand over whole ESP32 mono sample pairs so writes stay word-aligned + const size_t source_alignment = bytes_per_frame * this->dma_frame_multiple_(); + const bool pair_aligned = source_alignment > bytes_per_frame; + // Round the ring buffer size down to a multiple of source_alignment so the wrap boundary avoids splices. const size_t ring_buffer_size = - (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; + (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / source_alignment) * source_alignment; // Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral. const uint8_t channels = this->current_stream_info_.get_channels(); @@ -108,7 +110,8 @@ void I2SAudioSpeaker::run_speaker_task() { dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; } else { // Should not happen for a READY channel; fall back to the requested size. - dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); + dma_buffer_bytes = this->output_stream_info_.frames_to_bytes( + dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_())); } // dma_buffer_bytes counts output-format bytes; convert with the output stream info. const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); @@ -128,7 +131,7 @@ void I2SAudioSpeaker::run_speaker_task() { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_input_bytes, - static_cast(bytes_per_frame)); + static_cast(source_alignment)); if (audio_source != nullptr) { // audio_source is nullptr if the ring buffer fails to allocate @@ -295,9 +298,20 @@ void I2SAudioSpeaker::run_speaker_task() { // Convert as many whole frames as fit in the remaining DMA space, bounded by what the source // currently exposes. Frame counts are shared between input and output; only the byte widths differ. - const uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); const uint32_t frames_room = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes - bytes_written_total); + uint32_t padding_frames = 0; + if (pair_aligned && frames_available == 0 && frames_room >= 2 && !audio_source->has_buffered_data()) { + // Silence padding follows, so write a held half pair now with a silent partner rather than strand it. + // Only the real frame is reported as played. + const size_t real_bytes = audio_source->expose_padded_partial(bytes_per_frame); + if (real_bytes > 0) { + this->apply_software_volume_(audio_source->mutable_data(), real_bytes); + frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + padding_frames = frames_available - this->current_stream_info_.bytes_to_frames(real_bytes); + } + } const uint32_t frames_to_write = std::min(frames_available, frames_room); if (frames_to_write == 0) { // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer @@ -326,13 +340,17 @@ void I2SAudioSpeaker::run_speaker_task() { ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, (unsigned) output_bytes); audio_source->consume(input_bytes); - real_frames_total += frames_to_write; + real_frames_total += frames_to_write - padding_frames; partial_write_failure = true; break; } audio_source->consume(input_bytes); bytes_written_total += output_bytes; - real_frames_total += frames_to_write; + real_frames_total += frames_to_write - padding_frames; + if (padding_frames > 0) { + // The source was already empty; skip waiting on another fill() before the silence padding + break; + } } if (real_frames_total > 0) { last_data_received_time = millis(); @@ -440,7 +458,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream this->current_stream_info_ = audio_stream_info; // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. - uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); + uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_()); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 4b52dcd52a9..01390dfdf70 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -24,6 +24,9 @@ class I2SAudioSpeaker final : public I2SAudioSpeakerBase { void run_speaker_task() override; esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + /// @brief Frame multiple for DMA buffers and writes; 2 keeps ESP32 mono sample pairs word-aligned. + uint32_t dma_frame_multiple_() const { return this->esp32_mono_swap_needed_() ? 2 : 1; } + I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD}; }; From 68179603656125b6f2d7eb66c5c0d9796e7fc184 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 15 Oct 2026 19:38:03 -0400 Subject: [PATCH 449/651] [audio] Deprecate AudioSourceTransferBuffer (#20801) --- esphome/components/audio/audio_decoder.cpp | 2 +- esphome/components/audio/audio_resampler.cpp | 5 +- .../audio/audio_transfer_buffer.cpp | 68 ++++++++++--------- .../components/audio/audio_transfer_buffer.h | 25 +++++-- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 2 +- .../speaker/i2s_audio_speaker_standard.cpp | 2 +- .../micro_wake_word/micro_wake_word.cpp | 2 +- .../mixer/speaker/mixer_speaker.cpp | 2 +- .../components/sound_level/sound_level.cpp | 3 +- .../voice_assistant/voice_assistant.cpp | 7 +- 10 files changed, 65 insertions(+), 53 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index 13dea3f9bc4..a069bbda427 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -188,7 +188,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { // Expose the next chunk of file data. Every decoder buffers internally and consumes only what it // processed, so the source does not need to accumulate or stitch chunks across fill() calls. - this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); + this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); const size_t available_before_decode = this->input_buffer_->available(); diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp index bef62ce190b..8f35bac9012 100644 --- a/esphome/components/audio/audio_resampler.cpp +++ b/esphome/components/audio/audio_resampler.cpp @@ -129,9 +129,8 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d delay(READ_WRITE_TIMEOUT_MS); } - // Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource - // (there is no intermediate transfer buffer to compact). - this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); + // Expose a chunk of the ring buffer's internal storage. + this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); if (this->audio_source_->available() == 0) { // No samples available to process diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index b129efd7f42..2082a270820 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -20,16 +20,6 @@ std::unique_ptr AudioSinkTransferBuffer::create(size_t return sink_buffer; } -std::unique_ptr AudioSourceTransferBuffer::create(size_t buffer_size) { - std::unique_ptr source_buffer = make_unique(); - - if (!source_buffer->allocate_buffer_(buffer_size)) { - return nullptr; - } - - return source_buffer; -} - size_t AudioTransferBuffer::free() const { if (this->buffer_size_ == 0) { return 0; @@ -132,27 +122,6 @@ void AudioTransferBuffer::deallocate_buffer_() { this->buffer_length_ = 0; } -size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_wait, bool pre_shift) { - if (pre_shift) { - // Shift data in buffer to start - if (this->buffer_length_ > 0) { - std::memmove(this->buffer_, this->data_start_, this->buffer_length_); - } - this->data_start_ = this->buffer_; - } - - size_t bytes_to_read = AudioTransferBuffer::free(); - size_t bytes_read = 0; - if (bytes_to_read > 0) { - if (this->ring_buffer_ != nullptr) { - bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); - } - - this->increase_buffer_length(bytes_read); - } - return bytes_read; -} - size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bool post_shift) { size_t bytes_written = 0; if (this->available()) { @@ -192,9 +161,44 @@ bool AudioSinkTransferBuffer::has_buffered_data() const { return (this->available() > 0); } +// AudioSourceTransferBuffer is deprecated. Remove before 2027.5.0. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +std::unique_ptr AudioSourceTransferBuffer::create(size_t buffer_size) { + std::unique_ptr source_buffer = make_unique(); + + if (!source_buffer->allocate_buffer_(buffer_size)) { + return nullptr; + } + + return source_buffer; +} + +size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_wait, bool pre_shift) { + if (pre_shift) { + // Shift data in buffer to start + if (this->buffer_length_ > 0) { + std::memmove(this->buffer_, this->data_start_, this->buffer_length_); + } + this->data_start_ = this->buffer_; + } + + size_t bytes_to_read = AudioTransferBuffer::free(); + size_t bytes_read = 0; + if (bytes_to_read > 0) { + if (this->ring_buffer_ != nullptr) { + bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); + } + + this->increase_buffer_length(bytes_read); + } + return bytes_read; +} + size_t AudioSourceTransferBuffer::free() const { return AudioTransferBuffer::free(); } bool AudioSourceTransferBuffer::has_buffered_data() const { return AudioTransferBuffer::has_buffered_data(); } +#pragma GCC diagnostic pop void ConstAudioSourceBuffer::set_data(const uint8_t *data, size_t length) { this->data_start_ = data; @@ -293,7 +297,7 @@ bool RingBufferAudioSource::has_buffered_data() const { return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0); } -size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) { +size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait) { if (this->current_available_ > 0) { // Caller has not finished consuming the current exposure return 0; diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index d28328138db..e7a103fdc8b 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" // for ESPDEPRECATED #ifdef USE_SPEAKER #include "esphome/components/speaker/speaker.h" @@ -152,13 +153,20 @@ class AudioReadableBuffer { /// @brief Refills the buffer from its source. No-op by default for read-only buffers. /// @param ticks_to_wait FreeRTOS ticks to block while waiting for data - /// @param pre_shift If true, shifts existing data to the start of the buffer before reading /// @return Number of bytes read - virtual size_t fill(TickType_t ticks_to_wait, bool pre_shift) { return 0; } - size_t fill(TickType_t ticks_to_wait) { return this->fill(ticks_to_wait, true); } + virtual size_t fill(TickType_t ticks_to_wait) { return 0; } + + // Remove before 2027.5.0 + ESPDEPRECATED("Use fill(ticks_to_wait) instead. Removed in 2027.5.0.", "2026.11.0") + virtual size_t fill(TickType_t ticks_to_wait, bool pre_shift) { return this->fill(ticks_to_wait); } }; -class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadableBuffer { +// Remove before 2027.5.0 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +class ESPDEPRECATED("Use RingBufferAudioSource instead. Removed in 2027.5.0.", "2026.11.0") AudioSourceTransferBuffer + : public AudioTransferBuffer, + public AudioReadableBuffer { /* * @brief A class that implements a transfer buffer for audio sources. * Supports reading audio data from a ring buffer into the transfer buffer for processing. @@ -189,10 +197,12 @@ class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadab size_t free() const override; void consume(size_t bytes) override { this->decrease_buffer_length(bytes); } bool has_buffered_data() const override; + size_t fill(TickType_t ticks_to_wait) override { return this->transfer_data_from_source(ticks_to_wait); } size_t fill(TickType_t ticks_to_wait, bool pre_shift) override { return this->transfer_data_from_source(ticks_to_wait, pre_shift); } }; +#pragma GCC diagnostic pop /// @brief A lightweight read-only audio buffer for const data sources (e.g., flash memory). /// Does not allocate memory or transfer data from external sources. @@ -246,9 +256,10 @@ class RingBufferAudioSource : public AudioReadableBuffer { size_t available() const override { return this->current_available_; } void consume(size_t bytes) override; bool has_buffered_data() const override; - /// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed - /// exposure stays in place and fill() returns 0 until it is fully consumed. - size_t fill(TickType_t ticks_to_wait, bool pre_shift) override; + /// An unconsumed exposure stays in place and fill() returns 0 until it is fully consumed. + size_t fill(TickType_t ticks_to_wait) override; + // Keeps the deprecated fill(ticks_to_wait, pre_shift) overload visible. Remove before 2027.5.0. + using AudioReadableBuffer::fill; /// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 5054478ceb2..3938d777717 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -334,7 +334,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { if (!this->pause_state_) { while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) { if (audio_source->available() == 0) { - size_t bytes_read = audio_source->fill(read_timeout_ticks, false); + size_t bytes_read = audio_source->fill(read_timeout_ticks); if (bytes_read == 0) { break; // No upstream data within the read budget; silence-pad the remainder. } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index d6c77d28b42..16abb2f22ef 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -289,7 +289,7 @@ void I2SAudioSpeaker::run_speaker_task() { if (!this->pause_state_) { while (bytes_written_total < dma_buffer_bytes) { - size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); + size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2); if (bytes_read > 0) { // Apply volume at the input bit depth, before any narrowing, so the full precision is scaled. uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6b33a3a1b56..872dd7bd79a 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -221,7 +221,7 @@ void MicroWakeWord::inference_task(void *params) { xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER); } - audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false); + audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS)); // The frontend buffers samples internally and only emits a feature once it has a full window, so we can // hand it whatever the source exposes. The frontend consumes at least one sample per call, so available() diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 23867aef914..f52199093f8 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -302,7 +302,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptrfill(ticks_to_wait, false); + size_t bytes_read = audio_source->fill(ticks_to_wait); uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index 1e51e45b7b3..a9bd0e257f5 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -81,8 +81,7 @@ void SoundLevelComponent::loop() { } // Expose a chunk of the ring buffer's internal storage - don't block to avoid slowing the main loop. - // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). - this->audio_source_->fill(0, false); + this->audio_source_->fill(0); if (this->audio_source_->available() == 0) { // No new audio available for processing diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 1d66ca5950f..a0fa321812c 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -224,11 +224,11 @@ void VoiceAssistant::stream_api_audio_() { while (true) { // fill() exposes a new chunk, or returns 0 if a previous chunk is still exposed; available() // reports the currently exposed bytes either way. - this->audio_source_->fill(0, false); + this->audio_source_->fill(0); size_t available = this->audio_source_->available(); size_t available2 = 0; if (this->audio_source2_ != nullptr) { - this->audio_source2_->fill(0, false); + this->audio_source2_->fill(0); available2 = this->audio_source2_->available(); } @@ -383,14 +383,13 @@ void VoiceAssistant::loop() { break; // State changed when udp server port received } case State::STREAMING_MICROPHONE: { - // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). if (this->audio_mode_ == AUDIO_MODE_API) { this->stream_api_audio_(); } else { // UDP (will eventually be deprecated) // Only the primary microphone channel is used while (true) { - this->audio_source_->fill(0, false); + this->audio_source_->fill(0); size_t available = this->audio_source_->available(); if (available == 0) { break; From 0399379c0dd22134c42ca549bf530e7f657dc2de Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:56:40 -0400 Subject: [PATCH 450/651] [lp5562] Pin the test fixture to the shared I2C bus (#20856) --- tests/components/lp5562/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/lp5562/common.yaml b/tests/components/lp5562/common.yaml index c1608f1a3cf..5bf3406938b 100644 --- a/tests/components/lp5562/common.yaml +++ b/tests/components/lp5562/common.yaml @@ -1,6 +1,7 @@ lp5562: - address: 0x30 id: lp5562_1 + i2c_id: i2c_bus output: - platform: lp5562 From 6dbd15b90375c709f86b4fba29483f5f216571c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 15:02:26 -1000 Subject: [PATCH 451/651] [pca9505] Validate interrupt_pin with the shared expander validator (#20853) --- esphome/components/pca9505/__init__.py | 4 ++-- tests/component_tests/gpio_expander/test_init.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index 7b0d5ac1732..d50e8205a17 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -32,7 +32,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA9505Component), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py index 806b1775d29..34e77d84a2f 100644 --- a/tests/component_tests/gpio_expander/test_init.py +++ b/tests/component_tests/gpio_expander/test_init.py @@ -49,6 +49,7 @@ def test_allow_other_uses_rejected(stage_esp32: None) -> None: "pi4ioe5v6408", "mcp23016", "mcp23017", + "pca9505", ], ) def test_component_schemas_route_through_validator( From 6555da29063a38a46734bb1a29b355d6daca4915 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 19:57:11 -1000 Subject: [PATCH 452/651] [display] Fall back per pixel when a bulk blit does not match the panel (#20652) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- esphome/components/display/display.h | 4 + .../components/ili9xxx/ili9xxx_display.cpp | 30 +++++- esphome/components/mipi_dsi/display.py | 1 + esphome/components/mipi_dsi/mipi_dsi.cpp | 92 +++++++++++++------ esphome/components/mipi_dsi/mipi_dsi.h | 34 +++++++ esphome/components/mipi_rgb/display.py | 1 + esphome/components/mipi_rgb/mipi_rgb.cpp | 85 ++++++++++++----- esphome/components/mipi_rgb/mipi_rgb.h | 32 +++++++ esphome/components/mipi_spi/mipi_spi.h | 66 ++++++++++++- esphome/components/qspi_dbi/qspi_dbi.cpp | 37 +++++++- esphome/components/qspi_dbi/qspi_dbi.h | 3 + .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 13 ++- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 2 + esphome/components/st7701s/st7701s.cpp | 13 ++- esphome/components/st7701s/st7701s.h | 2 + 15 files changed, 346 insertions(+), 69 deletions(-) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index f17b7d86f71..b43a3221518 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -364,6 +364,10 @@ class Display : public PollingComponent { * \param x_pad How many pixels are in each line after the end of the pixels to be copied. * * The length of each source buffer line (stride) will be x_offset + w + x_pad. + * + * An override that copies the block as is must send any other format, byte order, a software + * rotation or an active clipping rectangle through this base version, which draws per pixel. + * The block must lie on the screen. */ virtual void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad); diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index 0ed18c45da0..870d4e7098d 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -4,6 +4,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#include + namespace esphome::ili9xxx { static const uint16_t SPI_SETUP_US = 100; // estimated fixed overhead in microseconds for an SPI write @@ -313,7 +316,8 @@ void ILI9XXXDisplay::display_() { this->y_high_ = 0; } -// note that this bypasses the buffer and writes directly to the display. +// Outside update() this bypasses the buffer and writes directly to the display. Inside it the block goes into the +// buffer, or the flush at the end of update() would paint the old buffer contents over it. void ILI9XXXDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { @@ -322,14 +326,34 @@ void ILI9XXXDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, cons // if color mapping or software rotation is required, hand this off to the parent implementation. This will // do color conversion pixel-by-pixel into the buffer and draw it later. If this is happening the user has not // configured the renderer well. - if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || !big_endian) { + if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || !big_endian || + order != display::COLOR_ORDER_RGB || this->is_clipping() || + (this->prossing_update_ && this->buffer_color_mode_ != BITS_16)) { display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; } - this->set_addr_window_(x_start, y_start, x_start + w - 1, y_start + h - 1); // x_ and y_offset are offsets into the source buffer, unrelated to our own offsets into the display. auto stride = x_offset + w + x_pad; + if (this->prossing_update_) { + if (!this->check_buffer_()) + return; + // The buffer holds big endian RGB565, the same layout as the block + const int first_col = std::max(0, -x_start), end_col = std::min(w, this->get_width_internal() - x_start); + const int first_row = std::max(0, -y_start), end_row = std::min(h, this->get_height_internal() - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + ((y_start + y) * this->width_ + x_start + first_col) * 2, + ptr + ((y_offset + y) * stride + x_offset + first_col) * 2, (end_col - first_col) * 2); + } + this->x_low_ = std::min(this->x_low_, x_start + first_col); + this->y_low_ = std::min(this->y_low_, y_start + first_row); + this->x_high_ = std::max(this->x_high_, x_start + end_col - 1); + this->y_high_ = std::max(this->y_high_, y_start + end_row - 1); + return; + } + this->set_addr_window_(x_start, y_start, x_start + w - 1, y_start + h - 1); if (!this->is_18bitdisplay_) { if (x_offset == 0 && x_pad == 0 && y_offset == 0) { // we could deal here with a non-zero y_offset, but if x_offset is zero, y_offset probably will be so don't bother diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 6bf94bf364b..97af7a33243 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -198,6 +198,7 @@ async def to_code(config: ConfigType) -> None: model.get_dimensions(config) ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) + cg.add(var.set_big_endian(config[CONF_BYTE_ORDER] == BYTE_ORDER_BIG)) sequence = model.get_sequence(config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 64166012c20..31bf69b178c 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -3,6 +3,9 @@ #include "mipi_dsi.h" #include "esphome/core/helpers.h" +#include +#include + namespace esphome::mipi_dsi { // Maximum bytes to log for init commands (truncated if larger) @@ -180,6 +183,7 @@ void MipiDsi::update() { if (this->auto_clear_enabled_) { this->clear(); } + this->updating_ = true; if (this->show_test_card_) { this->test_card(); } else if (this->page_ != nullptr) { @@ -189,7 +193,13 @@ void MipiDsi::update() { } else { this->stop_poller(); } - if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) + this->updating_ = false; + this->flush_dirty_(); +} + +void MipiDsi::flush_dirty_() { + // Inside update() the one flush at its end sends everything + if (this->updating_ || this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) return; ESP_LOGV(TAG, "x_low %d, y_low %d, x_high %d, y_high %d", this->x_low_, this->y_low_, this->x_high_, this->y_high_); int w = this->x_high_ - this->x_low_ + 1; @@ -207,11 +217,34 @@ void MipiDsi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0) return; - // if color mapping is required, pass the buck. - // note that endianness is not considered here - it is assumed to match! - if (bitness != this->color_depth_) { + if (bitness != this->color_depth_ || order != display::COLOR_ORDER_RGB || + (bitness == display::COLOR_BITNESS_565 && big_endian != this->big_endian_) || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + this->mark_dirty_(x_start, y_start, w, h); + this->flush_dirty_(); + return; + } + if (this->buffer_ != nullptr) { + // With a buffer the block goes through it, or a flush of the old contents would paint over it + const int first_col = std::max(0, -x_start), end_col = std::min(w, static_cast(this->width_) - x_start); + const int first_row = std::max(0, -y_start), end_row = std::min(h, static_cast(this->height_) - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + const size_t bytes_per_pixel = 3 - this->color_depth_; + const size_t stride = x_offset + w + x_pad; + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + ((y_start + y) * this->width_ + x_start + first_col) * bytes_per_pixel, + ptr + ((y_offset + y) * stride + x_offset + first_col) * bytes_per_pixel, + (end_col - first_col) * bytes_per_pixel); + } + if (this->updating_) { + this->mark_dirty_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row); + } else { + this->write_to_display_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row, ptr, + x_offset + first_col, y_offset + first_row, x_pad + w - end_col); + } return; } this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); @@ -259,26 +292,37 @@ bool MipiDsi::check_buffer_() { return true; } +// Widen the dirty box to the drawn rectangle, even where the buffer already held the same values. +void MipiDsi::mark_dirty_(int x_start, int y_start, int w, int h) { + int x1 = x_start, y1 = y_start; + int x2 = x_start + w - 1, y2 = y_start + h - 1; + this->rotate_(x1, y1); + this->rotate_(x2, y2); + const int right = static_cast(this->width_) - 1; + const int bottom = static_cast(this->height_) - 1; + const int left = std::clamp(std::min(x1, x2), 0, right); + const int top = std::clamp(std::min(y1, y2), 0, bottom); + x2 = std::clamp(std::max(x1, x2), 0, right); + y2 = std::clamp(std::max(y1, y2), 0, bottom); + // An empty box is low above high, so it is replaced rather than widened + if (this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) { + this->x_low_ = left; + this->x_high_ = x2; + this->y_low_ = top; + this->y_high_ = y2; + return; + } + this->x_low_ = std::min(this->x_low_, left); + this->x_high_ = std::max(this->x_high_, x2); + this->y_low_ = std::min(this->y_low_, top); + this->y_high_ = std::max(this->y_high_, y2); +} + void MipiDsi::draw_pixel_at(int x, int y, Color color) { if (this->is_point_clipped(x, y)) return; - switch (this->rotation_) { - case display::DISPLAY_ROTATION_0_DEGREES: - break; - case display::DISPLAY_ROTATION_90_DEGREES: - std::swap(x, y); - x = this->width_ - x - 1; - break; - case display::DISPLAY_ROTATION_180_DEGREES: - x = this->width_ - x - 1; - y = this->height_ - y - 1; - break; - case display::DISPLAY_ROTATION_270_DEGREES: - std::swap(x, y); - y = this->height_ - y - 1; - break; - } + this->rotate_(x, y); if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) { return; } @@ -288,9 +332,7 @@ void MipiDsi::draw_pixel_at(int x, int y, Color color) { switch (this->color_depth_) { case display::COLOR_BITNESS_565: { auto *ptr_16 = reinterpret_cast(this->buffer_); - uint8_t hi_byte = static_cast(color.r & 0xF8) | (color.g >> 5); - uint8_t lo_byte = static_cast((color.g & 0x1C) << 3) | (color.b >> 3); - uint16_t new_color = lo_byte | (hi_byte << 8); // little endian + const uint16_t new_color = this->to_panel_565_(color); if (ptr_16[pos] == new_color) return; ptr_16[pos] = new_color; @@ -328,9 +370,7 @@ void MipiDsi::fill(Color color) { switch (this->color_depth_) { case display::COLOR_BITNESS_565: { auto *ptr_16 = reinterpret_cast(this->buffer_); - uint8_t hi_byte = static_cast(color.r & 0xF8) | (color.g >> 5); - uint8_t lo_byte = static_cast((color.g & 0x1C) << 3) | (color.b >> 3); - uint16_t new_color = lo_byte | (hi_byte << 8); // little endian + const uint16_t new_color = this->to_panel_565_(color); std::fill_n(ptr_16, this->width_ * this->height_, new_color); break; } diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index ba3f29ca05a..dd1b0dc5f90 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -6,16 +6,20 @@ // only applicable on ESP32-P4 #ifdef USE_ESP32_VARIANT_ESP32P4 #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/gpio.h" #include "esphome/components/display/display.h" +#include "esphome/components/display/display_color_utils.h" #include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_io.h" #include "esp_lcd_mipi_dsi.h" +#include + namespace esphome::mipi_dsi { ESPHOME_LOG_TAG(TAG, "display.mipi_dsi"); @@ -39,6 +43,7 @@ class MipiDsi final : public display::Display { public: MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} + void set_big_endian(bool big_endian) { this->big_endian_ = big_endian; } void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } @@ -81,6 +86,33 @@ class MipiDsi final : public display::Display { void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); bool check_buffer_(); + /// Send the region touched since the last flush to the panel. + void flush_dirty_(); + // Maps drawn coordinates to the buffer the way draw_pixel_at() stores them + void rotate_(int &x, int &y) const { + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = this->width_ - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = this->width_ - x - 1; + y = this->height_ - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = this->height_ - y - 1; + break; + } + } + void mark_dirty_(int x_start, int y_start, int w, int h); + /// Pack a colour as the 565 value the panel's byte order expects in memory. + uint16_t to_panel_565_(Color color) const { + const uint16_t rgb565 = display::ColorUtil::color_to_565(color); + return this->big_endian_ ? convert_big_endian(rgb565) : rgb565; + } GPIOPin *reset_pin_{nullptr}; std::vector enable_pins_{}; size_t width_{}; @@ -101,6 +133,8 @@ class MipiDsi final : public display::Display { bool invert_colors_{}; display::ColorBitness color_depth_; + bool big_endian_{false}; + bool updating_{false}; uint8_t pixel_mode_{}; esp_lcd_panel_handle_t handle_{}; diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 71234c2f25b..6e508dd7c44 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -290,6 +290,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_init_sequence(table, len(sequence))) cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) + cg.add(var.set_big_endian(config[CONF_BYTE_ORDER] == BYTE_ORDER_BIG)) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) cg.add(var.set_hsync_pulse_width(config[CONF_HSYNC_PULSE_WIDTH])) cg.add(var.set_hsync_back_porch(config[CONF_HSYNC_BACK_PORCH])) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 4928b6f6672..2bc300e5b82 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -4,6 +4,9 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" + +#include +#include #include #include #include @@ -185,6 +188,7 @@ void MipiRgb::update() { if (this->auto_clear_enabled_) { this->clear(); } + this->updating_ = true; if (this->show_test_card_) { this->test_card(); } else if (this->page_ != nullptr) { @@ -194,7 +198,13 @@ void MipiRgb::update() { } else { this->stop_poller(); } - if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) + this->updating_ = false; + this->flush_dirty_(); +} + +void MipiRgb::flush_dirty_() { + // Inside update() the one flush at its end sends everything + if (this->updating_ || this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) return; ESP_LOGV(TAG, "x_low %d, y_low %d, x_high %d, y_high %d", this->x_low_, this->y_low_, this->x_high_, this->y_high_); int w = this->x_high_ - this->x_low_ + 1; @@ -212,12 +222,28 @@ void MipiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0 || this->is_failed()) return; - // if color mapping is required, pass the buck. - // note that endianness is not considered here - it is assumed to match! - if (bitness != display::COLOR_BITNESS_565) { + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || big_endian != this->big_endian_ || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); - this->write_to_display_(x_start, y_start, w, h, reinterpret_cast(this->buffer_), x_start, y_start, - this->width_ - w - x_start); + this->mark_dirty_(x_start, y_start, w, h); + this->flush_dirty_(); + } else if (this->buffer_ != nullptr) { + // With a buffer the block goes through it, or a flush of the old contents would paint over it + const int first_col = std::max(0, -x_start), end_col = std::min(w, static_cast(this->width_) - x_start); + const int first_row = std::max(0, -y_start), end_row = std::min(h, static_cast(this->height_) - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + const size_t stride = x_offset + w + x_pad; + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + (y_start + y) * this->width_ + x_start + first_col, + ptr + ((y_offset + y) * stride + x_offset + first_col) * 2, (end_col - first_col) * 2); + } + if (this->updating_) { + this->mark_dirty_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row); + } else { + this->write_to_display_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row, ptr, + x_offset + first_col, y_offset + first_row, x_pad + w - end_col); + } } else { this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } @@ -260,33 +286,44 @@ bool MipiRgb::check_buffer_() { return true; } +// Widen the dirty box to the drawn rectangle, even where the buffer already held the same values. +void MipiRgb::mark_dirty_(int x_start, int y_start, int w, int h) { + int x1 = x_start, y1 = y_start; + int x2 = x_start + w - 1, y2 = y_start + h - 1; + this->rotate_(x1, y1); + this->rotate_(x2, y2); + const int right = static_cast(this->width_) - 1; + const int bottom = static_cast(this->height_) - 1; + const int left = std::clamp(std::min(x1, x2), 0, right); + const int top = std::clamp(std::min(y1, y2), 0, bottom); + x2 = std::clamp(std::max(x1, x2), 0, right); + y2 = std::clamp(std::max(y1, y2), 0, bottom); + // An empty box is low above high, so it is replaced rather than widened + if (this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) { + this->x_low_ = left; + this->x_high_ = x2; + this->y_low_ = top; + this->y_high_ = y2; + return; + } + this->x_low_ = std::min(this->x_low_, left); + this->x_high_ = std::max(this->x_high_, x2); + this->y_low_ = std::min(this->y_low_, top); + this->y_high_ = std::max(this->y_high_, y2); +} + void MipiRgb::draw_pixel_at(int x, int y, Color color) { if (this->is_point_clipped(x, y) || this->is_failed()) return; - switch (this->rotation_) { - case display::DISPLAY_ROTATION_0_DEGREES: - break; - case display::DISPLAY_ROTATION_90_DEGREES: - std::swap(x, y); - x = this->width_ - x - 1; - break; - case display::DISPLAY_ROTATION_180_DEGREES: - x = this->width_ - x - 1; - y = this->height_ - y - 1; - break; - case display::DISPLAY_ROTATION_270_DEGREES: - std::swap(x, y); - y = this->height_ - y - 1; - break; - } + this->rotate_(x, y); if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) { return; } if (!this->check_buffer_()) return; size_t pos = (y * this->width_) + x; - uint16_t new_color = convert_big_endian(display::ColorUtil::color_to_565(color)); + uint16_t new_color = this->to_panel_565_(color); if (this->buffer_[pos] == new_color) return; this->buffer_[pos] = new_color; @@ -311,7 +348,7 @@ void MipiRgb::fill(Color color) { } auto *ptr_16 = reinterpret_cast(this->buffer_); - uint16_t new_color = convert_big_endian(display::ColorUtil::color_to_565(color)); + uint16_t new_color = this->to_panel_565_(color); std::fill_n(ptr_16, this->width_ * this->height_, new_color); this->x_low_ = 0; this->y_low_ = 0; diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 01b1ebee91f..393e052b26b 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -4,7 +4,9 @@ #include "esphome/core/gpio.h" #include "esphome/core/log.h" #include "esphome/components/display/display.h" +#include "esphome/components/display/display_color_utils.h" #include +#include #ifdef USE_SPI #include "esphome/components/spi/spi.h" #endif @@ -39,6 +41,7 @@ class MipiRgb : public display::Display { display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } + void set_big_endian(bool big_endian) { this->big_endian_ = big_endian; } void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } void add_data_pin(InternalGPIOPin *data_pin, size_t index) { this->data_pins_[index] = data_pin; }; @@ -71,6 +74,33 @@ class MipiRgb : public display::Display { void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); bool check_buffer_(); + /// Pack a colour as the 565 value the panel's byte order expects in memory. + uint16_t to_panel_565_(Color color) const { + const uint16_t rgb565 = display::ColorUtil::color_to_565(color); + return this->big_endian_ ? convert_big_endian(rgb565) : rgb565; + } + /// Send the region touched since the last flush to the panel. + void flush_dirty_(); + // Maps drawn coordinates to the buffer the way draw_pixel_at() stores them + void rotate_(int &x, int &y) const { + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = this->width_ - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = this->width_ - x - 1; + y = this->height_ - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = this->height_ - y - 1; + break; + } + } + void mark_dirty_(int x_start, int y_start, int w, int h); void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void setup_enables_(); void common_setup_(); @@ -91,6 +121,8 @@ class MipiRgb : public display::Display { const char *model_{"Unknown"}; bool invert_colors_{}; display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; + bool big_endian_{true}; + bool updating_{false}; size_t width_; size_t height_; uint16_t *buffer_{nullptr}; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index f55ae723780..cbf025e8c31 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include "esphome/components/spi/spi.h" @@ -205,9 +207,8 @@ class MipiSpi : public display::Display, return; if (w <= 0 || h <= 0) return; - if (get_pixel_mode(bitness) != BUFFERPIXEL || big_endian != IS_BIG_ENDIAN) { - // note that the usual logging macros are banned in header files, so use their replacement - esph_log_e(TAG, "Unsupported color depth or bit order"); + if (!this->is_native_block_(order, bitness, big_endian)) { + this->draw_pixels_slow(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; } this->write_to_display_(x_start, y_start, w, h, reinterpret_cast(ptr), x_offset, y_offset, @@ -419,6 +420,18 @@ class MipiSpi : public display::Display, } } + // True when the block is already in the buffer pixel format and can be copied without per pixel work + bool is_native_block_(display::ColorOrder order, display::ColorBitness bitness, bool big_endian) const { + return get_pixel_mode(bitness) == BUFFERPIXEL && big_endian == IS_BIG_ENDIAN && order == display::COLOR_ORDER_RGB && + (HAS_HARDWARE_ROTATION || this->rotation_ == display::DISPLAY_ROTATION_0_DEGREES) && !this->is_clipping(); + } + + // Without a buffer there is nothing to convert a foreign block into, so it is dropped + virtual void draw_pixels_slow(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + esph_log_e(TAG, "Unsupported color depth, order, bit order or rotation"); + } + /** * Writes a buffer to the display. * @@ -570,6 +583,7 @@ class MipiSpiBuffer if (this->auto_clear_enabled_) { this->clear(); } + this->updating_ = true; if (this->show_test_card_) { this->test_card(); } else if (this->page_ != nullptr) { @@ -577,6 +591,7 @@ class MipiSpiBuffer } else if (this->writer_.has_value()) { (*this->writer_)(*this); } + this->updating_ = false; #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE esph_log_v(TAG, "Drawing from line %d took %dms", this->start_line_, millis() - lap); lap = millis(); @@ -610,6 +625,42 @@ class MipiSpiBuffer #endif } + // While update() runs the writer, a block goes into the buffer slice, or the slice flush would paint over it. + // Outside update() (e.g. LVGL) it is written straight to the display. + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (!this->updating_) { + MipiSpiBuffer::MipiSpi::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, + y_offset, x_pad); + return; + } + if (this->is_failed() || w <= 0 || h <= 0) + return; + if (!this->is_native_block_(order, bitness, big_endian)) { + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; + } + // Only the rows inside the current slice are kept + const int first_col = std::max(0, -x_start); + const int end_col = std::min(w, this->get_width_internal() - x_start); + const int first_row = std::max(0, this->start_line_ - y_start); + const int end_row = std::min(h, this->end_line_ - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + const size_t stride = x_offset + w + x_pad; + const size_t buffer_stride = round_buffer(this->get_width_internal()); + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + (y_start + y - this->start_line_) * buffer_stride + x_start + first_col, + ptr + ((y_offset + y) * stride + x_offset + first_col) * sizeof(BUFFERTYPE), + (end_col - first_col) * sizeof(BUFFERTYPE)); + } + this->x_low_ = std::min(this->x_low_, x_start + first_col); + this->y_low_ = std::min(this->y_low_, y_start + first_row); + this->x_high_ = std::max(this->x_high_, x_start + end_col - 1); + this->y_high_ = std::max(this->y_high_, y_start + end_row - 1); + } + // With hardware rotation the buffer is written in drawn coordinates. bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); } @@ -665,8 +716,12 @@ class MipiSpiBuffer } protected: - // Rotate the coordinates to match the display orientation. - + void draw_pixels_slow(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) override { + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + } // Convert a color to the buffer pixel format. static BUFFERTYPE convert_color(const Color &color) { if constexpr (BUFFERPIXEL == PIXEL_MODE_8) { @@ -688,6 +743,7 @@ class MipiSpiBuffer uint16_t y_high_{0}; uint16_t start_line_{0}; uint16_t end_line_{1}; + bool updating_{false}; }; } // namespace esphome::mipi_spi diff --git a/esphome/components/qspi_dbi/qspi_dbi.cpp b/esphome/components/qspi_dbi/qspi_dbi.cpp index 5b4974b6317..ce0df1beb50 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.cpp +++ b/esphome/components/qspi_dbi/qspi_dbi.cpp @@ -3,6 +3,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#include + namespace esphome::qspi_dbi { // Maximum bytes to log in verbose hex output @@ -32,7 +35,9 @@ void QspiDbi::update() { if (!this->setup_complete_) { return; } + this->updating_ = true; this->do_update_(); + this->updating_ = false; if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) return; // Some chips require that the drawing window be aligned on certain boundaries @@ -139,9 +144,14 @@ void QspiDbi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 if (w <= 0 || h <= 0) return; if (bitness != display::COLOR_BITNESS_565 || order != this->color_mode_ || - big_endian != (this->bit_order_ == spi::BIT_ORDER_MSB_FIRST)) { + big_endian != (this->bit_order_ == spi::BIT_ORDER_MSB_FIRST) || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; + } else if (this->updating_) { + // Inside update() the block goes into the buffer, or the flush at its end would paint over it + this->copy_to_buffer_(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; } else if (this->draw_from_origin_) { auto stride = x_offset + w + x_pad; for (int y = 0; y != h; y++) { @@ -159,6 +169,31 @@ void QspiDbi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } +void QspiDbi::copy_to_buffer_(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // The buffer holds big endian RGB565 + if (!big_endian || order != display::COLOR_ORDER_RGB) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + const int first_col = std::max(0, -x_start), end_col = std::min(w, static_cast(this->width_) - x_start); + const int first_row = std::max(0, -y_start), end_row = std::min(h, static_cast(this->height_) - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + this->check_buffer_(); + if (this->buffer_ == nullptr) + return; + const size_t stride = x_offset + w + x_pad; + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + ((y_start + y) * this->width_ + x_start + first_col) * 2, + ptr + ((y_offset + y) * stride + x_offset + first_col) * 2, (end_col - first_col) * 2); + } + this->x_low_ = std::min(this->x_low_, x_start + first_col); + this->y_low_ = std::min(this->y_low_, y_start + first_row); + this->x_high_ = std::max(this->x_high_, x_start + end_col - 1); + this->y_high_ = std::max(this->y_high_, y_start + end_row - 1); +} + void QspiDbi::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad) { this->set_addr_window_(x_start, y_start, x_start + w - 1, y_start + h - 1); diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index eca062f4711..1c2565625b2 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -116,6 +116,8 @@ class QspiDbi final : public display::DisplayBuffer, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); + void copy_to_buffer_(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad); /** * the RM67162 in quad SPI mode seems to work like this (not in the datasheet, this is deduced from the * sample code.) @@ -160,6 +162,7 @@ class QspiDbi final : public display::DisplayBuffer, bool mirror_x_{}; bool mirror_y_{}; bool draw_from_origin_{false}; + bool updating_{false}; unsigned draw_rounding_{2}; uint8_t brightness_{0xD0}; const char *model_{"Unknown"}; diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index f2f25741f33..9f92a850f55 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -51,13 +51,17 @@ void RpiDpiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uin display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0) return; - // if color mapping is required, pass the buck. - // note that endianness is not considered here - it is assumed to match! - if (bitness != display::COLOR_BITNESS_565) { + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || !big_endian || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; } + this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); +} + +void RpiDpiRgb::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, + int y_offset, int x_pad) { x_start += this->offset_x_; y_start += this->offset_y_; esp_err_t err = ESP_OK; @@ -122,8 +126,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { } auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color)); - this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, - 0, 0, 0); + this->write_to_display_(x, y, 1, 1, (const uint8_t *) &pixel, 0, 0, 0); this->feed_wdt_per_pixel_(); } diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index ad2f6e80c26..5f1f3be0191 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -62,6 +62,8 @@ class RpiDpiRgb final : public display::Display { void dump_config() override; protected: + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad); int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } void reset_display_() const; diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 47b200c2de6..756473dae71 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -54,13 +54,17 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0) return; - // if color mapping is required, pass the buck. - // note that endianness is not considered here - it is assumed to match! - if (bitness != display::COLOR_BITNESS_565) { + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || !big_endian || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; } + this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); +} + +void ST7701S::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad) { x_start += this->offset_x_; y_start += this->offset_y_; esp_err_t err; @@ -105,8 +109,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) { } auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color)); - this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, - 0, 0, 0); + this->write_to_display_(x, y, 1, 1, (const uint8_t *) &pixel, 0, 0, 0); this->feed_wdt_per_pixel_(); } diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index fe29d794141..e34b980e73b 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -78,6 +78,8 @@ class ST7701S final : public display::Display, // this will be horribly slow. protected: + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad); void write_command_(uint8_t value); void write_data_(uint8_t value); void write_sequence_(uint8_t cmd, size_t len, const uint8_t *bytes); From 51b1fdcc8b509db28e9e59b1c8a8a4121c26a23a Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Fri, 16 Oct 2026 09:35:46 +0200 Subject: [PATCH 453/651] [i2s_audio] Add full duplex support (#19959) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/i2s_audio/__init__.py | 88 ++++++++++- esphome/components/i2s_audio/i2s_audio.cpp | 94 ++++++++++++ esphome/components/i2s_audio/i2s_audio.h | 64 +++++++- .../microphone/i2s_audio_microphone.cpp | 84 +++++++---- .../microphone/i2s_audio_microphone.h | 9 +- .../components/i2s_audio/speaker/__init__.py | 4 +- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 10 +- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 76 +++++++++- .../i2s_audio/speaker/i2s_audio_speaker.h | 19 ++- .../speaker/i2s_audio_speaker_standard.cpp | 80 ++++++---- .../speaker/i2s_audio_speaker_standard.h | 18 ++- esphome/core/defines.h | 1 + .../test_full_duplex_multiple_speakers.yaml | 42 ++++++ .../i2s_audio/test_i2s_audio.py | 141 ++++++++++++++++++ .../i2s_audio/common-full_duplex.yaml | 26 ++++ .../i2s_audio/test-full_duplex.esp32-idf.yaml | 9 ++ .../test-full_duplex.esp32-s3-idf.yaml | 9 ++ 17 files changed, 695 insertions(+), 79 deletions(-) create mode 100644 esphome/components/i2s_audio/i2s_audio.cpp create mode 100644 tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml create mode 100644 tests/component_tests/i2s_audio/test_i2s_audio.py create mode 100644 tests/components/i2s_audio/common-full_duplex.yaml create mode 100644 tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml create mode 100644 tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 6e4e35590c1..3d5ec97a633 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -19,8 +19,16 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S3, ) from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_CHANNEL, + CONF_ID, + CONF_PLATFORM, + CONF_SAMPLE_RATE, + CONF_SPEAKER, +) from esphome.core import CORE from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv @@ -42,6 +50,9 @@ CONF_I2S_BCLK_PIN = "i2s_bclk_pin" CONF_I2S_LRCLK_PIN = "i2s_lrclk_pin" CONF_I2S_AUDIO_ID = "i2s_audio_id" +CONF_FULL_DUPLEX = "full_duplex" +CONF_I2S_COMM_FMT = "i2s_comm_fmt" +CONF_SPDIF_MODE = "spdif_mode" CONF_I2S_MODE = "i2s_mode" CONF_PRIMARY = "primary" @@ -185,7 +196,8 @@ def i2s_audio_component_schema( async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None: - await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) + parent = await cg.get_variable(config[CONF_I2S_AUDIO_ID]) + await cg.register_parented(var, parent) cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) slot_mode = config[CONF_CHANNEL] if slot_mode != CONF_STEREO: @@ -200,6 +212,12 @@ async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None cg.add(var.set_use_apll(config[CONF_USE_APLL])) cg.add(var.set_mclk_multiple(I2S_MCLK_MULTIPLE[config[CONF_MCLK_MULTIPLE]])) + if str(config[CONF_I2S_AUDIO_ID]) in _get_data().full_duplex_buses: + if config[CONF_ID].type.inherits_from(I2SAudioIn): + cg.add(parent.set_audio_in(var)) + else: + cg.add(parent.set_audio_out(var)) + CONFIG_SCHEMA = cv.Schema( { @@ -207,6 +225,7 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_I2S_BCLK_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_I2S_MCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_FULL_DUPLEX, default=False): cv.boolean, }, ) @@ -216,6 +235,7 @@ class I2SAudioData: """I2S audio component state stored in CORE.data.""" port_map: dict[str, int] = field(default_factory=dict) + full_duplex_buses: set[str] = field(default_factory=set) def _get_data() -> I2SAudioData: @@ -262,8 +282,56 @@ def _assign_ports() -> None: next_port += 1 +def _bus_devices(full_config: ConfigType, domain: str, bus_id: str) -> list[ConfigType]: + return [ + device + for device in full_config.get(domain, []) + if device.get(CONF_PLATFORM) == DOMAIN + and str(device[CONF_I2S_AUDIO_ID]) == bus_id + ] + + +def _validate_full_duplex(full_config: ConfigType, bus_id: str) -> None: + """Check that a full duplex bus has one microphone and speakers that can share its clocks and TX channel.""" + microphones = _bus_devices(full_config, MICROPHONE_DOMAIN, bus_id) + speakers = _bus_devices(full_config, CONF_SPEAKER, bus_id) + if len(microphones) != 1 or not speakers: + raise cv.Invalid( + f"'{CONF_FULL_DUPLEX}' requires exactly one i2s_audio microphone and at least one " + f"i2s_audio speaker on bus '{bus_id}'" + ) + microphone = microphones[0] + if microphone.get(CONF_PDM): + raise cv.Invalid(f"A PDM microphone cannot use a '{CONF_FULL_DUPLEX}' bus") + for speaker in speakers: + if speaker.get(CONF_SPDIF_MODE): + raise cv.Invalid(f"An SPDIF speaker cannot use a '{CONF_FULL_DUPLEX}' bus") + # Both directions run from the same bit and word clocks + for key in ( + CONF_SAMPLE_RATE, + CONF_BITS_PER_SAMPLE, + CONF_I2S_MODE, + CONF_USE_APLL, + CONF_MCLK_MULTIPLE, + ): + if microphone[key] != speaker[key]: + raise cv.Invalid( + f"The microphone and speaker '{speaker[CONF_ID]}' on '{CONF_FULL_DUPLEX}' bus " + f"'{bus_id}' must use the same '{key}'" + ) + # The TX channel is set up once from one speaker's configuration, and the speakers take turns using it + first = speakers[0] + for speaker in speakers[1:]: + for key in (CONF_I2S_DOUT_PIN, CONF_CHANNEL, CONF_I2S_COMM_FMT): + if speaker.get(key) != first.get(key): + raise cv.Invalid( + f"The speakers on '{CONF_FULL_DUPLEX}' bus '{bus_id}' must use the same '{key}'" + ) + + def _final_validate(_: ConfigType) -> None: - i2s_audio_configs = fv.full_config.get()[DOMAIN] + full_config = fv.full_config.get() + i2s_audio_configs = full_config[DOMAIN] variant = get_esp32_variant() if variant not in I2S_PORTS: raise cv.Invalid(f"Unsupported variant {variant}") @@ -273,6 +341,13 @@ def _final_validate(_: ConfigType) -> None: ) _assign_ports() + data = _get_data() + for config in i2s_audio_configs: + if config[CONF_FULL_DUPLEX]: + bus_id = str(config[CONF_ID]) + _validate_full_duplex(full_config, bus_id) + data.full_duplex_buses.add(bus_id) + FINAL_VALIDATE_SCHEMA = _final_validate @@ -299,3 +374,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_bclk_pin(config[CONF_I2S_BCLK_PIN])) if CONF_I2S_MCLK_PIN in config: cg.add(var.set_mclk_pin(config[CONF_I2S_MCLK_PIN])) + if config[CONF_FULL_DUPLEX]: + cg.add_define("USE_I2S_AUDIO_FULL_DUPLEX") + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"i2s_audio.cpp": "USE_I2S_AUDIO_FULL_DUPLEX"} +) diff --git a/esphome/components/i2s_audio/i2s_audio.cpp b/esphome/components/i2s_audio/i2s_audio.cpp new file mode 100644 index 00000000000..d1ee8d0c73b --- /dev/null +++ b/esphome/components/i2s_audio/i2s_audio.cpp @@ -0,0 +1,94 @@ +#include "i2s_audio.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_FULL_DUPLEX) + +#include "esphome/core/log.h" + +namespace esphome::i2s_audio { + +ESPHOME_LOG_TAG(TAG, "i2s_audio"); + +void I2SAudioComponent::setup() { + if (!this->is_full_duplex()) + return; + + // ESP-IDF only shares the bit and word clocks between channels allocated in the same call, and deleting either + // channel ends the pairing. Allocate both once here and keep them for the lifetime of the device. + i2s_chan_config_t chan_cfg{}; + i2s_std_config_t tx_cfg{}; + i2s_std_config_t rx_cfg{}; + this->audio_out_->build_full_duplex_config(chan_cfg, tx_cfg); + this->audio_in_->build_full_duplex_config(rx_cfg); + + esp_err_t err = i2s_new_channel(&chan_cfg, &this->tx_handle_, &this->rx_handle_); + if (err == ESP_OK) { + // The first initialized channel drives the shared clocks. Use RX so the microphone runs on its own. + err = i2s_channel_init_std_mode(this->rx_handle_, &rx_cfg); + } + if (err == ESP_OK) { + err = i2s_channel_init_std_mode(this->tx_handle_, &tx_cfg); + } + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex channel setup failed: %s", esp_err_to_name(err)); + if (this->tx_handle_ != nullptr) { + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + } + if (this->rx_handle_ != nullptr) { + i2s_del_channel(this->rx_handle_); + this->rx_handle_ = nullptr; + } + this->mark_failed(); + } +} + +i2s_chan_handle_t I2SAudioComponent::acquire_(bool &in_use, i2s_chan_handle_t handle) { + if (handle == nullptr) + return nullptr; + in_use = true; + if (!this->update_rx_channel_()) { + in_use = false; + return nullptr; + } + return handle; +} + +i2s_chan_handle_t I2SAudioComponent::acquire_rx_channel() { return this->acquire_(this->rx_in_use_, this->rx_handle_); } + +void I2SAudioComponent::release_rx_channel() { + this->rx_in_use_ = false; + this->update_rx_channel_(); +} + +i2s_chan_handle_t I2SAudioComponent::acquire_tx_channel() { + // Speakers sharing the bus take turns; a second one must not get the channel while the first is playing + if (this->tx_in_use_) + return nullptr; + return this->acquire_(this->tx_in_use_, this->tx_handle_); +} + +void I2SAudioComponent::release_tx_channel() { + // The speaker task may have left the channel disabled already, so an invalid state error is expected here + i2s_channel_disable(this->tx_handle_); + this->tx_in_use_ = false; + this->update_rx_channel_(); +} + +bool I2SAudioComponent::update_rx_channel_() { + const bool needed = this->rx_in_use_ || this->tx_in_use_; + if (needed == this->rx_enabled_) + return true; + + esp_err_t err = needed ? i2s_channel_enable(this->rx_handle_) : i2s_channel_disable(this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to %s RX channel: %s", needed ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable"), + esp_err_to_name(err)); + return false; + } + this->rx_enabled_ = needed; + return true; +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 && USE_I2S_AUDIO_FULL_DUPLEX diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index 00a9705807e..b9adbe45e23 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -23,6 +23,15 @@ class I2SAudioBase : public Parented { void set_mclk_multiple(i2s_mclk_multiple_t mclk_multiple) { this->mclk_multiple_ = mclk_multiple; } protected: + i2s_clock_src_t get_clock_source_() const { +#ifdef I2S_CLK_SRC_APLL + if (this->use_apll_) { + return I2S_CLK_SRC_APLL; + } +#endif + return I2S_CLK_SRC_DEFAULT; + } + i2s_role_t i2s_role_{}; i2s_slot_mode_t slot_mode_; i2s_std_slot_mask_t std_slot_mask_; @@ -32,12 +41,51 @@ class I2SAudioBase : public Parented { i2s_mclk_multiple_t mclk_multiple_; }; -class I2SAudioIn : public I2SAudioBase {}; +class I2SAudioIn : public I2SAudioBase { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + public: + /// @brief Builds the RX configuration the parent uses to set up a full duplex channel pair. + virtual void build_full_duplex_config(i2s_std_config_t &std_cfg) = 0; +#endif +}; -class I2SAudioOut : public I2SAudioBase {}; +class I2SAudioOut : public I2SAudioBase { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + public: + /// @brief Builds the TX configuration the parent uses to set up a full duplex channel pair. The channel + /// configuration (DMA layout, role, interrupt priority) is shared by both channels. Validation only puts + /// standard speakers on a full duplex bus, so the SPDIF speaker never gets here. + virtual void build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) {} +#endif +}; class I2SAudioComponent final : public Component { public: +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + void setup() override; + + void set_audio_in(I2SAudioIn *audio_in) { this->audio_in_ = audio_in; } + void set_audio_out(I2SAudioOut *audio_out) { this->audio_out_ = audio_out; } + + /// @brief True when a microphone and a speaker share this bus at the same time. + bool is_full_duplex() const { return this->audio_in_ != nullptr && this->audio_out_ != nullptr; } + + /// @brief Enables the full duplex RX channel. Main loop only. + /// @return The enabled RX handle, or nullptr if the channel pair is unavailable + i2s_chan_handle_t acquire_rx_channel(); + /// @brief Releases the RX channel; it keeps running while the TX side needs its clocks. Main loop only. + void release_rx_channel(); + + /// @brief Starts the shared clocks and hands over the full duplex TX channel, still disabled, so the caller + /// can register callbacks and preload data before enabling it. Main loop only. + /// @return The TX handle, or nullptr if the channel pair is unavailable or another speaker holds it + i2s_chan_handle_t acquire_tx_channel(); + /// @brief Disables the TX channel and stops the shared clocks if the RX side is idle. Only the speaker that + /// acquired the channel may call this. Main loop only. + void release_tx_channel(); + /// @brief True while another speaker holds the full duplex TX channel + bool is_tx_in_use() const { return this->tx_in_use_; } +#endif i2s_std_gpio_config_t get_pin_config() const { return {.mclk = (gpio_num_t) this->mclk_pin_, .bclk = (gpio_num_t) this->bclk_pin_, @@ -68,8 +116,20 @@ class I2SAudioComponent final : public Component { protected: Mutex lock_; +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + /// @brief Marks one side active and brings the shared clocks up; undoes the mark if that fails. + i2s_chan_handle_t acquire_(bool &in_use, i2s_chan_handle_t handle); + /// @brief Enables the RX channel while either side is active, since it drives the shared clocks. + bool update_rx_channel_(); + I2SAudioIn *audio_in_{nullptr}; I2SAudioOut *audio_out_{nullptr}; + i2s_chan_handle_t rx_handle_{nullptr}; + i2s_chan_handle_t tx_handle_{nullptr}; + bool rx_in_use_{false}; + bool tx_in_use_{false}; + bool rx_enabled_{false}; +#endif int mclk_pin_{I2S_GPIO_UNUSED}; int bclk_pin_{I2S_GPIO_UNUSED}; int lrclk_pin_; diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 1345f0187b3..526ae304ee2 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -46,6 +46,13 @@ void I2SAudioMicrophone::setup() { return; } +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex() && this->parent_->is_failed()) { + this->mark_failed(LOG_STR("Full duplex bus setup failed")); + return; + } +#endif + this->configure_stream_settings_(); } @@ -94,7 +101,44 @@ void I2SAudioMicrophone::start() { xSemaphoreTake(this->active_listeners_semaphore_, 0); } +void I2SAudioMicrophone::build_std_config_(i2s_std_config_t &std_cfg) const { + i2s_std_slot_config_t std_slot_cfg = + I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) this->slot_bit_width_, this->slot_mode_); + std_slot_cfg.slot_bit_width = this->slot_bit_width_; + std_slot_cfg.slot_mask = this->std_slot_mask_; + + i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); + pin_config.din = this->din_pin_; + + std_cfg = { + .clk_cfg = + { + .sample_rate_hz = this->sample_rate_, + .clk_src = this->get_clock_source_(), + .mclk_multiple = this->mclk_multiple_, + }, + .slot_cfg = std_slot_cfg, + .gpio_cfg = pin_config, + }; +} + +#ifdef USE_I2S_AUDIO_FULL_DUPLEX +void I2SAudioMicrophone::build_full_duplex_config(i2s_std_config_t &std_cfg) { this->build_std_config_(std_cfg); } +#endif + bool I2SAudioMicrophone::start_driver_() { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex()) { + this->rx_handle_ = this->parent_->acquire_rx_channel(); + if (this->rx_handle_ == nullptr) { + ESP_LOGE(TAG, "Full duplex channel unavailable"); + return false; + } + this->configure_stream_settings_(); + return true; + } +#endif + if (!this->parent_->try_lock()) { return false; // Waiting for another i2s to return lock } @@ -115,18 +159,12 @@ bool I2SAudioMicrophone::start_driver_() { return false; } - i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; -#ifdef I2S_CLK_SRC_APLL - if (this->use_apll_) { - clk_src = I2S_CLK_SRC_APLL; - } -#endif - i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); #if SOC_I2S_SUPPORTS_PDM_RX if (this->pdm_) { + i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); i2s_pdm_rx_clk_config_t clk_cfg = { .sample_rate_hz = this->sample_rate_, - .clk_src = clk_src, + .clk_src = this->get_clock_source_(), .mclk_multiple = this->mclk_multiple_, .dn_sample_mode = this->pdm_dsr_, }; @@ -162,23 +200,8 @@ bool I2SAudioMicrophone::start_driver_() { } else #endif { - i2s_std_clk_config_t clk_cfg = { - .sample_rate_hz = this->sample_rate_, - .clk_src = clk_src, - .mclk_multiple = this->mclk_multiple_, - }; - i2s_std_slot_config_t std_slot_cfg = - I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) this->slot_bit_width_, this->slot_mode_); - std_slot_cfg.slot_bit_width = this->slot_bit_width_; - std_slot_cfg.slot_mask = this->std_slot_mask_; - - pin_config.din = this->din_pin_; - - i2s_std_config_t std_cfg = { - .clk_cfg = clk_cfg, - .slot_cfg = std_slot_cfg, - .gpio_cfg = pin_config, - }; + i2s_std_config_t std_cfg; + this->build_std_config_(std_cfg); /* Initialize the channel */ err = i2s_channel_init_std_mode(this->rx_handle_, &std_cfg); } @@ -210,6 +233,17 @@ void I2SAudioMicrophone::stop_driver_() { // There is no harm continuing to unload the driver if an error is ever returned by the various functions. This // ensures that we stop/unload the driver when it only partially starts. +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex()) { + // The parent owns the channel pair, so only hand the RX channel back + if (this->rx_handle_ != nullptr) { + this->parent_->release_rx_channel(); + this->rx_handle_ = nullptr; + } + return; + } +#endif + esp_err_t err; if (this->rx_handle_ != nullptr) { /* Have to stop the channel before deleting it */ diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 1b17380ed87..c5ed52f6514 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -33,7 +33,14 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon void set_pdm_dsr(i2s_pdm_dsr_t pdm_dsr) { this->pdm_dsr_ = pdm_dsr; } #endif +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + void build_full_duplex_config(i2s_std_config_t &std_cfg) override; +#endif + protected: + /// @brief Builds the standard (non-PDM) mode configuration from the configured settings. + void build_std_config_(i2s_std_config_t &std_cfg) const; + /// @brief Starts the I2S driver. Updates the ``audio_stream_info_`` member variable with the current setttings. /// @return True if succesful, false otherwise bool start_driver_(); @@ -59,7 +66,7 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon TaskHandle_t task_handle_{nullptr}; gpio_num_t din_pin_{I2S_GPIO_UNUSED}; - i2s_chan_handle_t rx_handle_; + i2s_chan_handle_t rx_handle_{nullptr}; #if SOC_I2S_SUPPORTS_PDM_RX i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; #endif diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index e4ea186199d..ddbe08922fe 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -20,6 +20,7 @@ from esphome.types import ConfigType from .. import ( CONF_I2S_AUDIO_ID, + CONF_I2S_COMM_FMT, CONF_I2S_DOUT_PIN, CONF_I2S_MODE, CONF_LEFT, @@ -27,6 +28,7 @@ from .. import ( CONF_MONO, CONF_PRIMARY, CONF_RIGHT, + CONF_SPDIF_MODE, CONF_STEREO, CONF_USE_APLL, I2SAudioOut, @@ -46,9 +48,7 @@ I2SAudioSpeakerBase = i2s_audio_ns.class_( I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) CONF_DAC_TYPE = "dac_type" -CONF_I2S_COMM_FMT = "i2s_comm_fmt" CONF_KEEP_ALIVE = "keep_alive" -CONF_SPDIF_MODE = "spdif_mode" I2SAudioSpeakerBase = i2s_audio_ns.class_( "I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 3938d777717..46d6473983d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -467,14 +467,6 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s return ESP_ERR_INVALID_STATE; } - i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; - -#if SOC_CLK_APLL_SUPPORTED - if (this->use_apll_) { - clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; - } -#endif // SOC_CLK_APLL_SUPPORTED - // SPDIF mode: fixed configuration for BMC encoding // For new driver, dma_frame_num is in I2S frames (8 bytes each for 32-bit stereo) uint32_t dma_buffer_length = SPDIF_BLOCK_I2S_FRAMES; // One SPDIF block = 384 I2S frames = 3072 bytes @@ -496,7 +488,7 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s // SPDIF: double sample rate for BMC, 32-bit stereo, only data pin needed i2s_std_clk_config_t clk_cfg = { .sample_rate_hz = this->sample_rate_ * 2, - .clk_src = clk_src, + .clk_src = this->get_clock_source_(), .mclk_multiple = this->mclk_multiple_, }; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 85c0e6358a2..f14626dd47f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -41,6 +41,17 @@ void I2SAudioSpeakerBase::setup() { this->set_volume(this->volume_); this->set_mute_state(this->mute_state_); +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + // The parent set up before us and already routed dout to the shared TX channel, which is never re-initialized. + // Parking would detach the pin for good. SPDIF speakers cannot use a full duplex bus. + if (this->parent_->is_full_duplex()) { + if (this->parent_->is_failed()) { + this->mark_failed(LOG_STR("Full duplex bus setup failed")); + } + return; + } +#endif + // Until the I2S driver first starts, dout sits in its reset state (often pulled high, or a JTAG // function on the ESP32-S3), which keeps a SPDIF optical transmitter lit. Park it low now. this->park_dout_pin_(); @@ -130,7 +141,7 @@ void I2SAudioSpeakerBase::loop() { // Handle the speaker's state switch (this->state_) { - case speaker::STATE_STARTING: + case speaker::STATE_STARTING: { if (this->status_has_error()) { break; } @@ -140,7 +151,21 @@ void I2SAudioSpeakerBase::loop() { break; } - if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_tx_in_use()) { + // Another speaker on the bus is playing; wait for it to release the channel + break; + } +#endif + const esp_err_t err = this->start_i2s_driver(this->audio_stream_info_); + if (err == ESP_ERR_NOT_SUPPORTED) { + // Retrying cannot help; stay stopped so the audio source gives up instead of waiting forever + ESP_LOGE(TAG, "Stream format not supported; not starting"); + this->rejected_stream_info_ = this->audio_stream_info_; + this->state_ = speaker::STATE_STOPPED; + break; + } + if (err != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); this->status_momentary_error(1000); break; @@ -159,6 +184,7 @@ void I2SAudioSpeakerBase::loop() { this->stop_i2s_driver_(false); // Stops the driver to return the lock; will be reloaded in next attempt } break; + } case speaker::STATE_RUNNING: // Intentional fallthrough case speaker::STATE_STOPPING: // Intentional fallthrough case speaker::STATE_STOPPED: @@ -240,6 +266,9 @@ void I2SAudioSpeakerBase::start() { return; if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; + // play() calls start() on every write, so without this a rejected stream would be retried and logged nonstop + if (this->rejected_stream_info_.has_value() && this->rejected_stream_info_.value() == this->audio_stream_info_) + return; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } @@ -277,6 +306,15 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c return err; } + err = this->prepare_event_queues_(event_queue_size); + if (err != ESP_OK) { + this->delete_channel_(); + this->parent_->unlock(); + } + return err; +} + +esp_err_t I2SAudioSpeakerBase::prepare_event_queues_(size_t event_queue_size) { // Both queues are drained by the speaker task before it preloads, whether the channel is new or kept if (this->i2s_event_queue_ == nullptr) { this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); @@ -290,15 +328,41 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) { ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)"); - this->delete_channel_(); - this->parent_->unlock(); return ESP_ERR_NO_MEM; } return ESP_OK; } +#ifdef USE_I2S_AUDIO_FULL_DUPLEX +esp_err_t I2SAudioSpeakerBase::acquire_full_duplex_channel_(size_t event_queue_size) { + this->tx_handle_ = this->parent_->acquire_tx_channel(); + if (this->tx_handle_ == nullptr) { + ESP_LOGE(TAG, "Full duplex channel unavailable"); + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = this->prepare_event_queues_(event_queue_size); + if (err != ESP_OK) { + this->parent_->release_tx_channel(); + this->tx_handle_ = nullptr; + } + return err; +} +#endif + void I2SAudioSpeakerBase::stop_i2s_driver_(bool keep_channel) { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex()) { + // The parent owns the channel pair and its pins, so only hand the TX channel back + if (this->tx_handle_ != nullptr) { + this->parent_->release_tx_channel(); + this->tx_handle_ = nullptr; + } + return; + } +#endif + if (this->tx_handle_ != nullptr) { i2s_channel_disable(this->tx_handle_); // A kept channel lets the next start skip i2s_new_channel and i2s_channel_init_std_mode, whose GPIO and @@ -372,9 +436,9 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea this->current_stream_info_.bytes_to_samples(bytes_read)); } -bool I2SAudioSpeakerBase::esp32_mono_swap_needed_() const { +bool I2SAudioSpeakerBase::esp32_mono_swap_needed(const audio::AudioStreamInfo &stream_info) { #ifdef USE_ESP32_VARIANT_ESP32 - return this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16; + return stream_info.get_channels() == 1 && stream_info.get_bits_per_sample() == 16; #else return false; #endif // USE_ESP32_VARIANT_ESP32 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 07b7e5d1905..03213347f0e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -127,6 +127,18 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, size_t event_queue_size); + /// @brief Creates the lockstep queues unless they exist already. + /// @param event_queue_size Size of each queue + /// @return ESP_OK if successful, or ESP_ERR_NO_MEM + esp_err_t prepare_event_queues_(size_t event_queue_size); + +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + /// @brief Takes the TX channel of the parent's full duplex pair and prepares the event queues. + /// @param event_queue_size Size of the event queue + /// @return ESP_OK if successful, or an error code. On failure, hands the channel back to the parent. + esp_err_t acquire_full_duplex_channel_(size_t event_queue_size); +#endif + /// @brief Stops the I2S driver and unlocks the I2S port; keep_channel leaves the disabled channel allocated void stop_i2s_driver_(bool keep_channel); @@ -160,8 +172,10 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); + /// @brief Whether 16-bit mono samples in this format need the ESP32 (non-variant) pair swap. + static bool esp32_mono_swap_needed(const audio::AudioStreamInfo &stream_info); /// @brief Whether the output is 16-bit mono on the original ESP32, which sends each pair of samples swapped. - bool esp32_mono_swap_needed_() const; + bool esp32_mono_swap_needed_() const { return esp32_mono_swap_needed(this->output_stream_info_); } /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. /// Only applies when esp32_mono_swap_needed_() is true. `data` must start on a DMA word and hold whole pairs. @@ -182,6 +196,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public optional timeout_; + // A format start_i2s_driver() refused. Those checks compare against fixed settings, so it can never start. + optional rejected_stream_info_; + bool pause_state_{false}; bool keep_alive_{false}; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 16abb2f22ef..73ad924fad7 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -86,7 +86,7 @@ void I2SAudioSpeaker::run_speaker_task() { // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); // Hand over whole ESP32 mono sample pairs so writes stay word-aligned - const size_t source_alignment = bytes_per_frame * this->dma_frame_multiple_(); + const size_t source_alignment = bytes_per_frame * dma_frame_multiple(this->output_stream_info_); const bool pair_aligned = source_alignment > bytes_per_frame; // Round the ring buffer size down to a multiple of source_alignment so the wrap boundary avoids splices. const size_t ring_buffer_size = @@ -111,7 +111,7 @@ void I2SAudioSpeaker::run_speaker_task() { } else { // Should not happen for a READY channel; fall back to the requested size. dma_buffer_bytes = this->output_stream_info_.frames_to_bytes( - dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_())); + dma_buffer_frames(this->output_stream_info_, dma_frame_multiple(this->output_stream_info_))); } // dma_buffer_bytes counts output-format bytes; convert with the output stream info. const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); @@ -442,6 +442,25 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream } #endif // USE_ESP32_VARIANT_ESP32 +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex()) { + // The parent set up the channel once from the configured format, so the stream must produce exactly that + const audio::AudioStreamInfo required = this->full_duplex_stream_info_(); + if (this->output_stream_info_ != required) { + ESP_LOGE(TAG, "Full duplex requires %u-bit, %u channel audio at %" PRIu32 " Hz", + (unsigned) required.get_bits_per_sample(), (unsigned) required.get_channels(), + required.get_sample_rate()); + if (this->output_stream_info_.get_channels() != required.get_channels()) { + ESP_LOGE(TAG, "A resampler does not change the channel count; add a mixer speaker with num_channels: %u", + (unsigned) required.get_channels()); + } + return ESP_ERR_NOT_SUPPORTED; + } + // The speaker task will enable the channel after preloading. + return this->acquire_full_duplex_channel_(I2S_EVENT_QUEUE_COUNT); + } +#endif // USE_I2S_AUDIO_FULL_DUPLEX + if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); return ESP_ERR_INVALID_STATE; @@ -457,25 +476,37 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream this->current_stream_info_ = audio_stream_info; + i2s_chan_config_t chan_cfg; + i2s_std_config_t std_cfg; + this->build_i2s_config_(this->output_stream_info_, chan_cfg, std_cfg); + + // The speaker task will enable the channel after preloading. + return this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT); +} + +#ifdef USE_I2S_AUDIO_FULL_DUPLEX +audio::AudioStreamInfo I2SAudioSpeaker::full_duplex_stream_info_() const { + return audio::AudioStreamInfo(static_cast(this->slot_bit_width_), + this->slot_mode_ == I2S_SLOT_MODE_STEREO ? 2 : 1, this->sample_rate_); +} + +void I2SAudioSpeaker::build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) { + this->build_i2s_config_(this->full_duplex_stream_info_(), chan_cfg, std_cfg); +} +#endif // USE_I2S_AUDIO_FULL_DUPLEX + +void I2SAudioSpeaker::build_i2s_config_(const audio::AudioStreamInfo &output_stream_info, i2s_chan_config_t &chan_cfg, + i2s_std_config_t &std_cfg) const { // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. - uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_()); - - i2s_role_t i2s_role = this->i2s_role_; - i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; - -#if SOC_CLK_APLL_SUPPORTED - if (this->use_apll_) { - clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; - } -#endif // SOC_CLK_APLL_SUPPORTED + uint32_t dma_buffer_length = dma_buffer_frames(output_stream_info, dma_frame_multiple(output_stream_info)); // Log DMA configuration for debugging ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT, (unsigned long) dma_buffer_length); - i2s_chan_config_t chan_cfg = { + chan_cfg = { .id = this->parent_->get_port(), - .role = i2s_role, + .role = this->i2s_role_, .dma_desc_num = DMA_BUFFERS_COUNT, .dma_frame_num = dma_buffer_length, .auto_clear = true, @@ -484,22 +515,22 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream // Build standard I2S clock/slot/gpio configuration i2s_std_clk_config_t clk_cfg = { - .sample_rate_hz = audio_stream_info.get_sample_rate(), - .clk_src = clk_src, + .sample_rate_hz = output_stream_info.get_sample_rate(), + .clk_src = this->get_clock_source_(), .mclk_multiple = this->mclk_multiple_, }; i2s_slot_mode_t slot_mode = this->slot_mode_; i2s_std_slot_mask_t slot_mask = this->std_slot_mask_; - if (audio_stream_info.get_channels() == 1) { + if (output_stream_info.get_channels() == 1) { slot_mode = I2S_SLOT_MODE_MONO; - } else if (audio_stream_info.get_channels() == 2) { + } else if (output_stream_info.get_channels() == 2) { slot_mode = I2S_SLOT_MODE_STEREO; slot_mask = I2S_STD_SLOT_BOTH; } // Configure the data bit width from the output (post-narrowing) format, which is what is clocked out. - const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) this->output_stream_info_.get_bits_per_sample(); + const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) output_stream_info.get_bits_per_sample(); i2s_std_slot_config_t slot_cfg; switch (this->i2s_comm_fmt_) { case I2SCommFmt::PCM: @@ -535,20 +566,11 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config(); gpio_cfg.dout = this->dout_pin_; - i2s_std_config_t std_cfg = { + std_cfg = { .clk_cfg = clk_cfg, .slot_cfg = slot_cfg, .gpio_cfg = gpio_cfg, }; - - esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT); - if (err != ESP_OK) { - return err; - } - - // The speaker task will enable the channel after preloading. - - return ESP_OK; } } // namespace esphome::i2s_audio diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 01390dfdf70..fb1184bb71e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -20,12 +20,28 @@ class I2SAudioSpeaker final : public I2SAudioSpeakerBase { void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; } +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + void build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) override; +#endif + protected: void run_speaker_task() override; esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + /// @brief Builds the channel and standard mode configuration for the given output format. + /// @param output_stream_info Format clocked out of the I2S peripheral + void build_i2s_config_(const audio::AudioStreamInfo &output_stream_info, i2s_chan_config_t &chan_cfg, + i2s_std_config_t &std_cfg) const; + +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + /// @brief The output format of a full duplex bus, fixed by the configuration since the channel is set up once. + audio::AudioStreamInfo full_duplex_stream_info_() const; +#endif + /// @brief Frame multiple for DMA buffers and writes; 2 keeps ESP32 mono sample pairs word-aligned. - uint32_t dma_frame_multiple_() const { return this->esp32_mono_swap_needed_() ? 2 : 1; } + static uint32_t dma_frame_multiple(const audio::AudioStreamInfo &stream_info) { + return esp32_mono_swap_needed(stream_info) ? 2 : 1; + } I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c901ff3e844..0655c4a399c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -95,6 +95,7 @@ #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT +#define USE_I2S_AUDIO_FULL_DUPLEX #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE #define USE_IMPROV_BLE_STATE_CALLBACK diff --git a/tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml b/tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml new file mode 100644 index 00000000000..5cc4fb38125 --- /dev/null +++ b/tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml @@ -0,0 +1,42 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2s_audio: + - id: duplex_bus + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + full_duplex: true + +microphone: + - platform: i2s_audio + id: duplex_mic + i2s_audio_id: duplex_bus + i2s_din_pin: GPIO16 + adc_type: external + sample_rate: 48000 + bits_per_sample: 32bit + +speaker: + - platform: i2s_audio + id: duplex_speaker_a + i2s_audio_id: duplex_bus + i2s_dout_pin: + number: GPIO17 + allow_other_uses: true + dac_type: external + sample_rate: 48000 + bits_per_sample: 32bit + - platform: i2s_audio + id: duplex_speaker_b + i2s_audio_id: duplex_bus + i2s_dout_pin: + number: GPIO17 + allow_other_uses: true + dac_type: external + sample_rate: 48000 + bits_per_sample: 32bit diff --git a/tests/component_tests/i2s_audio/test_i2s_audio.py b/tests/component_tests/i2s_audio/test_i2s_audio.py new file mode 100644 index 00000000000..f92d0ca9414 --- /dev/null +++ b/tests/component_tests/i2s_audio/test_i2s_audio.py @@ -0,0 +1,141 @@ +"""Tests for the i2s_audio full duplex bus.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.i2s_audio import _validate_full_duplex +from esphome.core import CORE, ID + +BUS_ID = "duplex_bus" + + +def _device(**overrides: Any) -> dict[str, Any]: + return { + "platform": "i2s_audio", + "id": ID("device", is_declaration=True), + "i2s_audio_id": ID(BUS_ID, is_declaration=False), + "i2s_dout_pin": 17, + "channel": "stereo", + "i2s_comm_fmt": "stand_i2s", + "sample_rate": 48000, + "bits_per_sample": 32, + "i2s_mode": "primary", + "use_apll": False, + "mclk_multiple": 256, + } | overrides + + +def _full_config( + microphones: list[dict[str, Any]], speakers: list[dict[str, Any]] +) -> dict[str, Any]: + return {"microphone": microphones, "speaker": speakers} + + +def test_full_duplex_generates_code( + generate_main: Callable[[str | Path], str], +) -> None: + """The microphone and every speaker are registered with the bus and the full duplex code is enabled.""" + main_cpp = generate_main( + "tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml" + ) + + assert "duplex_bus->set_audio_in(duplex_mic);" in main_cpp + assert "duplex_bus->set_audio_out(duplex_speaker_a);" in main_cpp + assert "duplex_bus->set_audio_out(duplex_speaker_b);" in main_cpp + assert "USE_I2S_AUDIO_FULL_DUPLEX" in {define.name for define in CORE.defines} + + +_OTHER_BUS = _device(i2s_audio_id=ID("other_bus", is_declaration=False)) + + +@pytest.mark.parametrize( + ("microphones", "speakers"), + [ + pytest.param([_device(pdm=False)], [_device(spdif_mode=False)], id="matching"), + pytest.param( + [_device(), _OTHER_BUS], [_device(), _OTHER_BUS], id="other-bus-ignored" + ), + pytest.param( + [_device()], [_device(), _device(), _device()], id="several-speakers" + ), + ], +) +def test_full_duplex_accepts( + microphones: list[dict[str, Any]], speakers: list[dict[str, Any]] +) -> None: + """Devices with the same clock and TX settings share the bus; other buses do not count.""" + _validate_full_duplex(_full_config(microphones, speakers), BUS_ID) + + +@pytest.mark.parametrize( + ("microphones", "speakers"), + [ + ([], [_device()]), + ([_device()], []), + ([_device(), _device()], [_device()]), + ], +) +def test_full_duplex_requires_one_microphone_and_a_speaker( + microphones: list[dict[str, Any]], speakers: list[dict[str, Any]] +) -> None: + """Exactly one microphone and at least one speaker must use the bus.""" + with pytest.raises(cv.Invalid, match="exactly one i2s_audio microphone"): + _validate_full_duplex(_full_config(microphones, speakers), BUS_ID) + + +@pytest.mark.parametrize( + ("microphone", "speaker", "error_match"), + [ + pytest.param(_device(pdm=True), _device(), "PDM microphone", id="pdm"), + pytest.param(_device(), _device(spdif_mode=True), "SPDIF speaker", id="spdif"), + ], +) +def test_full_duplex_rejects_unsupported_devices( + microphone: dict[str, Any], speaker: dict[str, Any], error_match: str +) -> None: + """PDM receive and SPDIF output cannot share the standard mode clocks.""" + with pytest.raises(cv.Invalid, match=error_match): + _validate_full_duplex(_full_config([microphone], [speaker]), BUS_ID) + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("sample_rate", 16000), + ("bits_per_sample", 16), + ("i2s_mode", "secondary"), + ("use_apll", True), + ("mclk_multiple", 384), + ], +) +def test_full_duplex_rejects_mismatched_clock_settings(key: str, value: Any) -> None: + """Settings that shape the shared clocks must match.""" + with pytest.raises(cv.Invalid, match=f"must use the same '{key}'"): + _validate_full_duplex( + _full_config([_device()], [_device(**{key: value})]), BUS_ID + ) + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("i2s_dout_pin", 18), + ("channel", "mono"), + ("i2s_comm_fmt", "stand_msb"), + ], +) +def test_full_duplex_rejects_mismatched_speakers(key: str, value: Any) -> None: + """Settings that shape the TX channel must match across speakers.""" + with pytest.raises( + cv.Invalid, + match=f"speakers on 'full_duplex' bus '{BUS_ID}' must use the same '{key}'", + ): + _validate_full_duplex( + _full_config([_device()], [_device(), _device(**{key: value})]), BUS_ID + ) diff --git a/tests/components/i2s_audio/common-full_duplex.yaml b/tests/components/i2s_audio/common-full_duplex.yaml new file mode 100644 index 00000000000..06ec6a87c96 --- /dev/null +++ b/tests/components/i2s_audio/common-full_duplex.yaml @@ -0,0 +1,26 @@ +i2s_audio: + - id: i2s_duplex_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} + full_duplex: true + +microphone: + - platform: i2s_audio + id: duplex_mic + i2s_audio_id: i2s_duplex_bus + i2s_din_pin: ${i2s_din_pin} + adc_type: external + sample_rate: 48000 + bits_per_sample: 32bit + channel: stereo + +speaker: + - platform: i2s_audio + id: duplex_speaker + i2s_audio_id: i2s_duplex_bus + i2s_dout_pin: ${i2s_dout_pin} + dac_type: external + sample_rate: 48000 + bits_per_sample: 32bit + channel: stereo diff --git a/tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml b/tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml new file mode 100644 index 00000000000..4e8c9b4e4c8 --- /dev/null +++ b/tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + i2s_din_pin: GPIO16 + i2s_dout_pin: GPIO17 + +packages: + i2s_audio: !include common-full_duplex.yaml diff --git a/tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml b/tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml new file mode 100644 index 00000000000..4e8c9b4e4c8 --- /dev/null +++ b/tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + i2s_din_pin: GPIO16 + i2s_dout_pin: GPIO17 + +packages: + i2s_audio: !include common-full_duplex.yaml From 802c2633edb9634b9d47bde46648288677d5a2de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 21:38:58 -1000 Subject: [PATCH 454/651] [image] Draw rows in the outer loop and hand opaque RGB565 and RGB blocks to the display (#20653) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- esphome/components/animation/animation.cpp | 4 +- esphome/components/animation/animation.h | 2 +- esphome/components/animation/image.py | 3 +- esphome/components/file/image.py | 8 +- esphome/components/image/image.cpp | 211 ++++++++++------- esphome/components/image/image.h | 11 +- .../runtime_image/image_decoder.cpp | 2 +- .../runtime_image/runtime_image.cpp | 7 +- .../components/runtime_image/runtime_image.h | 5 - esphome/components/sendspin/image/__init__.py | 1 + tests/component_tests/image/test_init.py | 2 +- .../config/image_byte_order_big_endian.yaml | 35 +++ .../image_byte_order_little_endian.yaml | 35 +++ tests/component_tests/sendspin/test_image.py | 23 ++ tests/components/image/test_image_draw.cpp | 213 ++++++++++++++++++ 15 files changed, 466 insertions(+), 96 deletions(-) create mode 100644 tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml create mode 100644 tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml create mode 100644 tests/components/image/test_image_draw.cpp diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index 2a293adf1d8..1a2307520d3 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -5,8 +5,8 @@ namespace esphome::animation { Animation::Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, - image::ImageType type, image::Transparency transparent) - : Image(data_start, width, height, type, transparent), + image::ImageType type, image::Transparency transparent, bool big_endian) + : Image(data_start, width, height, type, transparent, big_endian), animation_data_start_(data_start), current_frame_(0), animation_frame_count_(animation_frame_count), diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index 4ee060c1213..0cee54c3607 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -6,7 +6,7 @@ namespace esphome::animation { class Animation final : public image::Image { public: Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, - image::Transparency transparent); + image::Transparency transparent, bool big_endian = false); uint32_t get_animation_frame_count() const; int get_current_frame() const; diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 09e0e8753cf..b8280f47cd9 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components.const import CONF_LOOP +from esphome.components.const import CONF_BYTE_ORDER, CONF_LOOP from esphome.components.file import image as file_image from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings @@ -90,6 +90,7 @@ async def setup_animation(config: ConfigType) -> None: frame_count, image_type, trans_value, + config.get(CONF_BYTE_ORDER) == "BIG_ENDIAN", ) if loop_config := config.get(CONF_LOOP): start = loop_config[CONF_START_FRAME] diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 20eda9a3278..5e4fd145a44 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -254,7 +254,13 @@ async def new_image(config: ConfigType) -> MockObj: """ prog_arr, width, height, image_type, trans_value, _ = await write_image(config) var = cg.new_Pvariable( - config[CONF_ID], prog_arr, width, height, image_type, trans_value + config[CONF_ID], + prog_arr, + width, + height, + image_type, + trans_value, + config.get(CONF_BYTE_ORDER) == "BIG_ENDIAN", ) add_metadata( config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY] diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index d27d110248c..60d81ad62f1 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -1,5 +1,7 @@ #include "image.h" +#include + #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -7,92 +9,137 @@ ESPHOME_LOG_TAG(TAG, "image"); namespace esphome::image { +// Walks the clipped range with rows (ROWS_OUTER) or columns outer and hands the +// body its coordinates and row major index. The body must be ESPHOME_ALWAYS_INLINE, +// at -Os GCC otherwise calls it once per pixel. +template +static void for_each_pixel(int x_start, int x_end, int y_start, int y_end, int width, F &&f) { + const int outer_start = ROWS_OUTER ? y_start : x_start; + const int outer_end = ROWS_OUTER ? y_end : x_end; + const int inner_start = ROWS_OUTER ? x_start : y_start; + const int inner_end = ROWS_OUTER ? x_end : y_end; + for (int outer = outer_start; outer < outer_end; outer++) { + const int outer_pos = ROWS_OUTER ? outer * width : outer; + for (int inner = inner_start; inner < inner_end; inner++) { + f(ROWS_OUTER ? inner : outer, ROWS_OUTER ? outer : inner, + ROWS_OUTER ? outer_pos + inner : outer_pos + inner * width); + } + } +} + void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) { - int img_x0 = 0; - int img_y0 = 0; - int w = width_; - int h = height_; + int x_start = 0; + int y_start = 0; + int x_end = width_; + int y_end = height_; auto clipping = display->get_clipping(); if (clipping.is_set()) { if (clipping.x > x) - img_x0 += clipping.x - x; + x_start += clipping.x - x; if (clipping.y > y) - img_y0 += clipping.y - y; - if (w > clipping.x2() - x) - w = clipping.x2() - x; - if (h > clipping.y2() - y) - h = clipping.y2() - y; + y_start += clipping.y - y; + if (x_end > clipping.x2() - x) + x_end = clipping.x2() - x; + if (y_end > clipping.y2() - y) + y_end = clipping.y2() - y; } - switch (type_) { - case IMAGE_TYPE_BINARY: { - for (int img_x = img_x0; img_x < w; img_x++) { - for (int img_y = img_y0; img_y < h; img_y++) { - if (this->get_binary_pixel_(img_x, img_y)) { - display->draw_pixel_at(x + img_x, y + img_y, color_on); - } else if (!this->transparency_) { - display->draw_pixel_at(x + img_x, y + img_y, color_off); - } - } - } +#ifndef USE_ESP8266 + // Opaque RGB565 and RGB are already laid out for draw_pixels_at(), so the display + // gets the whole window. Not on the ESP8266, whose image bytes need progmem_read_byte(). + if (this->transparency_ == TRANSPARENCY_OPAQUE && + (this->type_ == IMAGE_TYPE_RGB565 || this->type_ == IMAGE_TYPE_RGB)) { + // Keep the block on screen + x_start = std::max(x_start, -x); + y_start = std::max(y_start, -y); + x_end = std::min(x_end, display->get_width() - x); + y_end = std::min(y_end, display->get_height() - y); + if (x_end <= x_start || y_end <= y_start) + return; + const bool rgb565 = this->type_ == IMAGE_TYPE_RGB565; + display->draw_pixels_at(x + x_start, y + y_start, x_end - x_start, y_end - y_start, this->data_start_, + display::COLOR_ORDER_RGB, rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_888, + this->big_endian_, x_start, y_start, this->width_ - x_end); + return; + } +#endif + + // Rows keep the read and the write sequential; when the display swaps the axes + // columns keep the write sequential instead, and a strided write costs more. +#ifdef USE_PSRAM + if (display->pixel_axes_swapped()) { + this->draw_(x, y, display, color_on, color_off, x_start, x_end, y_start, y_end); + return; + } +#endif + // Without PSRAM a strided write is free, so one copy of the loops is enough + this->draw_(x, y, display, color_on, color_off, x_start, x_end, y_start, y_end); +} + +template +void Image::draw_(int x, int y, display::Display *display, Color color_on, Color color_off, int x_start, int x_end, + int y_start, int y_end) { + // Local copies, or the virtual draw_pixel_at() forces a reload every pixel + const Transparency transparency = this->transparency_; + const int width = this->width_; + switch (this->type_) { + case IMAGE_TYPE_BINARY: + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + if (this->get_binary_pixel_(img_x, img_y)) { + display->draw_pixel_at(x + img_x, y + img_y, color_on); + } else if (transparency == TRANSPARENCY_OPAQUE) { + display->draw_pixel_at(x + img_x, y + img_y, color_off); + } + }); + break; + case IMAGE_TYPE_RGB111: + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + auto color = this->get_rgb111_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + }); + break; + case IMAGE_TYPE_GRAYSCALE: { + const uint8_t *const data = this->data_start_; + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + const uint8_t gray = progmem_read_byte(data + pos); + Color color = Color(gray, gray, gray, 0xFF); + if (transparency == TRANSPARENCY_CHROMA_KEY) { + if (gray == 1) { + return; // transparent pixel, skip drawing + } + } else if (transparency == TRANSPARENCY_ALPHA_CHANNEL) { + // gray is the alpha: blend from color_off to color_on, drawn opaque + color = Color(Color::blend_channel(color_off.r, color_on.r, gray), + Color::blend_channel(color_off.g, color_on.g, gray), + Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); + } + display->draw_pixel_at(x + img_x, y + img_y, color); + }); break; } - case IMAGE_TYPE_RGB111: { - for (int img_x = img_x0; img_x < w; img_x++) { - for (int img_y = img_y0; img_y < h; img_y++) { - auto color = this->get_rgb111_pixel_(img_x, img_y); - if (color.w >= 0x80) { - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - } - break; - } - case IMAGE_TYPE_GRAYSCALE: - for (int img_x = img_x0; img_x < w; img_x++) { - for (int img_y = img_y0; img_y < h; img_y++) { - const uint32_t pos = (img_x + img_y * this->width_); - const uint8_t gray = progmem_read_byte(this->data_start_ + pos); - Color color = Color(gray, gray, gray, 0xFF); - switch (this->transparency_) { - case TRANSPARENCY_CHROMA_KEY: - if (gray == 1) { - continue; // skip drawing - } - break; - case TRANSPARENCY_ALPHA_CHANNEL: - // gray is the alpha: blend from color_off to color_on, drawn opaque - color = Color(Color::blend_channel(color_off.r, color_on.r, gray), - Color::blend_channel(color_off.g, color_on.g, gray), - Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); - break; - default: - break; - } - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - break; case IMAGE_TYPE_RGB565: - for (int img_x = img_x0; img_x < w; img_x++) { - for (int img_y = img_y0; img_y < h; img_y++) { - auto color = this->get_rgb565_pixel_(img_x, img_y); - if (color.w >= 0x80) { - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - } + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + auto color = this->get_rgb565_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + }); break; case IMAGE_TYPE_RGB: - for (int img_x = img_x0; img_x < w; img_x++) { - for (int img_y = img_y0; img_y < h; img_y++) { - auto color = this->get_rgb_pixel_(img_x, img_y); - if (color.w >= 0x80) { - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - } + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + auto color = this->get_rgb_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + }); break; } } @@ -185,7 +232,7 @@ Color Image::get_rgb111_pixel_(int x, int y) const { return Color(rgb & 4 ? 0xFF : 0x00, rgb & 2 ? 0xFF : 0x00, rgb & 1 ? 0xFF : 0x00, opaque ? 0xFF : 0x00); } Color Image::get_rgb_pixel_(int x, int y) const { - const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8; + const uint32_t pos = (x + y * this->width_) * static_cast(this->bpp_) / 8; Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1), progmem_read_byte(this->data_start_ + pos + 0), 0xFF); @@ -205,8 +252,10 @@ Color Image::get_rgb_pixel_(int x, int y) const { return color; } Color Image::get_rgb565_pixel_(int x, int y) const { - const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8; - uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos)); + const uint8_t *pos = this->data_start_ + (x + y * this->width_) * static_cast(this->bpp_) / 8; + // High byte first when big endian + const uint8_t high = this->big_endian_ ? 0 : 1; + const uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + high), progmem_read_byte(pos + 1 - high)); auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; @@ -242,8 +291,14 @@ Color Image::get_grayscale_pixel_(int x, int y) const { int Image::get_width() const { return this->width_; } int Image::get_height() const { return this->height_; } ImageType Image::get_type() const { return this->type_; } -Image::Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency) - : width_(width), height_(height), type_(type), data_start_(data_start), transparency_(transparency) { +Image::Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency, + bool big_endian) + : width_(width), + height_(height), + type_(type), + data_start_(data_start), + transparency_(transparency), + big_endian_(big_endian) { switch (this->type_) { case IMAGE_TYPE_BINARY: this->bpp_ = 1; diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index a94f8137f41..b03befcddb4 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -24,7 +24,9 @@ enum Transparency { class Image : public display::BaseImage { public: - Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency); + /// big_endian is the RGB565 byte order; codegen writes little endian unless configured otherwise. + Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency, + bool big_endian = false); Color get_pixel(int x, int y, Color color_on = display::COLOR_ON, Color color_off = display::COLOR_OFF) const; int get_width() const override; int get_height() const override; @@ -44,6 +46,10 @@ class Image : public display::BaseImage { lv_image_dsc_t *get_lv_image_dsc(); #endif protected: + /// Per-pixel draw of the clipped range, rows (true) or columns (false) outer. + template + void draw_(int x, int y, display::Display *display, Color color_on, Color color_off, int x_start, int x_end, + int y_start, int y_end); bool get_binary_pixel_(int x, int y) const; Color get_rgb111_pixel_(int x, int y) const; Color get_rgb_pixel_(int x, int y) const; @@ -55,7 +61,8 @@ class Image : public display::BaseImage { ImageType type_; const uint8_t *data_start_; Transparency transparency_; - size_t bpp_{}; + bool big_endian_; + uint8_t bpp_{}; #ifdef USE_LVGL lv_img_dsc_t dsc_{}; #endif diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 55e25d76b72..d57d5d4e73d 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -30,6 +30,6 @@ 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_; } +bool ImageDecoder::image_is_big_endian_() const { return this->image_->big_endian_; } } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 79d2627a11c..14e0d5665a6 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -41,12 +41,11 @@ inline bool is_color_on(const Color &color) { RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Transparency transparency, image::Image *placeholder, bool is_big_endian, int fixed_width, int fixed_height) - : Image(nullptr, 0, 0, type, transparency), + : Image(nullptr, 0, 0, type, transparency, is_big_endian), format_(format), fixed_width_(fixed_width), fixed_height_(fixed_height), - placeholder_(placeholder), - is_big_endian_(is_big_endian) {} + placeholder_(placeholder) {} RuntimeImage::~RuntimeImage() { this->release(); } @@ -137,7 +136,7 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { Color mapped_color = color; this->map_chroma_key(mapped_color); uint16_t rgb565 = display::ColorUtil::color_to_565(mapped_color); - if (this->is_big_endian_) { + if (this->big_endian_) { this->buffer_[pos + 0] = static_cast((rgb565 >> 8) & 0xFF); this->buffer_[pos + 1] = static_cast(rgb565 & 0xFF); } else { diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 33bad52acf7..2a3ab40a5c1 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -227,11 +227,6 @@ class RuntimeImage : public image::Image { // Configuration bool progressive_display_{false}; - /** - * Whether the image is stored in big-endian format. - * This is used to determine how to store 16 bit colors in the buffer. - */ - bool is_big_endian_{false}; /** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */ bool external_buffer_{false}; diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index a5336d5e052..571f8aa47d8 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -173,6 +173,7 @@ async def to_code(config: ConfigType) -> None: settings.height, settings.image_type_enum, settings.transparent, + settings.byte_order_big_endian, ) add_metadata( view_id, diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 4d62bb52a19..602b923a1dd 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -745,7 +745,7 @@ def test_image_generation( in main_cpp ) assert ( - "new(cat_img) image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);" + "new(cat_img) image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, false);" in main_cpp ) diff --git a/tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml b/tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml new file mode 100644 index 00000000000..e58c546d2ee --- /dev/null +++ b/tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml @@ -0,0 +1,35 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +spi: + clk_pin: 18 + mosi_pin: 23 + +display: + - platform: ili9xxx + model: ili9342 + cs_pin: 22 + dc_pin: 21 + invert_colors: false + +sendspin: + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + byte_order: BIG_ENDIAN + resize: 240x240 + current_image: + id: album_art + transition_image: + id: album_art_transition diff --git a/tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml b/tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml new file mode 100644 index 00000000000..b9c4fd91d21 --- /dev/null +++ b/tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml @@ -0,0 +1,35 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +spi: + clk_pin: 18 + mosi_pin: 23 + +display: + - platform: ili9xxx + model: ili9342 + cs_pin: 22 + dc_pin: 21 + invert_colors: false + +sendspin: + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + byte_order: LITTLE_ENDIAN + resize: 240x240 + current_image: + id: album_art + transition_image: + id: album_art_transition diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py index dc9e4978c6b..7de2b9b4dba 100644 --- a/tests/component_tests/sendspin/test_image.py +++ b/tests/component_tests/sendspin/test_image.py @@ -4,6 +4,8 @@ These cover the rejection branches, which a compile test cannot reach: a `test*.yaml` can only assert that a configuration is accepted. """ +from collections.abc import Callable +from pathlib import Path from typing import Any import pytest @@ -120,3 +122,24 @@ def test_display_offset_accepted( config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) assert config["display_offset"].total_milliseconds == expected_ms + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + ("image_byte_order_big_endian.yaml", "true"), + ("image_byte_order_little_endian.yaml", "false"), + ], +) +def test_views_get_the_byte_order( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: str, +) -> None: + """Both views draw the frames the slot decodes, so they must read them in its byte order.""" + main_cpp = generate_main(component_config_path(config_file)) + + views = [line for line in main_cpp.splitlines() if "ArtworkImageView(" in line] + assert len(views) == 2 + assert all(line.endswith(f", {expected});") for line in views), views diff --git a/tests/components/image/test_image_draw.cpp b/tests/components/image/test_image_draw.cpp new file mode 100644 index 00000000000..80db8a4ad2a --- /dev/null +++ b/tests/components/image/test_image_draw.cpp @@ -0,0 +1,213 @@ +#include + +#include + +#include "esphome/components/display/display.h" +#include "esphome/components/image/image.h" + +namespace esphome::image::testing { + +class TestDisplay : public display::Display { + public: + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return 64; } + int get_height_internal() override { return 48; } +}; + +struct Blit { + int x, y, w, h; + const uint8_t *ptr; + display::ColorBitness bitness; + bool big_endian; + int x_offset, y_offset, x_pad; +}; + +// Records the bulk calls and counts the per pixel calls Image::draw() makes. +class RecordingDisplay : public TestDisplay { + public: + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + this->blits.push_back({x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad}); + } + void draw_pixel_at(int x, int y, Color color) override { this->pixels++; } + + std::vector blits; + int pixels{0}; +}; + +// Lets the bulk call fall through to the base class and keeps the pixels it produces. +class FallbackDisplay : public TestDisplay { + public: + void draw_pixel_at(int x, int y, Color color) override { this->pixels.push_back({x, y, color.raw_32}); } + + struct Pixel { + int x, y; + uint32_t raw; + }; + std::vector pixels; +}; + +static constexpr int W = 8, H = 6; + +static std::vector make_data(int bytes_per_pixel) { + std::vector data(W * H * bytes_per_pixel); + uint8_t value = 1; + for (auto &byte : data) + byte = value++; + return data; +} + +// Draws an opaque RGB565 image at (x, y) and returns the single bulk call it made. +static Blit draw_one(int x, int y, const std::vector &data, RecordingDisplay &display, + bool big_endian = false) { + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian); + image.draw(x, y, &display, Color::WHITE, Color::BLACK); + EXPECT_EQ(display.blits.size(), 1u); + EXPECT_EQ(display.pixels, 0); + return display.blits.at(0); +} + +static void expect_window(const Blit &blit, int x, int y, int w, int h, int x_offset, int y_offset, int x_pad) { + EXPECT_EQ(blit.x, x); + EXPECT_EQ(blit.y, y); + EXPECT_EQ(blit.w, w); + EXPECT_EQ(blit.h, h); + EXPECT_EQ(blit.x_offset, x_offset); + EXPECT_EQ(blit.y_offset, y_offset); + EXPECT_EQ(blit.x_pad, x_pad); +} + +TEST(ImageDraw, OpaqueRgb565IsOneBulkCall) { + const auto data = make_data(2); + RecordingDisplay display; + const Blit blit = draw_one(10, 20, data, display); + expect_window(blit, 10, 20, W, H, 0, 0, 0); + EXPECT_EQ(blit.ptr, data.data()); + EXPECT_EQ(blit.bitness, display::COLOR_BITNESS_565); + EXPECT_FALSE(blit.big_endian); +} + +TEST(ImageDraw, BigEndianRgb565IsPassedThrough) { + const auto data = make_data(2); + RecordingDisplay display; + EXPECT_TRUE(draw_one(0, 0, data, display, true).big_endian); +} + +TEST(ImageDraw, OpaqueRgbIsOneBulkCall) { + const auto data = make_data(3); + Image image(data.data(), W, H, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); + RecordingDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_EQ(display.blits.size(), 1u); + EXPECT_EQ(display.blits[0].bitness, display::COLOR_BITNESS_888); +} + +TEST(ImageDraw, ClippingShrinksTheWindow) { + const auto data = make_data(2); + RecordingDisplay display; + display.start_clipping(12, 22, 15, 24); // right and bottom are exclusive + expect_window(draw_one(10, 20, data, display), 12, 22, 3, 2, 2, 2, W - 5); +} + +TEST(ImageDraw, PartlyOffTheTopLeftIsClamped) { + const auto data = make_data(2); + RecordingDisplay display; + expect_window(draw_one(-3, -2, data, display), 0, 0, W - 3, H - 2, 3, 2, 0); +} + +TEST(ImageDraw, PartlyOffTheBottomRightIsClamped) { + const auto data = make_data(2); + RecordingDisplay display; + expect_window(draw_one(60, 44, data, display), 60, 44, 4, 4, 0, 0, W - 4); +} + +TEST(ImageDraw, FullyOffScreenDrawsNothing) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); + RecordingDisplay display; + image.draw(64, 0, &display, Color::WHITE, Color::BLACK); + image.draw(0, -H, &display, Color::WHITE, Color::BLACK); + EXPECT_TRUE(display.blits.empty()); + EXPECT_EQ(display.pixels, 0); +} + +TEST(ImageDraw, TransparentAndOtherFormatsStayPerPixel) { + const auto rgb565 = make_data(2); + const auto gray = make_data(1); + Image keyed(rgb565.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY); + Image grayscale(gray.data(), W, H, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE); + RecordingDisplay display; + keyed.draw(0, 0, &display, Color::WHITE, Color::BLACK); + grayscale.draw(0, 0, &display, Color::WHITE, Color::BLACK); + EXPECT_TRUE(display.blits.empty()); + EXPECT_GT(display.pixels, 0); +} + +// The base class fallback reads pixels in the image's byte order. +static void expect_fallback_decodes(bool big_endian) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian); + FallbackDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_EQ(display.pixels.size(), static_cast(W * H)); + for (const auto &pixel : display.pixels) { + const uint32_t rgb565 = + display::ColorUtil::read_packed(data.data(), pixel.x + pixel.y * W, big_endian); + const Color expected = display::ColorUtil::to_color(rgb565, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565); + EXPECT_EQ(pixel.raw, expected.raw_32) << "at " << pixel.x << "," << pixel.y; + } +} + +TEST(ImageDraw, FallbackDecodesLittleEndian) { expect_fallback_decodes(false); } +TEST(ImageDraw, FallbackDecodesBigEndian) { expect_fallback_decodes(true); } + +// The per pixel reader behind get_pixel() reads RGB565 in the image's byte order. +static void expect_reader_decodes(bool big_endian) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian); + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + const uint32_t rgb565 = + display::ColorUtil::read_packed(data.data(), x + y * W, big_endian); + const Color expected(((rgb565 >> 11) << 3) | ((rgb565 >> 11) >> 2), + (((rgb565 >> 5) & 0x3F) << 2) | (((rgb565 >> 5) & 0x3F) >> 4), + ((rgb565 & 0x1F) << 3) | ((rgb565 & 0x1F) >> 2), 0xFF); + EXPECT_EQ(image.get_pixel(x, y).raw_32, expected.raw_32) << "at " << x << "," << y; + } + } +} + +TEST(ImageDraw, ReaderDecodesLittleEndian) { expect_reader_decodes(false); } +TEST(ImageDraw, ReaderDecodesBigEndian) { expect_reader_decodes(true); } + +// A chroma keyed RGB565 draw stays per pixel and must match get_pixel(). +static void expect_keyed_draw_matches_reader(bool big_endian) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, big_endian); + FallbackDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_GT(display.pixels.size(), 0u); + for (const auto &pixel : display.pixels) { + EXPECT_EQ(pixel.raw, image.get_pixel(pixel.x, pixel.y).raw_32) << "at " << pixel.x << "," << pixel.y; + } +} + +TEST(ImageDraw, KeyedDrawMatchesReaderLittleEndian) { expect_keyed_draw_matches_reader(false); } +TEST(ImageDraw, KeyedDrawMatchesReaderBigEndian) { expect_keyed_draw_matches_reader(true); } + +// The fallback keeps red and blue in the order the image stores them. +TEST(ImageDraw, FallbackKeepsRgbChannelOrder) { + const auto data = make_data(3); + Image image(data.data(), W, H, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); + FallbackDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_EQ(display.pixels.size(), static_cast(W * H)); + for (const auto &pixel : display.pixels) { + // get_pixel() reports an opaque pixel with alpha set; the fallback decodes colour only + const Color expected = image.get_pixel(pixel.x, pixel.y); + EXPECT_EQ(pixel.raw, Color(expected.r, expected.g, expected.b).raw_32) << "at " << pixel.x << "," << pixel.y; + } +} + +} // namespace esphome::image::testing From 3298988f5d166d68a557c469183b3b4a9d3edf30 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:11:26 -1000 Subject: [PATCH 455/651] Bump bundled esphome-device-builder to 1.24.0 (#20851) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c9aa3dba62c..658b8c06302 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.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0 RUN \ platformio settings set enable_telemetry No \ From 126dc8b0b20a39d2992f3893eef0a79e0646e3d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:38:55 -1000 Subject: [PATCH 456/651] [components] Let core report communication failures in dump_config (1/5) (#20870) --- esphome/components/ads1115/ads1115.cpp | 7 +- esphome/components/aht10/aht10.cpp | 15 +--- esphome/components/aic3204/aic3204.cpp | 7 +- esphome/components/am2315c/am2315c.cpp | 28 ++----- esphome/components/am2320/am2320.cpp | 5 +- esphome/components/apds9306/apds9306.cpp | 25 +------ esphome/components/apds9306/apds9306.h | 6 -- esphome/components/apds9960/apds9960.cpp | 22 +----- esphome/components/apds9960/apds9960.h | 5 -- esphome/components/as5600/as5600.cpp | 8 +- esphome/components/as7341/as7341.cpp | 8 +- esphome/components/as734x/as734x.cpp | 3 - esphome/components/atm90e26/atm90e26.cpp | 9 +-- esphome/components/atm90e32/atm90e32.cpp | 5 +- esphome/components/bh1750/bh1750.cpp | 5 +- .../bluetooth_connection_bluedroid.cpp | 13 ++-- .../bluetooth_connection_rp2.cpp | 6 +- esphome/components/bm8563/bm8563.cpp | 5 +- .../components/bme280_base/bme280_base.cpp | 17 +---- esphome/components/bme280_base/bme280_base.h | 5 -- esphome/components/bme680/bme680.cpp | 57 ++++++-------- esphome/components/bme680/bme680.h | 2 + esphome/components/bmi160/bmi160.cpp | 30 ++++---- esphome/components/bmi160/bmi160.h | 2 + esphome/components/bmi270/bmi270.cpp | 26 +++---- esphome/components/bmp085/bmp085.cpp | 5 +- .../components/bmp280_base/bmp280_base.cpp | 18 +---- esphome/components/bmp280_base/bmp280_base.h | 5 -- .../components/bmp3xx_base/bmp3xx_base.cpp | 46 ++---------- esphome/components/bmp3xx_base/bmp3xx_base.h | 7 -- .../components/bmp581_base/bmp581_base.cpp | 74 +++---------------- esphome/components/bmp581_base/bmp581_base.h | 9 --- esphome/components/bq27220/bq27220.cpp | 5 +- esphome/components/cap1188/cap1188.cpp | 12 +-- esphome/components/cap1188/cap1188.h | 5 -- esphome/components/ccs811/ccs811.cpp | 38 ++-------- esphome/components/ccs811/ccs811.h | 9 --- esphome/components/ch422g/ch422g.cpp | 5 +- esphome/components/ch423/ch423.cpp | 10 +-- esphome/components/cm1106/cm1106.cpp | 6 +- esphome/components/cse7761/cse7761.cpp | 5 +- 41 files changed, 124 insertions(+), 456 deletions(-) diff --git a/esphome/components/ads1115/ads1115.cpp b/esphome/components/ads1115/ads1115.cpp index b75e1b73046..03714390fea 100644 --- a/esphome/components/ads1115/ads1115.cpp +++ b/esphome/components/ads1115/ads1115.cpp @@ -11,7 +11,7 @@ static const uint8_t ADS1115_REGISTER_CONFIG = 0x01; void ADS1115Component::setup() { uint16_t value; if (!this->read_byte_16(ADS1115_REGISTER_CONVERSION, &value)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -58,7 +58,7 @@ void ADS1115Component::setup() { config |= 0b0000000000000011; if (!this->write_byte_16(ADS1115_REGISTER_CONFIG, config)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->prev_config_ = config; @@ -66,9 +66,6 @@ void ADS1115Component::setup() { void ADS1115Component::dump_config() { ESP_LOGCONFIG(TAG, "ADS1115:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } float ADS1115Component::request_measurement(ADS1115Multiplexer multiplexer, ADS1115Gain gain, ADS1115Resolution resolution, ADS1115Samplerate samplerate) { diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index 83ab55e6576..41167033701 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -52,8 +52,7 @@ void AHT10Component::setup() { break; } if (error_code != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t cal_attempts = 0; @@ -61,20 +60,17 @@ void AHT10Component::setup() { while (data & AHT10_STATUS_BUSY) { delay(AHT10_DEFAULT_DELAY); if (this->read(&data, 1) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ++cal_attempts; if (cal_attempts > AHT10_INIT_ATTEMPTS) { - ESP_LOGE(TAG, "Initialization timed out"); - this->mark_failed(); + this->mark_failed(LOG_STR("Initialization timed out")); return; } } if ((data & 0x68) != 0x08) { // Bit[6:5] = 0b00, NORMAL mode and Bit[3] = 0b1, CALIBRATED - ESP_LOGE(TAG, "Initialization failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Initialization failed")); return; } } @@ -152,9 +148,6 @@ void AHT10Component::update() { void AHT10Component::dump_config() { ESP_LOGCONFIG(TAG, "AHT10:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } diff --git a/esphome/components/aic3204/aic3204.cpp b/esphome/components/aic3204/aic3204.cpp index 892a6c56ac0..23902035eb8 100644 --- a/esphome/components/aic3204/aic3204.cpp +++ b/esphome/components/aic3204/aic3204.cpp @@ -10,8 +10,7 @@ ESPHOME_LOG_TAG(TAG, "aic3204"); #define ERROR_CHECK(err, msg) \ if (!(err)) { \ - ESP_LOGE(TAG, msg); \ - this->mark_failed(); \ + this->mark_failed(LOG_STR(msg)); \ return; \ } @@ -108,10 +107,6 @@ void AIC3204::setup() { void AIC3204::dump_config() { ESP_LOGCONFIG(TAG, "AIC3204:"); LOG_I2C_DEVICE(this); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool AIC3204::set_mute_off() { diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp index d34061889be..9b350560ee9 100644 --- a/esphome/components/am2315c/am2315c.cpp +++ b/esphome/components/am2315c/am2315c.cpp @@ -42,21 +42,18 @@ bool AM2315C::reset_register_(uint8_t reg) { data[2] = 0; ESP_LOGD(TAG, "Reset register: 0x%02x", reg); if (this->write(data, 3) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Write failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } delay(5); if (this->read(data, 3) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Read failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } delay(10); data[0] = 0xB0 | reg; if (this->write(data, 3) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Write failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } delay(5); @@ -76,8 +73,7 @@ void AM2315C::setup() { // get status uint8_t status = 0; if (this->read(&status, 1) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Read failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -86,18 +82,9 @@ void AM2315C::setup() { // never required during testing if ((status & 0x18) != 0x18) { ESP_LOGD(TAG, "Resetting AM2315C registers"); - if (!this->reset_register_(0x1B)) { - this->mark_failed(); + // reset_register_() marks the component failed with its own reason + if (!this->reset_register_(0x1B) || !this->reset_register_(0x1C) || !this->reset_register_(0x1E)) return; - } - if (!this->reset_register_(0x1C)) { - this->mark_failed(); - return; - } - if (!this->reset_register_(0x1E)) { - this->mark_failed(); - return; - } } } @@ -168,9 +155,6 @@ void AM2315C::update() { void AM2315C::dump_config() { ESP_LOGCONFIG(TAG, "AM2315C:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } diff --git a/esphome/components/am2320/am2320.cpp b/esphome/components/am2320/am2320.cpp index 94053ab5dc8..1b3761fdac5 100644 --- a/esphome/components/am2320/am2320.cpp +++ b/esphome/components/am2320/am2320.cpp @@ -37,16 +37,13 @@ void AM2320Component::setup() { data[0] = 0; data[1] = 4; if (!this->read_data_(data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void AM2320Component::dump_config() { ESP_LOGD(TAG, "AM2320:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp index daf6bb6b338..af123d8c4f3 100644 --- a/esphome/components/apds9306/apds9306.cpp +++ b/esphome/components/apds9306/apds9306.cpp @@ -34,8 +34,7 @@ enum { // APDS9306 registers #define APDS9306_ERROR_CHECK(func, error) \ if (!(func)) { \ - ESP_LOGE(TAG, error); \ - this->mark_failed(); \ + this->mark_failed(LOG_STR(error)); \ return; \ } #define APDS9306_WARNING_CHECK(func, warning) \ @@ -48,21 +47,19 @@ enum { // APDS9306 registers ESP_LOGV(TAG, "Writing 0x%02x to 0x%02x", value, reg); \ if (!this->write_byte(reg, value)) { \ ESP_LOGE(TAG, "Failed writing 0x%02x to 0x%02x", value, reg); \ - this->mark_failed(); \ + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); \ return; \ } void APDS9306::setup() { uint8_t id; if (!this->read_byte(APDS9306_PART_ID, &id)) { // Part ID register - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (id != 0xB1 && id != 0xB3) { // 0xB1 for APDS9306 0xB3 for APDS9306-065 - this->error_code_ = WRONG_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("APDS9306 has invalid id")); return; } @@ -89,20 +86,6 @@ void APDS9306::dump_config() { LOG_SENSOR("", "APDS9306", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_ID: - ESP_LOGE(TAG, "APDS9306 has invalid id!"); - break; - default: - ESP_LOGE(TAG, "Setting up APDS9306 registers failed!"); - break; - } - } - ESP_LOGCONFIG(TAG, " Gain: %u\n" " Measurement rate: %u\n" diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h index f971290cdd5..1f0a695c4ff 100644 --- a/esphome/components/apds9306/apds9306.h +++ b/esphome/components/apds9306/apds9306.h @@ -50,12 +50,6 @@ class APDS9306 final : public sensor::Sensor, public PollingComponent, public i2 void set_ambient_light_gain(AmbientLightGain gain) { this->gain_ = gain; } protected: - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_ID, - } error_code_{NONE}; - MeasurementBitWidth bit_width_; MeasurementRate measurement_rate_; AmbientLightGain gain_; diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index ef5d43e2cba..c003e7a8bc9 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -8,7 +8,7 @@ ESPHOME_LOG_TAG(TAG, "apds9960"); #define APDS9960_ERROR_CHECK(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Setting up APDS9960 registers failed")); \ return; \ } #define APDS9960_WRITE_BYTE(reg, value) APDS9960_ERROR_CHECK(this->write_byte(reg, value)); @@ -16,14 +16,12 @@ ESPHOME_LOG_TAG(TAG, "apds9960"); void APDS9960::setup() { uint8_t id; if (!this->read_byte(0x92, &id)) { // ID register - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (id != 0xAB && id != 0x9C && id != 0xA8 && id != 0x9E) { // APDS9960 all should have one of these IDs - this->error_code_ = WRONG_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("APDS9960 has invalid id")); return; } @@ -135,20 +133,6 @@ void APDS9960::dump_config() { LOG_SENSOR(" ", "Clear channel", this->clear_sensor_); LOG_SENSOR(" ", "Proximity", this->proximity_sensor_); #endif - - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_ID: - ESP_LOGE(TAG, "APDS9960 has invalid id!"); - break; - default: - ESP_LOGE(TAG, "Setting up APDS9960 registers failed!"); - break; - } - } } #define APDS9960_WARNING_CHECK(func, warning) \ diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h index bfa64bcc745..5e84a6d3d9d 100644 --- a/esphome/components/apds9960/apds9960.h +++ b/esphome/components/apds9960/apds9960.h @@ -58,11 +58,6 @@ class APDS9960 final : public PollingComponent, public i2c::I2CDevice { uint8_t gesture_gain_; uint8_t gesture_wait_time_; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_ID, - } error_code_{NONE}; bool gesture_up_started_{false}; bool gesture_down_started_{false}; bool gesture_left_started_{false}; diff --git a/esphome/components/as5600/as5600.cpp b/esphome/components/as5600/as5600.cpp index fc768b383b0..279e4051660 100644 --- a/esphome/components/as5600/as5600.cpp +++ b/esphome/components/as5600/as5600.cpp @@ -23,7 +23,7 @@ static const uint8_t REGISTER_MAGNITUDE = 0x1B; // 16 bytes / R void AS5600Component::setup() { if (!this->read_byte(REGISTER_STATUS).has_value()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -54,7 +54,7 @@ void AS5600Component::setup() { // write config to config register if (!this->write_byte_16(REGISTER_CONF, config)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -87,10 +87,8 @@ void AS5600Component::dump_config() { ESP_LOGCONFIG(TAG, "AS5600:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Watchdog: %d\n" diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index 359636a4992..60f576f7bb9 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -14,14 +14,13 @@ void AS7341Component::setup() { this->read_byte(AS7341_ID, &id); ESP_LOGCONFIG(TAG, " Read ID: 0x%X", id); if ((id & 0xFC) != (AS7341_CHIP_ID << 2)) { - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } // Power on (enter IDLE state) if (!this->enable_power(true)) { - ESP_LOGE(TAG, " Power on failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR("Power on failed")); return; } @@ -35,9 +34,6 @@ void AS7341Component::setup() { void AS7341Component::dump_config() { ESP_LOGCONFIG(TAG, "AS7341:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Gain: %u\n" diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 5337b3918eb..30412be70f5 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -355,9 +355,6 @@ void AS734XComponent::dump_config() { #ifdef USE_AS734X_RGB LOG_TEXT_SENSOR(" ", "RGB hex", this->rgb_hex_sensor_); #endif - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void AS734XComponent::enable_led(bool enable) { diff --git a/esphome/components/atm90e26/atm90e26.cpp b/esphome/components/atm90e26/atm90e26.cpp index 8ec611357c8..d0b734ddcb5 100644 --- a/esphome/components/atm90e26/atm90e26.cpp +++ b/esphome/components/atm90e26/atm90e26.cpp @@ -52,7 +52,7 @@ void ATM90E26Component::setup() { uint16_t read = this->read16_(ATM90E26_REGISTER_LASTDATA); if (read != 0x0030) { ESP_LOGW(TAG, "Could not initialize ATM90E26 IC, check SPI settings: %d", read); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // TODO: 100 * * sqrt(2) * / (4 * gain_voltage/32768) @@ -120,21 +120,18 @@ void ATM90E26Component::setup() { ESP_LOGW(TAG, "Could not initialize ATM90E26 IC: CS1 was incorrect, expected: 0x%04X", this->read16_(ATM90E26_REGISTER_CS1)); - this->mark_failed(); + this->mark_failed(LOG_STR("Checksum mismatch")); } if (sys_status & 0x3000) { // Checksum 2 Error ESP_LOGW(TAG, "Could not initialize ATM90E26 IC: CS2 was incorrect, expected: 0x%04X", this->read16_(ATM90E26_REGISTER_CS2)); - this->mark_failed(); + this->mark_failed(LOG_STR("Checksum mismatch")); } } void ATM90E26Component::dump_config() { ESP_LOGCONFIG("", "ATM90E26:"); LOG_PIN(" CS Pin: ", this->cs_); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Voltage A", this->voltage_sensor_); LOG_SENSOR(" ", "Current A", this->current_sensor_); diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 0c4f095ab57..0cdf9918db9 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -168,7 +168,7 @@ void ATM90E32Component::setup() { this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); // enable register config access if (!this->validate_spi_read_(0x55AA, "setup()")) { ESP_LOGW(TAG, "Could not initialize ATM90E32 IC, check SPI settings"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -411,9 +411,6 @@ void ATM90E32Component::log_calibration_status_() { void ATM90E32Component::dump_config() { ESP_LOGCONFIG("", "ATM90E32:"); LOG_PIN(" CS Pin: ", this->cs_); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Voltage A", this->phase_[PHASEA].voltage_sensor_); LOG_SENSOR(" ", "Current A", this->phase_[PHASEA].current_sensor_); diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index 2d9709f4228..94ee60ea8d3 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -65,7 +65,7 @@ MTreg: void BH1750Sensor::setup() { uint8_t turn_on = BH1750_COMMAND_POWER_ON; if (this->write(&turn_on, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->state_ = IDLE; @@ -74,9 +74,6 @@ void BH1750Sensor::setup() { void BH1750Sensor::dump_config() { LOG_SENSOR("", "BH1750", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL_FOR, this->get_name().c_str()); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 5fe1460c661..b642c544c56 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -62,7 +62,8 @@ void BluedroidGattClient::loop() { auto ret = esp_ble_gattc_app_register(this->app_id); if (ret) { ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); - this->mark_failed(); + this->mark_failed( + LOG_STR("Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots")); } // Do not wait for REG_EVT; connect() rejects until it lands. this->set_idle_(); @@ -108,12 +109,7 @@ void BluedroidGattClient::ble_before_disabled_event_handler() { this->enable_loop(); } -void BluedroidGattClient::dump_config() { - ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); - if (this->is_failed()) { - ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots"); - } -} +void BluedroidGattClient::dump_config() { ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); } // ---- contract ops ---- @@ -680,7 +676,8 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga this->gattc_if_ = esp_gattc_if; } else { ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status); - this->mark_failed(); + this->mark_failed( + LOG_STR("Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots")); } break; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index c6e2703c48b..6af2fba62bb 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -76,16 +76,14 @@ void RP2GattClient::setup() { // Pre-create every pool entry so the packet handlers' allocate() calls are // always a free-list pop -- the IRQ path must never reach malloc(). if (!this->event_pool_.warm() || !this->notify_pool_.warm()) { - ESP_LOGE(TAG, "GATT event pool warm-up failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("GATT event pool warm-up failed")); return; } // Register this engine for IRQ-context event routing. if (instance_count >= ESPHOME_BLE_GATT_CLIENT_COUNT) { // Cannot happen with codegen-sized storage; refuse loudly if it ever does. - ESP_LOGE(TAG, "GATT client registry full"); - this->mark_failed(); + this->mark_failed(LOG_STR("GATT client registry full")); return; } { diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index de6b51ab1f5..f83b98af115 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -21,7 +21,7 @@ static constexpr uint32_t MAX_TIMER_DURATION_S = 255 * 60; void BM8563::setup() { if (!this->write_byte_16(CONTROL_STATUS_1_REG, 0)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -31,9 +31,6 @@ void BM8563::update() { this->read_time(); } void BM8563::dump_config() { ESP_LOGCONFIG(TAG, "BM8563:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void BM8563::start_timer(uint32_t duration_s) { diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 96bb56f345e..4b74a15c0a6 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -7,8 +7,6 @@ #include #include -#define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" - namespace esphome::bme280_base { ESPHOME_LOG_TAG(TAG, "bme280.sensor"); @@ -100,13 +98,11 @@ void BME280Component::setup() { } if (!this->read_byte(BME280_REGISTER_CHIPID, &chip_id)) { - this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != 0x60) { - this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(LOG_STR(BME280_ERROR_WRONG_CHIP_ID)); + this->mark_failed(LOG_STR("Wrong chip ID or no response")); return; } @@ -182,17 +178,6 @@ void BME280Component::setup() { } void BME280Component::dump_config() { ESP_LOGCONFIG(TAG, "BME280:"); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_CHIP_ID: - ESP_LOGE(TAG, BME280_ERROR_WRONG_CHIP_ID); - break; - case NONE: - default: - break; - } ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index 8b4906b7b77..d1a441ccebb 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -101,11 +101,6 @@ class BME280Component : public PollingComponent { sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_CHIP_ID, - } error_code_{NONE}; }; } // namespace esphome::bme280_base diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 6bacb71ff20..3bc1f3ec1e2 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -72,22 +72,24 @@ constexpr float BME680_GAS_LOOKUP_TABLE_2[16] PROGMEM = {0.0, 0.0, 0.0, 0.0, 0. } void BME680Component::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool BME680Component::setup_registers_() { uint8_t chip_id; if (!this->read_byte(BME680_REGISTER_CHIPID, &chip_id) || chip_id != 0x61) { - this->mark_failed(); - return; + return false; } // Read calibration uint8_t coeff1[25]; if (!this->read_bytes(BME680_REGISTER_COEFF1, coeff1, 25)) { - this->mark_failed(); - return; + return false; } uint8_t coeff2[16]; if (!this->read_bytes(BME680_REGISTER_COEFF2, coeff2, 16)) { - this->mark_failed(); - return; + return false; } this->calibration_.t1 = coeff2[9] << 8 | coeff2[8]; @@ -119,20 +121,17 @@ void BME680Component::setup() { uint8_t temp_var = 0; if (!this->read_byte(0x02, &temp_var)) { - this->mark_failed(); - return; + return false; } this->calibration_.res_heat_range = ((temp_var & 0x30) / 16); if (!this->read_byte(0x00, &temp_var)) { - this->mark_failed(); - return; + return false; } this->calibration_.res_heat_val = (int8_t) temp_var; if (!this->read_byte(0x04, &temp_var)) { - this->mark_failed(); - return; + return false; } this->calibration_.range_sw_err = ((int8_t) temp_var & (int8_t) 0xf0) / 16; @@ -141,41 +140,35 @@ void BME680Component::setup() { // Config register uint8_t config_register; if (!this->read_byte(BME680_REGISTER_CONFIG, &config_register)) { - this->mark_failed(); - return; + return false; } config_register &= ~0b00011100; config_register |= (this->iir_filter_ & 0b111) << 2; if (!this->write_byte(BME680_REGISTER_CONFIG, config_register)) { - this->mark_failed(); - return; + return false; } // Humidity control register uint8_t hum_control; if (!this->read_byte(BME680_REGISTER_CONTROL_HUMIDITY, &hum_control)) { - this->mark_failed(); - return; + return false; } hum_control &= ~0b00000111; hum_control |= this->humidity_oversampling_ & 0b111; if (!this->write_byte(BME680_REGISTER_CONTROL_HUMIDITY, hum_control)) { - this->mark_failed(); - return; + return false; } // Gas 1 control register uint8_t gas1_control; if (!this->read_byte(BME680_REGISTER_CONTROL_GAS1, &gas1_control)) { - this->mark_failed(); - return; + return false; } gas1_control &= ~0b00011111; gas1_control |= 1 << 4; gas1_control |= 0; // profile 0 if (!this->write_byte(BME680_REGISTER_CONTROL_GAS1, gas1_control)) { - this->mark_failed(); - return; + return false; } const bool heat_off = this->heater_temperature_ == 0 || this->heater_duration_ == 0; @@ -183,40 +176,34 @@ void BME680Component::setup() { // Gas 0 control register uint8_t gas0_control; if (!this->read_byte(BME680_REGISTER_CONTROL_GAS0, &gas0_control)) { - this->mark_failed(); - return; + return false; } gas0_control &= ~0b00001000; gas0_control |= heat_off << 3; if (!this->write_byte(BME680_REGISTER_CONTROL_GAS0, gas0_control)) { - this->mark_failed(); - return; + return false; } if (!heat_off) { // Gas Heater Temperature uint8_t temperature = this->calc_heater_resistance_(this->heater_temperature_); if (!this->write_byte(BME680_REGISTER_HEATER_HEAT0, temperature)) { - this->mark_failed(); - return; + return false; } // Gas Heater Duration uint8_t duration = this->calc_heater_duration_(this->heater_duration_); if (!this->write_byte(BME680_REGISTER_HEATER_WAIT0, duration)) { - this->mark_failed(); - return; + return false; } } + return true; } void BME680Component::dump_config() { ESP_LOGCONFIG(TAG, "BME680:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index e401d036595..64fa20f2fb1 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -101,6 +101,8 @@ class BME680Component final : public PollingComponent, public i2c::I2CDevice { void update() override; protected: + /// Configure the sensor registers; false on an I2C failure. + bool setup_registers_(); /// Calculate the heater resistance value to send to the BME680 register. uint8_t calc_heater_resistance_(uint16_t temperature); /// Calculate the heater duration value to send to the BME680 register. diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index a90d490ae18..b97baae06ae 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -117,18 +117,21 @@ const uint8_t BMI160_REGISTER_DATA_TEMP_MSB = 0x21; const float GRAVITY_EARTH = 9.80665f; void BMI160Component::internal_setup_(int stage) { + if (!this->setup_stage_(stage)) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool BMI160Component::setup_stage_(int stage) { switch (stage) { case 0: uint8_t chipid; if (!this->read_byte(BMI160_REGISTER_CHIPID, &chipid) || (chipid != 0b11010001)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Bringing accelerometer out of sleep"); if (!this->write_byte(BMI160_REGISTER_CMD, (uint8_t) Cmd::ACCL_SET_PMU_MODE | (uint8_t) AcclPmuMode::NORMAL)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Waiting for accelerometer to wake up"); // need to wait (max delay in datasheet) because we can't send commands while another is in progress @@ -139,8 +142,7 @@ void BMI160Component::internal_setup_(int stage) { case 1: ESP_LOGV(TAG, " Bringing gyroscope out of sleep"); if (!this->write_byte(BMI160_REGISTER_CMD, (uint8_t) Cmd::GYRO_SET_PMU_MODE | (uint8_t) GyroPmuMode::NORMAL)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Waiting for gyroscope to wake up"); // wait between 51 & 81ms, doing 100 to be safe @@ -152,15 +154,13 @@ void BMI160Component::internal_setup_(int stage) { uint8_t gyro_config = (uint8_t) GyroBandwidth::OSR4 | (uint8_t) GyroOuputDataRate::HZ_25; ESP_LOGV(TAG, " Output gyro_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); if (!this->write_byte(BMI160_REGISTER_GYRO_CONFIG, gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Gyro Range"); uint8_t gyro_range = (uint8_t) GyroRange::RANGE_2000_DPS; ESP_LOGV(TAG, " Output gyro_range: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_range)); if (!this->write_byte(BMI160_REGISTER_GYRO_RANGE, gyro_range)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Accel Config"); @@ -168,28 +168,24 @@ void BMI160Component::internal_setup_(int stage) { (uint8_t) AcclFilterMode::PERF | (uint8_t) AcclBandwidth::RES_AVG16 | (uint8_t) AccelOutputDataRate::HZ_25; ESP_LOGV(TAG, " Output accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); if (!this->write_byte(BMI160_REGISTER_ACCEL_CONFIG, accel_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Accel Range"); uint8_t accel_range = (uint8_t) AccelRange::RANGE_16G; ESP_LOGV(TAG, " Output accel_range: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_range)); if (!this->write_byte(BMI160_REGISTER_ACCEL_RANGE, accel_range)) { - this->mark_failed(); - return; + return false; } this->setup_complete_ = true; } + return true; } void BMI160Component::setup() { this->internal_setup_(0); } void BMI160Component::dump_config() { ESP_LOGCONFIG(TAG, "BMI160:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Acceleration X", this->accel_x_sensor_); LOG_SENSOR(" ", "Acceleration Y", this->accel_y_sensor_); diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index 8af25a09add..53704965cd9 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -31,6 +31,8 @@ class BMI160Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *gyro_z_sensor_{nullptr}; void internal_setup_(int stage); + /// Run one setup stage; false on an I2C failure. + bool setup_stage_(int stage); bool setup_complete_{false}; /** reads `len` 16-bit little-endian integers from the given i2c register */ diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index 6f14598a5f4..e3c6d83a722 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -80,28 +80,26 @@ void BMI270Component::setup() { // 1. Verify chip ID uint8_t chip_id = 0; if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { - ESP_LOGE(TAG, "Failed to read chip ID – check wiring / address"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != BMI270_CHIP_ID_VALUE) { ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE); - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); // 2. Soft-reset via CMD register (0x7E = 0xB6) if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(20); // 4. Upload the configuration blob if (!load_config_file_()) { - ESP_LOGE(TAG, "Config file upload failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Config file upload failed")); return; } ESP_LOGD(TAG, "Config blob uploaded ✓"); @@ -111,11 +109,11 @@ void BMI270Component::setup() { 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->write_byte(BMI270_REG_ACC_RANGE, (uint8_t) accel_range_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -124,18 +122,18 @@ void BMI270Component::setup() { 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->write_byte(BMI270_REG_GYR_RANGE, (uint8_t) gyro_range_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // 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, BMI270_PWR_CTRL_IMU)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(5); @@ -143,7 +141,7 @@ 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, BMI270_PWR_CONF_FIFO_SELF_WAKEUP)) { // bit1 = fifo_self_wakeup - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -272,10 +270,8 @@ bool BMI270Component::setup_magnetometer_() { void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, "BMI270 IMU:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Communication failed!"); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Accel range : %s", LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX))); diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index b989ed877b6..f9ec710a9b6 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -24,7 +24,7 @@ void BMP085Component::update() { void BMP085Component::setup() { uint8_t data[22]; if (!this->read_bytes(BMP085_REGISTER_AC1_H, data, 22)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -44,9 +44,6 @@ void BMP085Component::setup() { void BMP085Component::dump_config() { ESP_LOGCONFIG(TAG, "BMP085:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with BMP085 failed!"); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 9cb023c6884..d82f80ccd43 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -2,8 +2,6 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -#define BMP280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" - namespace esphome::bmp280_base { ESPHOME_LOG_TAG(TAG, "bmp280.sensor"); @@ -65,18 +63,15 @@ void BMP280Component::setup() { // Read the chip id twice, to work around a bug where the first read is 0. // https://community.st.com/t5/stm32-mcus-products/issue-with-reading-bmp280-chip-id-using-spi/td-p/691855 if (!this->bmp_read_byte(0xD0, &chip_id)) { - this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->bmp_read_byte(0xD0, &chip_id)) { - this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != 0x58) { - this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(LOG_STR(BMP280_ERROR_WRONG_CHIP_ID)); + this->mark_failed(LOG_STR("Wrong chip ID or no response")); return; } @@ -130,17 +125,6 @@ void BMP280Component::setup() { } void BMP280Component::dump_config() { ESP_LOGCONFIG(TAG, "BMP280:"); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_CHIP_ID: - ESP_LOGE(TAG, BMP280_ERROR_WRONG_CHIP_ID); - break; - case NONE: - default: - break; - } ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 860fff6b4b1..1c3fc9cf6cf 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -85,11 +85,6 @@ class BMP280Component : public PollingComponent { BMP280IIRFilter iir_filter_{BMP280_IIR_FILTER_OFF}; sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_CHIP_ID, - } error_code_{NONE}; }; } // namespace esphome::bmp280_base diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index b10a7a7a951..f59abc61ec3 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -43,35 +43,27 @@ static const LogString *iir_filter_to_str(IIRFilter filter) { } void BMP3XXComponent::setup() { - this->error_code_ = NONE; // Call the Device base class "initialise" function if (!reset()) { - ESP_LOGE(TAG, "Failed to reset"); - this->error_code_ = ERROR_SENSOR_RESET; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to reset")); + return; } if (!read_byte(BMP388_CHIP_ID, &this->chip_id_.reg)) { - ESP_LOGE(TAG, "Can't read chip id"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ESP_LOGCONFIG(TAG, "Chip %s Id 0x%X", LOG_STR_ARG(chip_type_to_str(this->chip_id_.reg)), this->chip_id_.reg); if (chip_id_.reg != BMP388_ID && chip_id_.reg != BMP390_ID) { - ESP_LOGE(TAG, "Unknown chip id - is this really a BMP388 or BMP390?"); - this->error_code_ = ERROR_WRONG_CHIP_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID - please check if you are really using a BMP 388 or BMP 390")); return; } // set sensor in sleep mode stop_conversion(); // Read the calibration parameters into the params structure if (!read_bytes(BMP388_TRIM_PARAMS, (uint8_t *) &compensation_params_, sizeof(compensation_params_))) { - ESP_LOGE(TAG, "Can't read calibration data"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Can't read calibration data")); return; } compensation_float_params_.param_T1 = @@ -92,9 +84,7 @@ void BMP3XXComponent::setup() { // Initialise the BMP388 IIR filter register if (!set_iir_filter(this->iir_filter_)) { - ESP_LOGE(TAG, "Failed to set IIR filter"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to set IIR filter")); return; } @@ -113,9 +103,7 @@ void BMP3XXComponent::setup() { } // Initialise the BMP388 oversampling register if (!set_oversampling_register(this->pressure_oversampling_, this->temperature_oversampling_)) { - ESP_LOGE(TAG, "Failed to set oversampling register"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to set oversampling register")); return; } } @@ -125,23 +113,6 @@ void BMP3XXComponent::dump_config() { "BMP3XX:\n" " Type: %s (0x%X)", LOG_STR_ARG(chip_type_to_str(this->chip_id_.reg)), this->chip_id_.reg); - switch (this->error_code_) { - case NONE: - break; - case ERROR_COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case ERROR_WRONG_CHIP_ID: - ESP_LOGE(TAG, "Wrong chip ID (reported id: 0x%X) - please check if you are really using a BMP 388 or BMP 390", - this->chip_id_.reg); - break; - case ERROR_SENSOR_RESET: - ESP_LOGE(TAG, "Failed to reset"); - break; - default: - ESP_LOGE(TAG, "Error code %d", (int) this->error_code_); - break; - } ESP_LOGCONFIG(TAG, " IIR Filter: %s", LOG_STR_ARG(iir_filter_to_str(this->iir_filter_))); LOG_UPDATE_INTERVAL(this); if (this->temperature_sensor_) { @@ -165,8 +136,7 @@ void BMP3XXComponent::update() { meas_time += 2.02f * oversampling_to_time(this->pressure_oversampling_) + 0.392f; meas_time += 0.234f; if (!set_mode(FORCED_MODE)) { - ESP_LOGE(TAG, "Failed start forced mode"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed start forced mode")); return; } diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.h b/esphome/components/bmp3xx_base/bmp3xx_base.h index cd70b2fb163..c3e30890411 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.h +++ b/esphome/components/bmp3xx_base/bmp3xx_base.h @@ -124,13 +124,6 @@ class BMP3XXComponent : public PollingComponent { OperationMode operation_mode_{FORCED_MODE}; sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; - enum ErrorCode { - NONE = 0, - ERROR_COMMUNICATION_FAILED, - ERROR_WRONG_CHIP_ID, - ERROR_SENSOR_STATUS, - ERROR_SENSOR_RESET, - } error_code_{NONE}; struct { // The BMP388 compensation trim parameters (coefficients) uint16_t param_T1; diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 29f67fadc51..7bc743977b2 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -38,29 +38,6 @@ static const LogString *iir_filter_to_str(IIRFilter filter) { void BMP581Component::dump_config() { ESP_LOGCONFIG(TAG, "BMP581:"); - switch (this->error_code_) { - case NONE: - break; - case ERROR_COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case ERROR_WRONG_CHIP_ID: - ESP_LOGE(TAG, "Unknown chip ID"); - break; - case ERROR_SENSOR_RESET: - ESP_LOGE(TAG, "Reset failed"); - break; - case ERROR_SENSOR_STATUS: - ESP_LOGE(TAG, "Get status failed"); - break; - case ERROR_PRIME_IIR_FAILED: - ESP_LOGE(TAG, "IIR Filter failed to prime with initial measurement"); - break; - default: - ESP_LOGE(TAG, "Error %d", (int) this->error_code_); - break; - } - LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Measurement conversion time: %ums", this->conversion_time_); @@ -96,17 +73,13 @@ void BMP581Component::setup() { * 6) Configure and prime IIR Filter(s), if enabled */ - this->error_code_ = NONE; //////////////////// // 1) Soft reboot // //////////////////// // Power-On-Reboot bit is asserted if sensor successfully reset if (!this->reset_()) { - ESP_LOGE(TAG, "Reset failed"); - - this->error_code_ = ERROR_SENSOR_RESET; - this->mark_failed(); + this->mark_failed(LOG_STR("Reset failed")); return; } @@ -119,20 +92,14 @@ void BMP581Component::setup() { // read chip id from sensor if (!this->bmp_read_byte(BMP581_CHIP_ID, &chip_id)) { - ESP_LOGE(TAG, "Read chip ID failed"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Read chip ID failed")); return; } // verify id if (chip_id != BMP581_ASIC_ID && chip_id != BMP585_ASIC_ID) { - ESP_LOGE(TAG, "Unknown chip ID"); - - this->error_code_ = ERROR_WRONG_CHIP_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("Unknown chip ID")); return; } @@ -142,30 +109,21 @@ void BMP581Component::setup() { //////////////////////////////////////////////////// if (!this->bmp_read_byte(BMP581_STATUS, &this->status_.reg)) { - ESP_LOGE(TAG, "Failed to read status register"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read status register")); return; } // verify status_nvm_rdy bit (it is asserted if boot was successful) if (!(this->status_.bit.status_nvm_rdy)) { - ESP_LOGE(TAG, "NVM not ready"); - - this->error_code_ = ERROR_SENSOR_STATUS; - this->mark_failed(); + this->mark_failed(LOG_STR("NVM not ready")); return; } // verify status_nvm_err bit (it is asserted if an error is detected) if (this->status_.bit.status_nvm_err) { - ESP_LOGE(TAG, "NVM error detected"); - - this->error_code_ = ERROR_SENSOR_STATUS; - this->mark_failed(); + this->mark_failed(LOG_STR("NVM error detected")); return; } @@ -176,10 +134,7 @@ void BMP581Component::setup() { // enable the data ready interrupt source if (!this->write_interrupt_source_settings_(true)) { - ESP_LOGE(TAG, "Failed to write interrupt source register"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write interrupt source register")); return; } @@ -198,10 +153,7 @@ void BMP581Component::setup() { // write oversampling settings if (!this->write_oversampling_settings_(this->temperature_oversampling_, this->pressure_oversampling_)) { - ESP_LOGE(TAG, "Failed to write oversampling register"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write oversampling register")); return; } @@ -218,19 +170,13 @@ void BMP581Component::setup() { if ((this->iir_temperature_level_ != IIR_FILTER_OFF) || (this->iir_pressure_level_ != IIR_FILTER_OFF)) { if (!this->write_iir_settings_(this->iir_temperature_level_, this->iir_pressure_level_)) { - ESP_LOGE(TAG, "Failed to write IIR configuration registers"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write IIR configuration registers")); return; } if (!this->prime_iir_filter_()) { - ESP_LOGE(TAG, "Failed to prime the IIR filter with an initial measurement"); - - this->error_code_ = ERROR_PRIME_IIR_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to prime the IIR filter with an initial measurement")); return; } diff --git a/esphome/components/bmp581_base/bmp581_base.h b/esphome/components/bmp581_base/bmp581_base.h index 1a73a91558c..abd7f64b4d2 100644 --- a/esphome/components/bmp581_base/bmp581_base.h +++ b/esphome/components/bmp581_base/bmp581_base.h @@ -133,15 +133,6 @@ class BMP581Component : public PollingComponent { // Sets the power mode on the BMP581 by writing to the ODR register bool write_power_mode_(OperationMode mode); - enum ErrorCode { - NONE = 0, - ERROR_COMMUNICATION_FAILED, - ERROR_WRONG_CHIP_ID, - ERROR_SENSOR_STATUS, - ERROR_SENSOR_RESET, - ERROR_PRIME_IIR_FAILED - } error_code_{NONE}; - // BMP581's interrupt source register (address 0x15) to configure which interrupts are enabled (page 54 of datasheet) union { struct { diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp index d2f7961e020..b7912b930e6 100644 --- a/esphome/components/bq27220/bq27220.cpp +++ b/esphome/components/bq27220/bq27220.cpp @@ -48,7 +48,7 @@ void BQ27220Component::setup() { } if (device_number != DEVICE_NUMBER) { ESP_LOGE(TAG, "Unexpected device number 0x%04X (expected 0x%04X)", device_number, DEVICE_NUMBER); - this->mark_failed(); + this->mark_failed(LOG_STR("Unexpected device number")); } } @@ -76,9 +76,6 @@ void BQ27220Component::update() { 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_); diff --git a/esphome/components/cap1188/cap1188.cpp b/esphome/components/cap1188/cap1188.cpp index d6d97329db4..394b3a909bd 100644 --- a/esphome/components/cap1188/cap1188.cpp +++ b/esphome/components/cap1188/cap1188.cpp @@ -37,8 +37,7 @@ void CAP1188Component::finish_setup_() { this->read_byte(CAP1188_REVISION, &this->cap1188_revision_); if ((this->cap1188_product_id_ != 0x50) || (this->cap1188_manufacture_id_ != 0x5D)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Product ID or Manufacture ID of the connected device does not match a known CAP1188")); return; } @@ -70,15 +69,6 @@ void CAP1188Component::dump_config() { this->cap1188_product_id_, this->cap1188_manufacture_id_, this->cap1188_revision_); LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); - - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, "Product ID or Manufacture ID of the connected device does not match a known CAP1188."); - break; - case NONE: - default: - break; - } } void CAP1188Component::loop() { diff --git a/esphome/components/cap1188/cap1188.h b/esphome/components/cap1188/cap1188.h index a4abb270e70..daa2c4015f6 100644 --- a/esphome/components/cap1188/cap1188.h +++ b/esphome/components/cap1188/cap1188.h @@ -59,11 +59,6 @@ class CAP1188Component final : public Component, public i2c::I2CDevice { uint8_t cap1188_product_id_{0}; uint8_t cap1188_manufacture_id_{0}; uint8_t cap1188_revision_{0}; - - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_{NONE}; }; } // namespace esphome::cap1188 diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index 126ed0049d1..1cae581194f 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -10,29 +10,28 @@ ESPHOME_LOG_TAG(TAG, "ccs811"); // based on // - https://cdn.sparkfun.com/datasheets/BreakoutBoards/CCS811_Programming_Guide.pdf -#define CHECK_TRUE(f, error_code) \ +#define CHECK_TRUE(f, message) \ if (!(f)) { \ - this->mark_failed(); \ - this->error_code_ = (error_code); \ + this->mark_failed(LOG_STR(message)); \ return; \ } -#define CHECKED_IO(f) CHECK_TRUE(f, COMMUNICATION_FAILED) +#define CHECKED_IO(f) CHECK_TRUE(f, ESP_LOG_MSG_COMM_FAIL) void CCS811Component::setup() { // page 9 programming guide - hwid is always 0x81 uint8_t hw_id; CHECKED_IO(this->read_byte(0x20, &hw_id)) - CHECK_TRUE(hw_id == 0x81, INVALID_ID) + CHECK_TRUE(hw_id == 0x81, "Sensor reported an invalid ID. Is this a CCS811?") // software reset, page 3 - allowed to fail this->write_bytes(0xFF, {0x11, 0xE5, 0x72, 0x8A}); delay(5); // page 10, APP_START - CHECK_TRUE(!this->status_has_error_(), SENSOR_REPORTED_ERROR) - CHECK_TRUE(this->status_app_is_valid_(), APP_INVALID) - CHECK_TRUE(this->write_bytes(0xF4, {}), APP_START_FAILED) + CHECK_TRUE(!this->status_has_error_(), "Sensor reported internal error") + CHECK_TRUE(this->status_app_is_valid_(), "Sensor reported invalid APP installed") + CHECK_TRUE(this->write_bytes(0xF4, {}), "Sensor reported APP start failed") // App setup, wait for it to load delay(1); @@ -160,29 +159,6 @@ void CCS811Component::dump_config() { } else { ESP_LOGCONFIG(TAG, " Baseline: NOT SET"); } - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case INVALID_ID: - ESP_LOGW(TAG, "Sensor reported an invalid ID. Is this a CCS811?"); - break; - case SENSOR_REPORTED_ERROR: - ESP_LOGW(TAG, "Sensor reported internal error"); - break; - case APP_INVALID: - ESP_LOGW(TAG, "Sensor reported invalid APP installed."); - break; - case APP_START_FAILED: - ESP_LOGW(TAG, "Sensor reported APP start failed."); - break; - case UNKNOWN: - default: - ESP_LOGW(TAG, "Unknown setup error!"); - break; - } - } } } // namespace esphome::ccs811 diff --git a/esphome/components/ccs811/ccs811.h b/esphome/components/ccs811/ccs811.h index fb83f842fd9..d7a8f22f116 100644 --- a/esphome/components/ccs811/ccs811.h +++ b/esphome/components/ccs811/ccs811.h @@ -31,15 +31,6 @@ class CCS811Component final : public PollingComponent, public i2c::I2CDevice { bool status_has_data_() { return this->read_status_().value_or(0) & (1 << 3); } void send_env_data_(); - enum ErrorCode { - UNKNOWN, - COMMUNICATION_FAILED, - INVALID_ID, - SENSOR_REPORTED_ERROR, - APP_INVALID, - APP_START_FAILED, - } error_code_{UNKNOWN}; - sensor::Sensor *co2_{nullptr}; sensor::Sensor *tvoc_{nullptr}; text_sensor::TextSensor *version_{nullptr}; diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index 28b355a4f1d..e76801ff77a 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -18,7 +18,7 @@ void CH422GComponent::setup() { // Set mode and check for errors if (!this->set_mode_(this->mode_value_) || !this->read_inputs_()) { ESP_LOGE(TAG, "CH422G not detected at 0x%02X", this->address_); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -34,9 +34,6 @@ void CH422GComponent::loop() { void CH422GComponent::dump_config() { ESP_LOGCONFIG(TAG, "CH422G:"); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void CH422GComponent::pin_mode(uint8_t pin, gpio::Flags flags) { diff --git a/esphome/components/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp index b7e9d224faf..3a57f646d4e 100644 --- a/esphome/components/ch423/ch423.cpp +++ b/esphome/components/ch423/ch423.cpp @@ -24,8 +24,7 @@ void CH423Component::setup() { success = this->read_inputs_(); } if (!success) { - ESP_LOGE(TAG, "CH423 not detected"); - this->mark_failed(); + this->mark_failed(LOG_STR("CH423 not detected")); return; } @@ -38,12 +37,7 @@ void CH423Component::loop() { this->pin_read_flags_ = 0x00; } -void CH423Component::dump_config() { - ESP_LOGCONFIG(TAG, "CH423:"); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } -} +void CH423Component::dump_config() { ESP_LOGCONFIG(TAG, "CH423:"); } void CH423Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (pin < 8) { diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index edfcd0f7309..55b7556cda5 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -27,8 +27,7 @@ uint8_t cm1106_checksum(const uint8_t *response, size_t len) { void CM1106Component::setup() { 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_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -139,9 +138,6 @@ void CM1106Component::dump_config() { " 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); - } } } // namespace esphome::cm1106 diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 4ba93af01f1..0c56c741fe6 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -67,15 +67,12 @@ void CSE7761Component::setup() { ESP_LOGD(TAG, "CSE7761 found"); this->data_.ready = true; } else { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } void CSE7761Component::dump_config() { ESP_LOGCONFIG(TAG, "CSE7761:"); - 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); From e95e56f1002d7cc26b18aa6481933ab65435df99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:39:20 -1000 Subject: [PATCH 457/651] [components] Let core report communication failures in dump_config (2/5) (#20871) --- esphome/components/dac7678/dac7678_output.cpp | 10 +-- esphome/components/dht12/dht12.cpp | 5 +- esphome/components/dps310/dps310.cpp | 36 +++++------ esphome/components/dps310/dps310.h | 2 + esphome/components/ds1307/ds1307.cpp | 5 +- esphome/components/ds3231/ds3231.cpp | 4 -- esphome/components/emc2101/emc2101.cpp | 5 +- .../components/ens160_base/ens160_base.cpp | 64 +++++-------------- esphome/components/ens160_base/ens160_base.h | 10 --- esphome/components/ens210/ens210.cpp | 20 ++---- esphome/components/ens210/ens210.h | 3 +- esphome/components/es7210/es7210.cpp | 7 +- esphome/components/es7243e/es7243e.cpp | 11 +--- esphome/components/es8156/es8156.cpp | 11 +--- esphome/components/es8311/es8311.cpp | 7 +- esphome/components/es8388/es8388.cpp | 7 +- .../camera_web_server.cpp | 8 +-- esphome/components/ezo/ezo.cpp | 3 - esphome/components/ezo_pmp/ezo_pmp.cpp | 3 - esphome/components/gdk101/gdk101.cpp | 3 - .../grove_gas_mc_v2/grove_gas_mc_v2.cpp | 22 +------ .../grove_gas_mc_v2/grove_gas_mc_v2.h | 7 -- esphome/components/hdc1080/hdc1080.cpp | 3 - esphome/components/hdc2010/hdc2010.cpp | 3 - esphome/components/hdc2080/hdc2080.cpp | 3 - esphome/components/hmc5883l/hmc5883l.cpp | 20 ++---- esphome/components/hmc5883l/hmc5883l.h | 5 -- .../honeywell_hih_i2c/honeywell_hih.cpp | 15 ++--- esphome/components/htu21d/htu21d.cpp | 5 +- esphome/components/htu31d/htu31d.cpp | 7 +- esphome/components/husb238/husb238.cpp | 3 - .../components/hydreon_rgxx/hydreon_rgxx.cpp | 5 +- esphome/components/iaqcore/iaqcore.cpp | 6 +- esphome/components/icm20689/icm20689.cpp | 9 +-- .../components/ili9xxx/ili9xxx_display.cpp | 5 +- 35 files changed, 74 insertions(+), 268 deletions(-) diff --git a/esphome/components/dac7678/dac7678_output.cpp b/esphome/components/dac7678/dac7678_output.cpp index d1a076733aa..de61964a8ff 100644 --- a/esphome/components/dac7678/dac7678_output.cpp +++ b/esphome/components/dac7678/dac7678_output.cpp @@ -23,8 +23,7 @@ void DAC7678Output::setup() { // Reset device if (!this->write_byte_16(DAC7678_REG_SOFTWARE_RESET, 0x0000)) { - ESP_LOGE(TAG, "Reset failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Reset failed")); return; } else { ESP_LOGV(TAG, "Reset succeeded"); @@ -35,8 +34,7 @@ void DAC7678Output::setup() { // Set internal reference if (this->internal_reference_) { if (!this->write_byte_16(DAC7678_REG_INTERNAL_REF_0, 1 << 4)) { - ESP_LOGE(TAG, "Set internal reference failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Set internal reference failed")); return; } else { ESP_LOGV(TAG, "Internal reference enabled"); @@ -45,9 +43,7 @@ void DAC7678Output::setup() { } void DAC7678Output::dump_config() { - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up DAC7678 failed!"); - } else { + if (!this->is_failed()) { ESP_LOGCONFIG(TAG, "DAC7678 initialised"); } } diff --git a/esphome/components/dht12/dht12.cpp b/esphome/components/dht12/dht12.cpp index e099b65d5b7..353546dba3a 100644 --- a/esphome/components/dht12/dht12.cpp +++ b/esphome/components/dht12/dht12.cpp @@ -35,16 +35,13 @@ void DHT12Component::update() { void DHT12Component::setup() { uint8_t data[5]; if (!this->read_data_(data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void DHT12Component::dump_config() { ESP_LOGD(TAG, "DHT12:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 080f101747f..6ab786e7c71 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -9,13 +9,17 @@ ESPHOME_LOG_TAG(TAG, "dps310"); static constexpr uint32_t READ_RETRY_TIMEOUT_ID = 0; void DPS310Component::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool DPS310Component::setup_registers_() { uint8_t coef_data_raw[DPS310_NUM_COEF_REGS]; auto timer = DPS310_INIT_TIMEOUT; uint8_t reg = 0; // first, reset the sensor if (!this->write_byte(DPS310_REG_RESET, DPS310_CMD_RESET)) { - this->mark_failed(); - return; + return false; } delay(10); // wait for the sensor and its coefficients to be ready @@ -25,40 +29,32 @@ void DPS310Component::setup() { } if (!(reg & DPS310_BIT_SENSOR_RDY) || !(reg & DPS310_BIT_COEF_RDY)) { // the flags were not set in time - this->mark_failed(); - return; + return false; } // read device ID if (!this->read_byte(DPS310_REG_PROD_REV_ID, &this->prod_rev_id_)) { - this->mark_failed(); - return; + return false; } // read in coefficients used to calculate the compensated pressure and temperature values if (!this->read_bytes(DPS310_REG_COEF, coef_data_raw, DPS310_NUM_COEF_REGS)) { - this->mark_failed(); - return; + return false; } // read in coefficients source register, too -- we need this a few lines down if (!this->read_byte(DPS310_REG_TMP_COEF_SRC, ®)) { - this->mark_failed(); - return; + return false; } // set up operational stuff if (!this->write_byte(DPS310_REG_PRS_CFG, DPS310_VAL_PRS_CFG)) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(DPS310_REG_TMP_CFG, DPS310_VAL_TMP_CFG | (reg & DPS310_BIT_TMP_COEF_SRC))) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(DPS310_REG_CFG, DPS310_VAL_REG_CFG)) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(DPS310_REG_MEAS_CFG, 0x07)) { // enable background mode - this->mark_failed(); - return; + return false; } this->c0_ = // we only ever use c0/2, so just divide by 2 here to save time later @@ -82,6 +78,7 @@ void DPS310Component::setup() { this->c20_ = int16_t(((uint16_t) coef_data_raw[12] << 8) | (uint16_t) coef_data_raw[13]); this->c21_ = int16_t(((uint16_t) coef_data_raw[14] << 8) | (uint16_t) coef_data_raw[15]); this->c30_ = int16_t(((uint16_t) coef_data_raw[16] << 8) | (uint16_t) coef_data_raw[17]); + return true; } void DPS310Component::dump_config() { @@ -91,9 +88,6 @@ void DPS310Component::dump_config() { " Revision ID: %u", this->prod_rev_id_ & 0x0F, (this->prod_rev_id_ >> 4) & 0x0F); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index 4dd23985d75..d761940ca99 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -45,6 +45,8 @@ class DPS310Component final : public PollingComponent, public i2c::I2CDevice { void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } protected: + /// Configure the sensor; false on a failure. + bool setup_registers_(); void read_(); void read_pressure_(); void read_temperature_(); diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 4ddef04f413..485e0f4b5c1 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -10,7 +10,7 @@ ESPHOME_LOG_TAG(TAG, "ds1307"); void DS1307Component::setup() { if (!this->read_rtc_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } @@ -19,9 +19,6 @@ void DS1307Component::update() { this->read_time(); } void DS1307Component::dump_config() { ESP_LOGCONFIG(TAG, "DS1307:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } RealTimeClock::dump_config(); } diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp index 4f7396fcbdd..72cae0e3832 100644 --- a/esphome/components/ds3231/ds3231.cpp +++ b/esphome/components/ds3231/ds3231.cpp @@ -29,10 +29,6 @@ void DS3231Component::setup() { 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) { diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index 6a21b4e7b8e..c6ae5169e4c 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -60,7 +60,7 @@ void Emc2101Component::setup() { uint8_t chip_id = reg(EMC2101_REGISTER_WHOAMI).get(); if ((chip_id != EMC2101_CHIP_ID) && (chip_id != EMC2101_ALT_CHIP_ID)) { ESP_LOGE(TAG, "Wrong chip ID %02X", chip_id); - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } @@ -90,9 +90,6 @@ void Emc2101Component::setup() { void Emc2101Component::dump_config() { ESP_LOGCONFIG(TAG, "Emc2101 component:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Mode: %s", this->dac_mode_ ? LOG_STR_LITERAL("DAC") : LOG_STR_LITERAL("PWM")); if (this->dac_mode_) { ESP_LOGCONFIG(TAG, " DAC Conversion Rate: %X", this->dac_conversion_rate_); diff --git a/esphome/components/ens160_base/ens160_base.cpp b/esphome/components/ens160_base/ens160_base.cpp index b06182e59c0..b18a44f7523 100644 --- a/esphome/components/ens160_base/ens160_base.cpp +++ b/esphome/components/ens160_base/ens160_base.cpp @@ -62,20 +62,17 @@ void ENS160Component::setup() { // check part_id uint16_t part_id; if (!this->read_bytes(ENS160_REG_PART_ID, reinterpret_cast(&part_id), 2)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (part_id != ENS160_PART_ID) { - this->error_code_ = INVALID_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("Sensor reported an invalid ID. Is this a ENS160?")); return; } // set mode to reset if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_RESET)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(ENS160_BOOTING); @@ -83,51 +80,44 @@ void ENS160Component::setup() { // check status uint8_t status_value; if (!this->read_byte(ENS160_REG_DATA_STATUS, &status_value)) { - this->error_code_ = READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->validity_flag_ = static_cast((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); if (this->validity_flag_ == INVALID_OUTPUT) { - this->error_code_ = VALIDITY_INVALID; - this->mark_failed(); + this->mark_failed(LOG_STR("Invalid Device Status - No valid output")); return; } // set mode to idle if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_IDLE)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(ENS160_BOOTING); // clear command if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_NOP)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_CLRGPR)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(ENS160_BOOTING); // read firmware version if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_GET_APPVER)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(ENS160_BOOTING); uint8_t version_data[3]; if (!this->read_bytes(ENS160_REG_GPR_READ_4, version_data, 3)) { - this->error_code_ = READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->firmware_ver_major_ = version_data[0]; @@ -136,22 +126,19 @@ void ENS160Component::setup() { // set mode to standard if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_STD)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // read opmode and check standard mode is achieved before finishing Setup uint8_t op_mode; if (!this->read_byte(ENS160_REG_OPMODE, &op_mode)) { - this->error_code_ = READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (op_mode != ENS160_OPMODE_STD) { - this->error_code_ = STD_OPMODE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Device failed to achieve Standard Operating Mode")); return; } } @@ -291,29 +278,8 @@ void ENS160Component::send_env_data_() { void ENS160Component::dump_config() { ESP_LOGCONFIG(TAG, "ENS160:"); - - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case READ_FAILED: - ESP_LOGE(TAG, "Error reading from register"); - break; - case WRITE_FAILED: - ESP_LOGE(TAG, "Error writing to register"); - break; - case INVALID_ID: - ESP_LOGE(TAG, "Sensor reported an invalid ID. Is this a ENS160?"); - break; - case VALIDITY_INVALID: - ESP_LOGE(TAG, "Invalid Device Status - No valid output"); - break; - case STD_OPMODE_FAILED: - ESP_LOGE(TAG, "Device failed to achieve Standard Operating Mode"); - break; - case NONE: - ESP_LOGD(TAG, "Setup successful"); - break; + if (!this->is_failed()) { + ESP_LOGD(TAG, "Setup successful"); } ESP_LOGI(TAG, "Firmware Version: %d.%d.%d", this->firmware_ver_major_, this->firmware_ver_minor_, this->firmware_ver_build_); diff --git a/esphome/components/ens160_base/ens160_base.h b/esphome/components/ens160_base/ens160_base.h index f42272684ed..d844dccb02c 100644 --- a/esphome/components/ens160_base/ens160_base.h +++ b/esphome/components/ens160_base/ens160_base.h @@ -21,16 +21,6 @@ class ENS160Component : public PollingComponent, public sensor::Sensor { protected: void send_env_data_(); - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - INVALID_ID, - VALIDITY_INVALID, - READ_FAILED, - WRITE_FAILED, - STD_OPMODE_FAILED, - } error_code_{NONE}; - enum ValidityFlag { NORMAL_OPERATION = 0, WARMING_UP, diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index 5815fc90382..2251e0a574b 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -55,8 +55,6 @@ static const LogString *ens210_status_to_human(int status) { return LOG_STR("Invalid data"); case ENS210Component::ENS210_STATUS_OK: return LOG_STR("Status OK"); - case ENS210Component::ENS210_WRONG_CHIP_ID: - return LOG_STR("ENS210 has wrong chip ID! Is it a ENS210?"); default: return LOG_STR("Unknown"); } @@ -93,8 +91,7 @@ void ENS210Component::setup() { // Reset if (!this->write_byte(ENS210_REGISTER_SYS_CTRL, 0x80)) { this->write_byte(ENS210_REGISTER_SYS_CTRL, 0x80); - this->error_code_ = ENS210_STATUS_I2C_ERROR; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // Wait to boot after reset @@ -103,24 +100,24 @@ void ENS210Component::setup() { if (!set_low_power_(false)) { // Try to go back to default mode (low power enabled) set_low_power_(true); - this->error_code_ = ENS210_STATUS_I2C_ERROR; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // Read the PART_ID if (!this->read_bytes(ENS210_REGISTER_PART_ID, data, 2)) { // Try to go back to default mode (low power enabled) set_low_power_(true); - this->error_code_ = ENS210_STATUS_I2C_ERROR; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // Pack bytes into partid part_id = data[1] * 256U + data[0] * 1U; // Check expected part id of the ENS210 if (part_id != ENS210_PART_ID) { - this->error_code_ = ENS210_WRONG_CHIP_ID; - this->mark_failed(); + // Try to go back to default mode (low power enabled) + set_low_power_(true); + this->mark_failed(LOG_STR("Wrong chip ID")); + return; } // Set default power mode (low power enabled) set_low_power_(true); @@ -129,9 +126,6 @@ void ENS210Component::setup() { void ENS210Component::dump_config() { ESP_LOGCONFIG(TAG, "ENS210:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "%s", LOG_STR_ARG(ens210_status_to_human(this->error_code_))); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); diff --git a/esphome/components/ens210/ens210.h b/esphome/components/ens210/ens210.h index fca20133b82..803d3b9cedf 100644 --- a/esphome/components/ens210/ens210.h +++ b/esphome/components/ens210/ens210.h @@ -22,8 +22,7 @@ class ENS210Component final : public PollingComponent, public i2c::I2CDevice { // not yet finished) ENS210_STATUS_CRC_ERROR, // The value was read, but the CRC over the payload (valid and data) does not match ENS210_STATUS_I2C_ERROR, // There was an I2C communication error - ENS210_WRONG_CHIP_ID // The read PART_ID is not the expected part id of the ENS210 - } error_code_{ENS210_STATUS_OK}; + }; protected: bool set_low_power_(bool enable); diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index fbabf7460f2..9eb24653dfc 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -13,7 +13,7 @@ static const size_t MCLK_DIV_FRE = 256; // Mark the component as failed; use only in setup #define ES7210_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } @@ -31,11 +31,6 @@ void ES7210::dump_config() { " 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"); - return; - } } void ES7210::setup() { diff --git a/esphome/components/es7243e/es7243e.cpp b/esphome/components/es7243e/es7243e.cpp index 1bfa6933ddc..c43ed38cd57 100644 --- a/esphome/components/es7243e/es7243e.cpp +++ b/esphome/components/es7243e/es7243e.cpp @@ -13,7 +13,7 @@ ESPHOME_LOG_TAG(TAG, "es7243e"); // Mark the component as failed; use only in setup #define ES7243E_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } @@ -23,14 +23,7 @@ ESPHOME_LOG_TAG(TAG, "es7243e"); return false; \ } -void ES7243E::dump_config() { - ESP_LOGCONFIG(TAG, "ES7243E audio ADC:"); - - if (this->is_failed()) { - ESP_LOGE(TAG, " Failed to initialize"); - return; - } -} +void ES7243E::dump_config() { ESP_LOGCONFIG(TAG, "ES7243E audio ADC:"); } void ES7243E::setup() { ES7243E_ERROR_FAILED(this->write_byte(ES7243E_CLOCK_MGR_REG01, 0x3A)); diff --git a/esphome/components/es8156/es8156.cpp b/esphome/components/es8156/es8156.cpp index 467402e704c..4b854a1bc35 100644 --- a/esphome/components/es8156/es8156.cpp +++ b/esphome/components/es8156/es8156.cpp @@ -11,7 +11,7 @@ ESPHOME_LOG_TAG(TAG, "es8156"); // Mark the component as failed; use only in setup #define ES8156_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } @@ -74,14 +74,7 @@ void ES8156::setup() { ES8156_ERROR_FAILED(this->write_byte(ES8156_REG25_ANALOG_SYS5, 0x20)); } -void ES8156::dump_config() { - ESP_LOGCONFIG(TAG, "ES8156 Audio Codec:"); - - if (this->is_failed()) { - ESP_LOGCONFIG(TAG, " Failed to initialize"); - return; - } -} +void ES8156::dump_config() { ESP_LOGCONFIG(TAG, "ES8156 Audio Codec:"); } bool ES8156::set_volume(float volume) { volume = clamp(volume, 0.0f, 1.0f); diff --git a/esphome/components/es8311/es8311.cpp b/esphome/components/es8311/es8311.cpp index 96b644ee780..67229d908ec 100644 --- a/esphome/components/es8311/es8311.cpp +++ b/esphome/components/es8311/es8311.cpp @@ -11,7 +11,7 @@ ESPHOME_LOG_TAG(TAG, "es8311"); // Mark the component as failed; use only in setup #define ES8311_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } // Return false; use outside of setup @@ -56,11 +56,6 @@ void ES8311::dump_config() { " DAC Bits per Sample: %" PRIu8 "\n" " Sample Rate: %" PRIu32, YESNO(this->use_mclk_), YESNO(this->use_mic_), this->resolution_out_, this->sample_frequency_); - - if (this->is_failed()) { - ESP_LOGCONFIG(TAG, " Failed to initialize!"); - return; - } } bool ES8311::set_volume(float volume) { diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index 56ee1ac4a59..d7aedd9d9e5 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -11,7 +11,7 @@ ESPHOME_LOG_TAG(TAG, "es8388"); // Mark the component as failed; use only in setup #define ES8388_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } @@ -154,11 +154,6 @@ void ES8388::dump_config() { LOG_SELECT(" ", "DacOutputSelect", this->dac_output_select_); LOG_SELECT(" ", "ADCInputMicSelect", this->adc_input_mic_select_); #endif - - if (this->is_failed()) { - ESP_LOGCONFIG(TAG, " Failed to initialize"); - return; - } } bool ES8388::set_volume(float volume) { diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index 36c1cdfa45b..a248b901204 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -42,7 +42,7 @@ CameraWebServer::~CameraWebServer() {} void CameraWebServer::setup() { if (!camera::Camera::instance() || camera::Camera::instance()->is_failed()) { - this->mark_failed(); + this->mark_failed(LOG_STR("Camera not available")); return; } @@ -56,7 +56,7 @@ void CameraWebServer::setup() { config.lru_purge_enable = true; if (httpd_start(&this->httpd_, &config) != ESP_OK) { - mark_failed(); + this->mark_failed(LOG_STR("Failed to start the web server")); return; } @@ -97,10 +97,6 @@ void CameraWebServer::dump_config() { } else { ESP_LOGCONFIG(TAG, " Mode: snapshot"); } - - if (this->is_failed()) { - ESP_LOGE(TAG, " Setup Failed"); - } } float CameraWebServer::get_setup_priority() const { return setup_priority::LATE; } diff --git a/esphome/components/ezo/ezo.cpp b/esphome/components/ezo/ezo.cpp index d18e6e67cb1..c9a5087bf5c 100644 --- a/esphome/components/ezo/ezo.cpp +++ b/esphome/components/ezo/ezo.cpp @@ -13,9 +13,6 @@ static const char *const EZO_CALIBRATION_TYPE_STRINGS[] = {"LOW", "MID", "HIGH"} void EZOSensor::dump_config() { LOG_SENSOR("", "EZO", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/ezo_pmp/ezo_pmp.cpp b/esphome/components/ezo_pmp/ezo_pmp.cpp index 8db57670aea..393ba66fdf0 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.cpp +++ b/esphome/components/ezo_pmp/ezo_pmp.cpp @@ -39,9 +39,6 @@ static const std::string DOSING_MODE_CONTINUOUS = "Continuous"; void EzoPMP::dump_config() { LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 3c7e8d21613..a84d5ba06fb 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -82,9 +82,6 @@ bool GDK101Component::try_reset_() { void GDK101Component::dump_config() { ESP_LOGCONFIG(TAG, "GDK101:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } #ifdef USE_SENSOR LOG_SENSOR(" ", "Average Radaition Dose per 1 minute", this->rad_1m_sensor_); LOG_SENSOR(" ", "Average Radaition Dose per 10 minutes", this->rad_10m_sensor_); diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp index b01dd119e63..1acf8076af4 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp @@ -23,7 +23,6 @@ bool GroveGasMultichannelV2Component::read_sensor_(uint8_t address, sensor::Sens uint32_t value = 0; if (!this->read_bytes(address, (uint8_t *) &value, 4)) { ESP_LOGW(TAG, "Reading Grove Gas Sensor data failed!"); - this->error_code_ = COMMUNICATION_FAILED; this->status_set_warning(); return false; } @@ -34,8 +33,7 @@ bool GroveGasMultichannelV2Component::read_sensor_(uint8_t address, sensor::Sens void GroveGasMultichannelV2Component::setup() { // Before reading sensor values, must preheat sensor if (!(this->write_bytes(GROVE_GAS_MC_V2_HEAT_ON, {}))) { - this->mark_failed(); - this->error_code_ = APP_START_FAILED; + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } @@ -61,24 +59,6 @@ void GroveGasMultichannelV2Component::dump_config() { LOG_SENSOR(" ", "Ethanol", this->ethanol_sensor_); LOG_SENSOR(" ", "Carbon Monoxide", this->carbon_monoxide_sensor_); LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_); - - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case APP_INVALID: - ESP_LOGW(TAG, "Sensor reported invalid APP installed"); - break; - case APP_START_FAILED: - ESP_LOGW(TAG, "Sensor reported APP start failed"); - break; - case UNKNOWN: - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } } } // namespace esphome::grove_gas_mc_v2 diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h index 545b6df97a9..8aafdea8e7d 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h @@ -22,13 +22,6 @@ class GroveGasMultichannelV2Component final : public PollingComponent, public i2 void dump_config() override; protected: - enum ErrorCode { - UNKNOWN, - COMMUNICATION_FAILED, - APP_INVALID, - APP_START_FAILED, - } error_code_{UNKNOWN}; - bool read_sensor_(uint8_t address, sensor::Sensor *sensor); }; diff --git a/esphome/components/hdc1080/hdc1080.cpp b/esphome/components/hdc1080/hdc1080.cpp index be0de1bae6e..c61feb52d57 100644 --- a/esphome/components/hdc1080/hdc1080.cpp +++ b/esphome/components/hdc1080/hdc1080.cpp @@ -24,9 +24,6 @@ void HDC1080Component::setup() { void HDC1080Component::dump_config() { ESP_LOGCONFIG(TAG, "HDC1080:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); LOG_SENSOR(" ", "Humidity", this->humidity_); diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index 825d7d3da96..471afe515a1 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -42,9 +42,6 @@ void HDC2010Component::setup() { void HDC2010Component::dump_config() { ESP_LOGCONFIG(TAG, "HDC2010:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index 8210733b4cb..beb84f30746 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -33,9 +33,6 @@ void HDC2080Component::dump_config() { LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void HDC2080Component::update() { diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index c747e7968ed..d1abe8c1fdb 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -27,8 +27,7 @@ void HMC5883LComponent::setup() { if (!this->read_byte(HMC5883L_REGISTER_IDENTIFICATION_A, &id[0]) || !this->read_byte(HMC5883L_REGISTER_IDENTIFICATION_B, &id[1]) || !this->read_byte(HMC5883L_REGISTER_IDENTIFICATION_C, &id[2])) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -37,8 +36,7 @@ void HMC5883LComponent::setup() { } if (id[0] != 0x48 || id[1] != 0x34 || id[2] != 0x33) { - this->error_code_ = ID_REGISTERS; - this->mark_failed(); + this->mark_failed(LOG_STR("The ID registers don't match - Is this really an HMC5883L?")); return; } @@ -47,16 +45,14 @@ void HMC5883LComponent::setup() { config_a |= this->datarate_ << 2; config_a |= 0b0 << 0; // Measurement Mode: Normal(high impedance on load) if (!this->write_byte(HMC5883L_REGISTER_CONFIG_A, config_a)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t config_b = 0; config_b |= this->range_ << 5; if (!this->write_byte(HMC5883L_REGISTER_CONFIG_B, config_b)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -64,19 +60,13 @@ void HMC5883LComponent::setup() { // Continuous Measurement Mode mode |= 0b00; if (!this->write_byte(HMC5883L_REGISTER_MODE, mode)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void HMC5883LComponent::dump_config() { ESP_LOGCONFIG(TAG, "HMC5883L:"); LOG_I2C_DEVICE(this); - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } else if (this->error_code_ == ID_REGISTERS) { - ESP_LOGE(TAG, "The ID registers don't match - Is this really an HMC5883L?"); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "X Axis", this->x_sensor_); diff --git a/esphome/components/hmc5883l/hmc5883l.h b/esphome/components/hmc5883l/hmc5883l.h index d23eb1a0f4a..6e5303d8988 100644 --- a/esphome/components/hmc5883l/hmc5883l.h +++ b/esphome/components/hmc5883l/hmc5883l.h @@ -56,11 +56,6 @@ class HMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *y_sensor_{nullptr}; sensor::Sensor *z_sensor_{nullptr}; sensor::Sensor *heading_sensor_{nullptr}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - ID_REGISTERS, - } error_code_{NONE}; HighFrequencyLoopRequester high_freq_; }; diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp index 352d3f7f01f..91f3089c729 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp @@ -18,8 +18,7 @@ void HoneywellHIComponent::read_sensor_data_() { uint8_t data[4]; if (this->read(data, sizeof(data)) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -38,8 +37,7 @@ void HoneywellHIComponent::read_sensor_data_() { void HoneywellHIComponent::start_measurement_() { if (this->write(REQUEST_CMD, sizeof(REQUEST_CMD)) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -50,8 +48,7 @@ bool HoneywellHIComponent::is_measurement_ready_() { uint8_t data[1]; if (this->read(data, sizeof(data)) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } @@ -60,9 +57,8 @@ bool HoneywellHIComponent::is_measurement_ready_() { } void HoneywellHIComponent::measurement_timeout_() { - ESP_LOGE(TAG, "Honeywell HIH Timeout!"); this->measurement_running_ = false; - this->mark_failed(); + this->mark_failed(LOG_STR("Measurement timeout")); } void HoneywellHIComponent::update() { @@ -84,9 +80,6 @@ void HoneywellHIComponent::loop() { void HoneywellHIComponent::dump_config() { ESP_LOGD(TAG, "Honeywell HIH:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/htu21d/htu21d.cpp b/esphome/components/htu21d/htu21d.cpp index 468a7f8e6a2..4fe502f2a5c 100644 --- a/esphome/components/htu21d/htu21d.cpp +++ b/esphome/components/htu21d/htu21d.cpp @@ -18,7 +18,7 @@ static const uint8_t HTU21D_REG_HTRE_BIT = 0x02; /**< Control Register He void HTU21DComponent::setup() { if (!this->write_bytes(HTU21D_REGISTER_RESET, nullptr, 0)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -28,9 +28,6 @@ void HTU21DComponent::setup() { void HTU21DComponent::dump_config() { ESP_LOGCONFIG(TAG, "HTU21D:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); LOG_SENSOR(" ", "Humidity", this->humidity_); diff --git a/esphome/components/htu31d/htu31d.cpp b/esphome/components/htu31d/htu31d.cpp index 86c9876b2b6..5426ca50845 100644 --- a/esphome/components/htu31d/htu31d.cpp +++ b/esphome/components/htu31d/htu31d.cpp @@ -49,12 +49,12 @@ static const uint8_t HTU31D_DIAGNOSTICS = 0x08; */ void HTU31DComponent::setup() { if (!this->reset_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->read_serial_num_() == 0) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -131,9 +131,6 @@ void HTU31DComponent::update() { void HTU31DComponent::dump_config() { ESP_LOGCONFIG(TAG, "HTU31D:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); LOG_SENSOR(" ", "Humidity", this->humidity_); diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp index 8ca841a7e43..d0f9973b2a4 100644 --- a/esphome/components/husb238/husb238.cpp +++ b/esphome/components/husb238/husb238.cpp @@ -39,9 +39,6 @@ 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_); diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 36a7b1b3511..a2f8fc50862 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -15,9 +15,6 @@ static const char *const IGNORE_STRINGS[] = {HYDREON_RGXX_IGNORE_LIST(, HYDREON_ void HydreonRGxxComponent::dump_config() { ESP_LOGCONFIG(TAG, "hydreon_rgxx:"); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with hydreon_rgxx failed!"); - } if (model_ == RG9) { ESP_LOGCONFIG(TAG, " Model: RG9\n" @@ -154,7 +151,7 @@ void HydreonRGxxComponent::schedule_reboot_() { sensor->publish_state(NAN); } } - this->mark_failed(); + this->mark_failed(LOG_STR("Sensor did not boot")); } }); } diff --git a/esphome/components/iaqcore/iaqcore.cpp b/esphome/components/iaqcore/iaqcore.cpp index 48935c24681..73b6252cece 100644 --- a/esphome/components/iaqcore/iaqcore.cpp +++ b/esphome/components/iaqcore/iaqcore.cpp @@ -27,8 +27,7 @@ struct SensorData { void IAQCore::setup() { if (this->write(nullptr, 0) != i2c::ERROR_OK) { - ESP_LOGD(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -89,9 +88,6 @@ void IAQCore::dump_config() { ESP_LOGCONFIG(TAG, "AMS iAQ Core:"); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "CO2", this->co2_); LOG_SENSOR(" ", "TVOC", this->tvoc_); } diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp index 7331a3fe4f6..3bca35dc254 100644 --- a/esphome/components/icm20689/icm20689.cpp +++ b/esphome/components/icm20689/icm20689.cpp @@ -38,8 +38,7 @@ void ICM20689Component::setup() { this->spi_setup(); if (!this->spi_is_ready()) { - ESP_LOGE(TAG, "SPI device is not ready"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("SPI device is not ready")); return; } @@ -61,7 +60,7 @@ void ICM20689Component::setup() { if (who_am_i != ICM20689_WHO_AM_I_VALUE) { ESP_LOGE(TAG, "Unexpected WHO_AM_I 0x%02X (expected 0x%02X) -- check wiring/CS", who_am_i, ICM20689_WHO_AM_I_VALUE); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } @@ -77,10 +76,8 @@ void ICM20689Component::dump_config() { ESP_LOGCONFIG(TAG, "ICM20689 IMU:"); LOG_PIN(" CS Pin: ", this->cs_); - if (this->is_failed()) { - ESP_LOGE(TAG, " " ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Accel range: ±%dg", 2 << static_cast(this->accel_range_)); ESP_LOGCONFIG(TAG, " Gyro range: ±%d°/s", 250 << static_cast(this->gyro_range_)); diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index 870d4e7098d..d2e1bc127e5 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -70,7 +70,7 @@ void ILI9XXXDisplay::alloc_buffer_() { this->init_internal_(this->get_buffer_length_()); } if (this->buffer_ == nullptr) { - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to allocate the display buffer")); } } @@ -122,9 +122,6 @@ void ILI9XXXDisplay::dump_config() { this->color_order_ == display::COLOR_ORDER_BGR ? LOG_STR_LITERAL("BGR") : LOG_STR_LITERAL("RGB"), YESNO(this->swap_xy_), YESNO(this->mirror_x_), YESNO(this->mirror_y_), YESNO(this->pre_invertcolors_)); - if (this->is_failed()) { - ESP_LOGCONFIG(TAG, " => Failed to init Memory: YES!"); - } LOG_UPDATE_INTERVAL(this); } From e1a6c615b1493a0832bff233e3f69375d240d26f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:40:06 -1000 Subject: [PATCH 458/651] [core] Document passing the failure reason to mark_failed in AGENTS.md (#20879) --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e00888b5f77..5581a255dba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,15 @@ file does, and it is the authority when they disagree. The most useful starting stays 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()`. + * **`dump_config()` already prints the failure reason:** for a failed component, core prints + ` is marked FAILED: ` right after the component's `dump_config()`, using the reason + passed to `mark_failed()`. So `dump_config()` never checks `is_failed()` to log a failure, and no + `error_code_` member exists only to pick that message. Pass the reason where setup fails instead: + `this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))` for a failed bus transfer, a short specific reason + otherwise (`LOG_STR("Wrong chip ID")`). Core also logs it at that moment, so no `ESP_LOGE` repeats it + before `mark_failed()`; keep a separate line only when it prints a value read at run time, such as the + chip ID that did not match. Only the first reason is kept, so do not call `status_set_error()` before + `mark_failed()` and do not mark the component failed again after a helper already did. * **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 From 49caeae7807aef32292ab1e509200293d8c295be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:40:54 -1000 Subject: [PATCH 459/651] [components] Let core report communication failures in dump_config (3/5) (#20872) --- esphome/components/ina219/ina219.cpp | 10 +++--- esphome/components/ina226/ina226.cpp | 10 +++--- esphome/components/ina260/ina260.cpp | 24 ++------------ esphome/components/ina260/ina260.h | 7 ----- .../components/ina2xx_base/ina2xx_base.cpp | 9 ++---- esphome/components/ina3221/ina3221.cpp | 11 +++---- .../components/kamstrup_kmp/kamstrup_kmp.cpp | 3 -- esphome/components/lcd_menu/lcd_menu.cpp | 5 +-- .../lcd_pcf8574/pcf8574_display.cpp | 5 +-- esphome/components/lis2dw12/lis2dw12.cpp | 11 ++++--- esphome/components/lis3dh/lis3dh.cpp | 12 +++---- esphome/components/lm75b/lm75b.cpp | 3 -- esphome/components/lp5562/lp5562_output.cpp | 7 ++--- esphome/components/ltr501/ltr501.cpp | 6 +--- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 6 +--- esphome/components/max17043/max17043.cpp | 10 ++---- esphome/components/max17048/max17048.cpp | 3 -- esphome/components/max44009/max44009.cpp | 5 +-- esphome/components/mcp4461/mcp4461.cpp | 9 ++---- esphome/components/mcp4725/mcp4725.cpp | 11 ++----- esphome/components/mcp4725/mcp4725.h | 1 - esphome/components/mcp4728/mcp4728.cpp | 5 +-- esphome/components/mcp9600/mcp9600.cpp | 18 ++--------- esphome/components/mcp9600/mcp9600.h | 6 ---- esphome/components/mcp9808/mcp9808.cpp | 19 +++++++----- .../components/mlx90393/sensor_mlx90393.cpp | 14 +++------ esphome/components/mmc5603/mmc5603.cpp | 26 ++++------------ esphome/components/mmc5603/mmc5603.h | 5 --- esphome/components/mpl3115a2/mpl3115a2.cpp | 19 ++---------- esphome/components/mpl3115a2/mpl3115a2.h | 5 --- esphome/components/mpr121/mpr121.cpp | 11 +------ esphome/components/mpr121/mpr121.h | 4 --- esphome/components/mpu6050/mpu6050.cpp | 31 +++++++------------ esphome/components/mpu6050/mpu6050.h | 2 ++ esphome/components/mpu6886/mpu6886.cpp | 31 +++++++------------ esphome/components/mpu6886/mpu6886.h | 2 ++ esphome/components/ms5611/ms5611.cpp | 7 ++--- esphome/components/msa3xx/msa3xx.cpp | 16 +++++----- 38 files changed, 107 insertions(+), 282 deletions(-) diff --git a/esphome/components/ina219/ina219.cpp b/esphome/components/ina219/ina219.cpp index a675099b4dc..48c9a06f504 100644 --- a/esphome/components/ina219/ina219.cpp +++ b/esphome/components/ina219/ina219.cpp @@ -36,7 +36,7 @@ void INA219Component::setup() { // Config Register // 0bx000000000000000 << 15 RESET Bit (1 -> trigger reset) if (!this->write_byte_16(INA219_REGISTER_CONFIG, 0x8000)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -101,7 +101,7 @@ void INA219Component::setup() { config |= shunt_gain << 11; ESP_LOGCONFIG(TAG, " Using %dV-Range Shunt Gain=%dmV", bus_32v_range ? 32 : 16, 40 << shunt_gain); if (!this->write_byte_16(INA219_REGISTER_CONFIG, config)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -122,7 +122,7 @@ void INA219Component::setup() { auto calibration = uint32_t(0.04096f / (0.000001f * lsb * this->shunt_resistance_ohm_)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); if (!this->write_byte_16(INA219_REGISTER_CALIBRATION, calibration)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -138,10 +138,8 @@ void INA219Component::dump_config() { ESP_LOGCONFIG(TAG, "INA219:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Bus Voltage", this->bus_voltage_sensor_); diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index 97b3caf949b..987ce25d60b 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -40,7 +40,7 @@ void INA226Component::setup() { config.reset = 1; if (!this->write_byte_16(INA226_REGISTER_CONFIG, config.raw)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(1); @@ -61,7 +61,7 @@ void INA226Component::setup() { config.mode = 0b111; if (!this->write_byte_16(INA226_REGISTER_CONFIG, config.raw)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -75,7 +75,7 @@ void INA226Component::setup() { ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); if (!this->write_byte_16(INA226_REGISTER_CALIBRATION, calibration)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -84,10 +84,8 @@ void INA226Component::dump_config() { ESP_LOGCONFIG(TAG, "INA226:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/ina260/ina260.cpp b/esphome/components/ina260/ina260.cpp index 82a4e4fba14..0600e088390 100644 --- a/esphome/components/ina260/ina260.cpp +++ b/esphome/components/ina260/ina260.cpp @@ -36,8 +36,7 @@ static const uint8_t INA260_REGISTER_DEVICE_ID = 0xFF; void INA260Component::setup() { // Reset device on setup if (!this->write_byte_16(INA260_REGISTER_CONFIG, 0x8000)) { - this->error_code_ = DEVICE_RESET_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Device reset failed - Is the device connected?")); return; } @@ -47,14 +46,12 @@ void INA260Component::setup() { this->read_byte_16(INA260_REGISTER_DEVICE_ID, &this->device_id_); if (this->manufacture_id_ != (uint16_t) 0x5449 || this->device_id_ != (uint16_t) 0x2270) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Connected device does not match a known INA260 sensor")); return; } if (!this->write_byte_16(INA260_REGISTER_CONFIG, (uint16_t) 0b0000001100000111)) { - this->error_code_ = FAILED_TO_UPDATE_CONFIGURATION; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to update device configuration")); return; } } @@ -70,21 +67,6 @@ void INA260Component::dump_config() { LOG_SENSOR(" ", "Bus Voltage", this->bus_voltage_sensor_); LOG_SENSOR(" ", "Current", this->current_sensor_); LOG_SENSOR(" ", "Power", this->power_sensor_); - - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, "Connected device does not match a known INA260 sensor"); - break; - case DEVICE_RESET_FAILED: - ESP_LOGE(TAG, "Device reset failed - Is the device connected?"); - break; - case FAILED_TO_UPDATE_CONFIGURATION: - ESP_LOGE(TAG, "Failed to update device configuration"); - break; - case NONE: - default: - break; - } } void INA260Component::update() { diff --git a/esphome/components/ina260/ina260.h b/esphome/components/ina260/ina260.h index bbcb7a7acb2..162a4447e0f 100644 --- a/esphome/components/ina260/ina260.h +++ b/esphome/components/ina260/ina260.h @@ -23,13 +23,6 @@ class INA260Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *bus_voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; - - enum ErrorCode { - NONE, - COMMUNICATION_FAILED, - DEVICE_RESET_FAILED, - FAILED_TO_UPDATE_CONFIGURATION, - } error_code_{NONE}; }; } // namespace esphome::ina260 diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 58e0598b06c..4c36870b680 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -50,15 +50,13 @@ static bool check_model_and_device_match(INAModel model, uint16_t dev_id) { void INA2XX::setup() { if (!this->reset_config_()) { - ESP_LOGE(TAG, "Reset failed, check connection"); - this->mark_failed(); + this->mark_failed(LOG_STR("Reset failed, check connection")); return; } delay(2); if (!this->check_device_model_()) { - ESP_LOGE(TAG, "Device not supported or model selected improperly in yaml file"); - this->mark_failed(); + this->mark_failed(LOG_STR("Device not supported or model selected improperly in yaml file")); return; } delay(1); @@ -197,9 +195,6 @@ void INA2XX::dump_config() { ESP_LOGE(TAG, " Device model mismatch. Found device with ID = %x. Please check your configuration.", this->dev_id_); } - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Shunt resistance = %f Ohm\n" diff --git a/esphome/components/ina3221/ina3221.cpp b/esphome/components/ina3221/ina3221.cpp index e66e2456767..455b4c0c7d2 100644 --- a/esphome/components/ina3221/ina3221.cpp +++ b/esphome/components/ina3221/ina3221.cpp @@ -40,13 +40,13 @@ static constexpr uint32_t READ_TIMEOUT_ID = 0; void INA3221Component::setup() { if (!this->write_byte_16(INA3221_REGISTER_CONFIG, INA3221_CONFIG_RESET)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(1); if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -57,7 +57,7 @@ void INA3221Component::setup() { !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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -67,7 +67,7 @@ void INA3221Component::setup() { // 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } #endif @@ -76,9 +76,6 @@ void INA3221Component::setup() { void INA3221Component::dump_config() { ESP_LOGCONFIG(TAG, "INA3221:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - 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) { diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 9713c72b613..fcf648c31f3 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -9,9 +9,6 @@ ESPHOME_LOG_TAG(TAG, "kamstrup_kmp"); void KamstrupKMPComponent::dump_config() { ESP_LOGCONFIG(TAG, "kamstrup_kmp:"); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Heat Energy", this->heat_energy_sensor_); diff --git a/esphome/components/lcd_menu/lcd_menu.cpp b/esphome/components/lcd_menu/lcd_menu.cpp index 7ced6e67cec..b500461809b 100644 --- a/esphome/components/lcd_menu/lcd_menu.cpp +++ b/esphome/components/lcd_menu/lcd_menu.cpp @@ -8,7 +8,7 @@ ESPHOME_LOG_TAG(TAG, "lcd_menu"); void LCDCharacterMenuComponent::setup() { if (this->display_->is_failed()) { - this->mark_failed(); + this->mark_failed(LOG_STR("The connected display failed, the menu is disabled")); return; } @@ -24,9 +24,6 @@ void LCDCharacterMenuComponent::dump_config() { " Mark characters: %02x, %02x, %02x, %02x", this->columns_, this->rows_, this->mark_selected_, this->mark_editing_, this->mark_submenu_, this->mark_back_); - if (this->is_failed()) { - ESP_LOGE(TAG, "The connected display failed, the menu is disabled!"); - } } void LCDCharacterMenuComponent::draw_item(const display_menu_base::MenuItem *item, uint8_t row, bool selected) { diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.cpp b/esphome/components/lcd_pcf8574/pcf8574_display.cpp index fc7f5ff2f50..2e2196be4ad 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.cpp +++ b/esphome/components/lcd_pcf8574/pcf8574_display.cpp @@ -12,7 +12,7 @@ static const uint8_t LCD_DISPLAY_BACKLIGHT_OFF = 0x00; void PCF8574LCDDisplay::setup() { this->backlight_value_ = LCD_DISPLAY_BACKLIGHT_ON; if (!this->write_bytes(this->backlight_value_, nullptr, 0)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -25,9 +25,6 @@ void PCF8574LCDDisplay::dump_config() { this->columns_, this->rows_); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void PCF8574LCDDisplay::write_n_bits(uint8_t value, uint8_t n) { if (n == 4) { diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp index 38065aa7859..21e1a07676f 100644 --- a/esphome/components/lis2dw12/lis2dw12.cpp +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -83,9 +83,13 @@ static const LogString *filter_bandwidth_to_string(FilterBandwidth bw) { 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) { + if (!this->read_byte(static_cast(RegisterMap::WHO_AM_I), &who_am_i)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (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(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } @@ -171,9 +175,6 @@ bool LIS2DW12Component::configure_registers_() { 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" diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp index 22ca7ae56b2..f12c00852cd 100644 --- a/esphome/components/lis3dh/lis3dh.cpp +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -29,19 +29,17 @@ void LIS3DHComponent::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(); + this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); 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(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { - ESP_LOGE(TAG, "Failed to configure sensor"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to configure sensor")); return; } } @@ -119,10 +117,8 @@ bool LIS3DHComponent::setup_interrupt_() { void LIS3DHComponent::dump_config() { ESP_LOGCONFIG(TAG, "LIS3DH:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Range: ±%ug\n" diff --git a/esphome/components/lm75b/lm75b.cpp b/esphome/components/lm75b/lm75b.cpp index 56e69faa62f..388f5dfd93a 100644 --- a/esphome/components/lm75b/lm75b.cpp +++ b/esphome/components/lm75b/lm75b.cpp @@ -9,9 +9,6 @@ ESPHOME_LOG_TAG(TAG, "lm75b"); void LM75BComponent::dump_config() { ESP_LOGCONFIG(TAG, "LM75B:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up LM75B failed!"); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); } diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp index 356ef2c0669..b55e8b297d2 100644 --- a/esphome/components/lp5562/lp5562_output.cpp +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -33,7 +33,7 @@ void LP5562Output::setup() { // 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(1); @@ -41,16 +41,13 @@ void LP5562Output::setup() { // 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } 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) { diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index 91fca7fa9ba..a3f082051cc 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -115,10 +115,6 @@ void LTRAlsPs501Component::dump_config() { LOG_SENSOR(" ", "CH1 Infrared counts", this->infrared_counts_sensor_); LOG_SENSOR(" ", "CH0 Visible+IR counts", this->full_spectrum_counts_sensor_); LOG_SENSOR(" ", "Actual gain", this->actual_gain_sensor_); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void LTRAlsPs501Component::update() { @@ -151,7 +147,7 @@ void LTRAlsPs501Component::loop() { err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { ESP_LOGW(TAG, "i2c connection failed"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } this->configure_reset_(); if (this->is_als_()) { diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index 2d3b3755ad5..743bd68529d 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -109,10 +109,6 @@ void LTRAlsPsComponent::dump_config() { LOG_SENSOR(" ", "Proximity counts", this->proximity_counts_sensor_); } LOG_UPDATE_INTERVAL(this); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void LTRAlsPsComponent::update() { @@ -142,7 +138,7 @@ void LTRAlsPsComponent::loop() { err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { ESP_LOGV(TAG, "i2c connection failed"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } this->configure_reset_(); if (this->is_als_()) { diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index a15658512b2..c64875e67c9 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -55,16 +55,13 @@ void MAX17043Component::setup() { ESP_LOGV(TAG, "MAX17043 CONFIG register reads 0x%X", config_reg); if (config_reg != MAX17043_CONFIG_POWER_UP_DEFAULT) { - ESP_LOGE(TAG, "Device does not appear to be a MAX17043"); - this->status_set_error(LOG_STR("unrecognised")); - this->mark_failed(); + this->mark_failed(LOG_STR("Device does not appear to be a MAX17043")); return; } // need to write back to config register to reset the sleep bit if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT)) { - this->status_set_error(LOG_STR("sleep reset failed")); - this->mark_failed(); + this->mark_failed(LOG_STR("sleep reset failed")); return; } } @@ -72,9 +69,6 @@ void MAX17043Component::setup() { void MAX17043Component::dump_config() { ESP_LOGCONFIG(TAG, "MAX17043:"); 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->voltage_sensor_); LOG_SENSOR(" ", "Battery Level", this->battery_remaining_sensor_); diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp index 67f9dbaac3d..1caf8ab308b 100644 --- a/esphome/components/max17048/max17048.cpp +++ b/esphome/components/max17048/max17048.cpp @@ -65,9 +65,6 @@ void MAX17048Component::update() { 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_); diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index b616b9032ee..10df08bbeb4 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -39,15 +39,12 @@ void MAX44009Sensor::setup() { } } if (!state_ok) - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } void MAX44009Sensor::dump_config() { ESP_LOGCONFIG(TAG, "MAX44009:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void MAX44009Sensor::update() { diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index b6e8c247021..800ec631e75 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -12,7 +12,7 @@ void Mcp4461Component::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; - this->mark_failed(); + this->mark_failed(Mcp4461Component::get_message_string(this->error_code_)); return; } // save WP/WL status @@ -96,9 +96,6 @@ void Mcp4461Component::update_write_protection_status_() { void Mcp4461Component::dump_config() { ESP_LOGCONFIG(TAG, "mcp4461:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "%s", LOG_STR_ARG(this->get_message_string(this->error_code_))); - } // log wiper status for (uint8_t i = 0; i < 8; ++i) { // terminals only valid for volatile wipers 0-3 - enable/disable is terminal hw @@ -218,7 +215,7 @@ uint8_t Mcp4461Component::get_status_register_() { uint16_t buf; if (!this->read_16_(reg, &buf)) { this->error_code_ = MCP4461_STATUS_REGISTER_ERROR; - this->mark_failed(); + this->mark_failed(Mcp4461Component::get_message_string(this->error_code_)); return 0; } uint8_t msb = buf >> 8; @@ -227,7 +224,7 @@ uint8_t Mcp4461Component::get_status_register_() { // D8, D7 and R1 bits are hardlocked to 1 -> a status msb bit 0 (bit 9 of status register) of 0 or lsb bit 1/7 = 0 // indicate device/communication issues, therefore mark component failed this->error_code_ = MCP4461_STATUS_REGISTER_INVALID; - this->mark_failed(); + this->mark_failed(Mcp4461Component::get_message_string(this->error_code_)); return 0; } this->status_clear_warning(); diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index faa377b8de9..f25765f4a3a 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -8,19 +8,12 @@ ESPHOME_LOG_TAG(TAG, "mcp4725"); void MCP4725::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } -void MCP4725::dump_config() { - LOG_I2C_DEVICE(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } -} +void MCP4725::dump_config() { LOG_I2C_DEVICE(this); } // https://learn.sparkfun.com/tutorials/mcp4725-digital-to-analog-converter-hookup-guide?_ga=2.176055202.1402343014.1607953301-893095255.1606753886 void MCP4725::write_state(float state) { diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index a0838dc33e6..9c4b95db3f0 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -16,7 +16,6 @@ class MCP4725 final : public Component, public output::FloatOutput, public i2c:: void write_state(float state) override; protected: - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::mcp4725 diff --git a/esphome/components/mcp4728/mcp4728.cpp b/esphome/components/mcp4728/mcp4728.cpp index 7259a0a89ec..48336af339d 100644 --- a/esphome/components/mcp4728/mcp4728.cpp +++ b/esphome/components/mcp4728/mcp4728.cpp @@ -10,7 +10,7 @@ ESPHOME_LOG_TAG(TAG, "mcp4728"); void MCP4728Component::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -18,9 +18,6 @@ void MCP4728Component::setup() { void MCP4728Component::dump_config() { ESP_LOGCONFIG(TAG, "MCP4728:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void MCP4728Component::loop() { diff --git a/esphome/components/mcp9600/mcp9600.cpp b/esphome/components/mcp9600/mcp9600.cpp index 70e7cb176d4..d99cf63b471 100644 --- a/esphome/components/mcp9600/mcp9600.cpp +++ b/esphome/components/mcp9600/mcp9600.cpp @@ -33,8 +33,7 @@ void MCP9600Component::setup() { // Allows both MCP9600's and MCP9601's to be connected. if (this->device_id_ != (uint8_t) 0x40 && this->device_id_ != (uint8_t) 0x41) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Connected device does not match a known MCP9600 or MCP9601 sensor")); return; } @@ -55,8 +54,7 @@ void MCP9600Component::setup() { success &= this->write_byte_16(MCP9600_REGISTER_ALERT4_LIMIT, 0x0000); if (!success) { - this->error_code_ = FAILED_TO_UPDATE_CONFIGURATION; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to update device configuration")); return; } } @@ -70,18 +68,6 @@ void MCP9600Component::dump_config() { LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Hot Junction Temperature", this->hot_junction_sensor_); LOG_SENSOR(" ", "Cold Junction Temperature", this->cold_junction_sensor_); - - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, "Connected device does not match a known MCP9600 or MCP901 sensor"); - break; - case FAILED_TO_UPDATE_CONFIGURATION: - ESP_LOGE(TAG, "Failed to update device configuration"); - break; - case NONE: - default: - break; - } } void MCP9600Component::update() { diff --git a/esphome/components/mcp9600/mcp9600.h b/esphome/components/mcp9600/mcp9600.h index 523c9ace2f7..88dfd046ceb 100644 --- a/esphome/components/mcp9600/mcp9600.h +++ b/esphome/components/mcp9600/mcp9600.h @@ -36,12 +36,6 @@ class MCP9600Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *cold_junction_sensor_{nullptr}; MCP9600ThermocoupleType thermocouple_type_{MCP9600_THERMOCOUPLE_TYPE_K}; - - enum ErrorCode { - NONE, - COMMUNICATION_FAILED, - FAILED_TO_UPDATE_CONFIGURATION, - } error_code_{NONE}; }; } // namespace esphome::mcp9600 diff --git a/esphome/components/mcp9808/mcp9808.cpp b/esphome/components/mcp9808/mcp9808.cpp index 47df37f4563..309651970ca 100644 --- a/esphome/components/mcp9808/mcp9808.cpp +++ b/esphome/components/mcp9808/mcp9808.cpp @@ -18,24 +18,29 @@ ESPHOME_LOG_TAG(TAG, "mcp9808"); void MCP9808Sensor::setup() { uint16_t manu = 0; - if (!this->read_byte_16(MCP9808_REG_MANUF_ID, &manu) || manu != MCP9808_MANUF_ID) { - this->mark_failed(); + if (!this->read_byte_16(MCP9808_REG_MANUF_ID, &manu)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (manu != MCP9808_MANUF_ID) { ESP_LOGE(TAG, "Incorrect manufacturer ID (%X) for '%s'", manu, this->name_.c_str()); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } uint16_t dev_id = 0; - if (!this->read_byte_16(MCP9808_REG_DEVICE_ID, &dev_id) || dev_id != MCP9808_DEV_ID) { - this->mark_failed(); + if (!this->read_byte_16(MCP9808_REG_DEVICE_ID, &dev_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (dev_id != MCP9808_DEV_ID) { ESP_LOGE(TAG, "Incorrect device ID (%X) for '%s'", dev_id, this->name_.c_str()); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } } void MCP9808Sensor::dump_config() { ESP_LOGCONFIG(TAG, "%s:", this->name_.c_str()); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL_FOR, this->name_.c_str()); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); } diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index e47b7d8c30b..7aa01a976d9 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -112,7 +112,7 @@ void MLX90393Cls::setup() { this->mlx_.begin_with_hal(this, 0, 0); if (!this->apply_all_settings_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } // start verify settings process @@ -123,10 +123,8 @@ void MLX90393Cls::dump_config() { ESP_LOGCONFIG(TAG, "MLX90393:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "X Axis", this->x_sensor_); @@ -250,16 +248,12 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { if (!is_setting_ok) { if (this->mlx_.checkStatus(this->mlx_.reset()) != MLX90393::STATUS_OK) { - ESP_LOGE(TAG, "failed to reset device"); - this->status_set_error(); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to reset device")); return; } if (!this->apply_all_settings_()) { - ESP_LOGE(TAG, "failed to re-apply settings"); - this->status_set_error(); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to re-apply settings")); } else { ESP_LOGI(TAG, "reset and re-apply settings completed"); } diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index b7d16b30edd..fa5ba5985d7 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -34,35 +34,27 @@ static const uint8_t MMC5603_ODR_REG = 0x1A; void MMC5603Component::setup() { uint8_t id = 0; if (!this->read_byte(MMC56X3_PRODUCT_ID, &id)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (id != 0 && id != MMC56X3_CHIP_ID) { // ID is not reported correctly by all chips, 0 on some chips - ESP_LOGCONFIG(TAG, "Chip Wrong"); - this->error_code_ = ID_REGISTERS; - this->mark_failed(); + this->mark_failed(LOG_STR("The ID registers don't match - Is this really an MMC5603?")); return; } if (!this->write_byte(MMC56X3_CTRL1_REG, 0x80)) { // turn on set bit - ESP_LOGCONFIG(TAG, "Control 1 Failed for set bit"); - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Control 1 failed for set bit")); return; } if (!this->write_byte(MMC56X3_CTRL0_REG, 0x08)) { // turn on set bit - ESP_LOGCONFIG(TAG, "Control 0 Failed for set bit"); - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Control 0 failed for set bit")); return; } if (!this->write_byte(MMC56X3_CTRL0_REG, 0x10)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -70,19 +62,13 @@ void MMC5603Component::setup() { ctrl_2 &= ~0x10; // turn off cmm_en bit if (!this->write_byte(MMC56X3_CTRL2_REG, ctrl_2)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void MMC5603Component::dump_config() { ESP_LOGCONFIG(TAG, "MMC5603:"); LOG_I2C_DEVICE(this); - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } else if (this->error_code_ == ID_REGISTERS) { - ESP_LOGE(TAG, "The ID registers don't match - Is this really an MMC5603?"); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Auto set/reset: %s", ONOFF(this->auto_set_reset_)); diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index d291e6d2728..9187e019fb3 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -32,11 +32,6 @@ class MMC5603Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *z_sensor_{nullptr}; sensor::Sensor *heading_sensor_{nullptr}; bool auto_set_reset_{true}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - ID_REGISTERS, - } error_code_{NONE}; }; } // namespace esphome::mmc5603 diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index db892716258..4a206b30dfe 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -10,13 +10,11 @@ ESPHOME_LOG_TAG(TAG, "mpl3115a2"); void MPL3115A2Component::setup() { uint8_t whoami = 0xFF; if (!this->read_byte(MPL3115A2_WHOAMI, &whoami)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (whoami != 0xC4) { - this->error_code_ = WRONG_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("MPL3115A2 has invalid id")); return; } @@ -32,19 +30,6 @@ void MPL3115A2Component::setup() { void MPL3115A2Component::dump_config() { ESP_LOGCONFIG(TAG, "MPL3115A2:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_ID: - ESP_LOGE(TAG, "MPL3115A2 has invalid id"); - break; - default: - ESP_LOGE(TAG, "Setting up MPL3115A2 registers failed!"); - break; - } - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); LOG_SENSOR(" ", "Pressure", this->pressure_); diff --git a/esphome/components/mpl3115a2/mpl3115a2.h b/esphome/components/mpl3115a2/mpl3115a2.h index a6163673cbf..a8af1ff0fea 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.h +++ b/esphome/components/mpl3115a2/mpl3115a2.h @@ -94,11 +94,6 @@ class MPL3115A2Component final : public PollingComponent, public i2c::I2CDevice sensor::Sensor *temperature_{nullptr}; sensor::Sensor *altitude_{nullptr}; sensor::Sensor *pressure_{nullptr}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_ID, - } error_code_{NONE}; }; } // namespace esphome::mpl3115a2 diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 26cdcae6138..3d9cded59ff 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -15,8 +15,7 @@ void MPR121Component::setup() { this->write_byte(MPR121_SOFTRESET, 0x63); this->set_timeout(100, [this]() { if (!this->write_byte(MPR121_ECR, 0x0)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // set touch sensitivity for all 12 channels @@ -70,14 +69,6 @@ void MPR121Component::set_release_debounce(uint8_t debounce) { void MPR121Component::dump_config() { ESP_LOGCONFIG(TAG, "MPR121:"); LOG_I2C_DEVICE(this); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case NONE: - default: - break; - } } void MPR121Component::loop() { uint16_t val = 0; diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 64c4b291b3d..4d945ded872 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -84,10 +84,6 @@ class MPR121Component final : public Component, public i2c::I2CDevice { uint8_t touch_threshold_{}; uint8_t release_threshold_{}; uint8_t max_touch_channel_{3}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_{NONE}; bool flush_gpio_(); diff --git a/esphome/components/mpu6050/mpu6050.cpp b/esphome/components/mpu6050/mpu6050.cpp index cf393fd4d10..364843ebdce 100644 --- a/esphome/components/mpu6050/mpu6050.cpp +++ b/esphome/components/mpu6050/mpu6050.cpp @@ -20,19 +20,22 @@ const uint8_t MPU6050_BIT_TEMPERATURE_DISABLED = 3; const float GRAVITY_EARTH = 9.80665f; void MPU6050Component::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool MPU6050Component::setup_registers_() { uint8_t who_am_i; if (!this->read_byte(MPU6050_REGISTER_WHO_AM_I, &who_am_i) || (who_am_i != 0x68 && who_am_i != 0x70 && who_am_i != 0x98)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Power Management"); // Setup power management uint8_t power_management; if (!this->read_byte(MPU6050_REGISTER_POWER_MANAGEMENT_1, &power_management)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input power_management: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(power_management)); // Set clock source - X-Gyro @@ -44,48 +47,38 @@ void MPU6050Component::setup() { power_management &= ~(1 << MPU6050_BIT_TEMPERATURE_DISABLED); ESP_LOGV(TAG, " Output power_management: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(power_management)); if (!this->write_byte(MPU6050_REGISTER_POWER_MANAGEMENT_1, power_management)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Gyro Config"); // Set scale - 2000DPS uint8_t gyro_config; if (!this->read_byte(MPU6050_REGISTER_GYRO_CONFIG, &gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input gyro_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); gyro_config &= 0b11100111; gyro_config |= MPU6050_SCALE_2000_DPS << 3; ESP_LOGV(TAG, " Output gyro_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); if (!this->write_byte(MPU6050_REGISTER_GYRO_CONFIG, gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Accel Config"); // Set range - 2G uint8_t accel_config; if (!this->read_byte(MPU6050_REGISTER_ACCEL_CONFIG, &accel_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); accel_config &= 0b11100111; accel_config |= (MPU6050_RANGE_2G << 3); ESP_LOGV(TAG, " Output accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); - if (!this->write_byte(MPU6050_REGISTER_ACCEL_CONFIG, accel_config)) { - this->mark_failed(); - return; - } + return this->write_byte(MPU6050_REGISTER_ACCEL_CONFIG, accel_config); } void MPU6050Component::dump_config() { ESP_LOGCONFIG(TAG, "MPU6050:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Acceleration X", this->accel_x_sensor_); LOG_SENSOR(" ", "Acceleration Y", this->accel_y_sensor_); diff --git a/esphome/components/mpu6050/mpu6050.h b/esphome/components/mpu6050/mpu6050.h index 4410bf01645..2a346c1f4d9 100644 --- a/esphome/components/mpu6050/mpu6050.h +++ b/esphome/components/mpu6050/mpu6050.h @@ -22,6 +22,8 @@ class MPU6050Component final : public PollingComponent, public i2c::I2CDevice { void set_gyro_z_sensor(sensor::Sensor *gyro_z_sensor) { gyro_z_sensor_ = gyro_z_sensor; } protected: + /// Configure the sensor; false on an I2C failure. + bool setup_registers_(); sensor::Sensor *accel_x_sensor_{nullptr}; sensor::Sensor *accel_y_sensor_{nullptr}; sensor::Sensor *accel_z_sensor_{nullptr}; diff --git a/esphome/components/mpu6886/mpu6886.cpp b/esphome/components/mpu6886/mpu6886.cpp index 514ff0dd0e7..15fbd3c9c16 100644 --- a/esphome/components/mpu6886/mpu6886.cpp +++ b/esphome/components/mpu6886/mpu6886.cpp @@ -25,18 +25,21 @@ const float TEMPERATURE_SENSITIVITY = 326.8; const float TEMPERATURE_OFFSET = 25.0; void MPU6886Component::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool MPU6886Component::setup_registers_() { uint8_t who_am_i; if (!this->read_byte(MPU6886_REGISTER_WHO_AM_I, &who_am_i) || who_am_i != MPU6886_WHO_AM_I_IDENTIFIER) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Power Management"); // Setup power management uint8_t power_management; if (!this->read_byte(MPU6886_REGISTER_POWER_MANAGEMENT_1, &power_management)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input power_management: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(power_management)); // Set clock source - X-Gyro @@ -48,49 +51,39 @@ void MPU6886Component::setup() { power_management &= ~(1 << MPU6886_BIT_TEMPERATURE_DISABLED); ESP_LOGV(TAG, " Output power_management: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(power_management)); if (!this->write_byte(MPU6886_REGISTER_POWER_MANAGEMENT_1, power_management)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Gyroscope Config"); // Set scale - 2000DPS uint8_t gyro_config; if (!this->read_byte(MPU6886_REGISTER_GYRO_CONFIG, &gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input gyroscope_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); gyro_config &= 0b11100111; gyro_config |= MPU6886_SCALE_2000_DPS << 3; ESP_LOGV(TAG, " Output gyro_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); if (!this->write_byte(MPU6886_REGISTER_GYRO_CONFIG, gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Accelerometer Config"); // Set range - 2G uint8_t accel_config; if (!this->read_byte(MPU6886_REGISTER_ACCEL_CONFIG, &accel_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input accelerometer_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); accel_config &= 0b11100111; accel_config |= (MPU6886_RANGE_2G << 3); ESP_LOGV(TAG, " Output accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); - if (!this->write_byte(MPU6886_REGISTER_ACCEL_CONFIG, accel_config)) { - this->mark_failed(); - return; - } + return this->write_byte(MPU6886_REGISTER_ACCEL_CONFIG, accel_config); } void MPU6886Component::dump_config() { ESP_LOGCONFIG(TAG, "MPU6886:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Acceleration X", this->accel_x_sensor_); LOG_SENSOR(" ", "Acceleration Y", this->accel_y_sensor_); diff --git a/esphome/components/mpu6886/mpu6886.h b/esphome/components/mpu6886/mpu6886.h index b795d5f690f..a7c81e17593 100644 --- a/esphome/components/mpu6886/mpu6886.h +++ b/esphome/components/mpu6886/mpu6886.h @@ -22,6 +22,8 @@ class MPU6886Component final : public PollingComponent, public i2c::I2CDevice { void set_gyro_z_sensor(sensor::Sensor *gyro_z_sensor) { gyro_z_sensor_ = gyro_z_sensor; } protected: + /// Configure the sensor; false on an I2C failure. + bool setup_registers_(); sensor::Sensor *accel_x_sensor_{nullptr}; sensor::Sensor *accel_y_sensor_{nullptr}; sensor::Sensor *accel_z_sensor_{nullptr}; diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index 8f66d704fe6..e3e5194c415 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -18,13 +18,13 @@ static const uint8_t MS5611_CMD_READ_PROM = 0xA2; void MS5611Component::setup() { if (!this->write_bytes(MS5611_CMD_RESET, nullptr, 0)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->set_timeout(100, [this]() { for (uint8_t offset = 0; offset < 6; offset++) { if (!this->read_byte_16(MS5611_CMD_READ_PROM + (offset * 2), &this->prom_[offset])) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -33,9 +33,6 @@ void MS5611Component::setup() { void MS5611Component::dump_config() { ESP_LOGCONFIG(TAG, "MS5611:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index 78f2b75c9ce..19a4b067bc7 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -118,9 +118,13 @@ const char *orientation_z_to_string(bool orientation) { return orientation ? "Do void MSA3xxComponent::setup() { uint8_t part_id{0xff}; - if (!this->read_byte(static_cast(RegisterMap::PART_ID), &part_id) || (part_id != MSA_3XX_PART_ID)) { - ESP_LOGE(TAG, "Part ID is wrong or missing. Got 0x%02X", part_id); - this->mark_failed(); + if (!this->read_byte(static_cast(RegisterMap::PART_ID), &part_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (part_id != MSA_3XX_PART_ID) { + ESP_LOGE(TAG, "Part ID is wrong. Got 0x%02X", part_id); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } @@ -137,8 +141,7 @@ void MSA3xxComponent::setup() { this->device_params_.accel_data_width = 12; this->device_params_.scale_factor_exp = static_cast(this->range_) - 10; } else { - ESP_LOGE(TAG, "Unknown model"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unknown model")); return; } @@ -155,9 +158,6 @@ void MSA3xxComponent::setup() { void MSA3xxComponent::dump_config() { ESP_LOGCONFIG(TAG, "MSA3xx:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Model: %s\n" " Power Mode: %s\n" From 1ff6b29b0d9fbc55e3d094ff86db079a8de4661a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:41:52 -1000 Subject: [PATCH 460/651] [components] Let core report communication failures in dump_config (4/5) (#20873) --- esphome/components/nau7802/nau7802.cpp | 10 ++-- esphome/components/opt3001/opt3001.cpp | 3 -- esphome/components/pca6416a/pca6416a.cpp | 5 +- esphome/components/pca9505/pca9505.cpp | 5 +- esphome/components/pca9554/pca9554.cpp | 5 +- esphome/components/pca9685/pca9685_output.cpp | 33 ++++++------ esphome/components/pca9685/pca9685_output.h | 2 + esphome/components/pcf85063/pcf85063.cpp | 5 +- esphome/components/pcf8563/pcf8563.cpp | 5 +- esphome/components/pcf8574/pcf8574.cpp | 5 +- .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 7 +-- esphome/components/pm2005/pm2005.cpp | 6 +-- esphome/components/pmwcs3/pmwcs3.cpp | 5 +- esphome/components/pn532/pn532.cpp | 28 +++-------- esphome/components/pn532/pn532.h | 6 --- esphome/components/pylontech/pylontech.cpp | 3 -- esphome/components/qmc5883l/qmc5883l.cpp | 15 ++---- esphome/components/qmc5883l/qmc5883l.h | 4 -- esphome/components/qmi8658/qmi8658.cpp | 12 ++--- esphome/components/qwiic_pir/qwiic_pir.cpp | 30 ++--------- esphome/components/qwiic_pir/qwiic_pir.h | 6 --- esphome/components/rc522/rc522.cpp | 11 +--- esphome/components/rc522/rc522.h | 5 -- esphome/components/rx8025t/rx8025t.cpp | 5 +- esphome/components/scd30/scd30.cpp | 40 +++------------ esphome/components/scd30/scd30.h | 7 --- esphome/components/scd4x/scd4x.cpp | 41 +++------------ esphome/components/scd4x/scd4x.h | 9 ---- esphome/components/sdp3x/sdp3x.cpp | 15 ++---- esphome/components/sen0321/sen0321.cpp | 5 +- esphome/components/sen21231/sen21231.cpp | 4 -- esphome/components/sen5x/sen5x.cpp | 50 +++---------------- esphome/components/sen5x/sen5x.h | 10 ---- esphome/components/sfa30/sfa30.cpp | 21 +------- esphome/components/sfa30/sfa30.h | 4 -- esphome/components/sfa40/sfa40.cpp | 21 +------- esphome/components/sfa40/sfa40.h | 6 --- esphome/components/sgp30/sgp30.cpp | 34 +++---------- esphome/components/sgp30/sgp30.h | 8 --- esphome/components/sgp4x/sgp4x.cpp | 32 +++--------- esphome/components/sgp4x/sgp4x.h | 10 ---- esphome/components/sht4x/sht4x.cpp | 5 +- 42 files changed, 91 insertions(+), 452 deletions(-) diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index ad1a569e0fa..cc24353782e 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -56,8 +56,7 @@ void NAU7802Sensor::setup() { uint8_t rev; if (this->read_register(DEVICE_REV | READ_BIT, &rev, 1)) { - ESP_LOGE(TAG, "Failed I2C read during setup()"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed I2C read during setup()")); return; } ESP_LOGI(TAG, "Setting up NAU7802 Rev %d", rev); @@ -72,8 +71,7 @@ void NAU7802Sensor::setup() { delay(1); if (!(pu_ctrl.get() & PU_CTRL_POWERUP_READY)) { - ESP_LOGE(TAG, "Failed to reset sensor during setup()"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to reset sensor during setup()")); return; } @@ -124,10 +122,8 @@ void NAU7802Sensor::dump_config() { LOG_SENSOR("", "NAU7802", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL_FOR, this->get_name().c_str()); + if (this->is_failed()) return; - } // Note these may differ from the values on the device if calbration has been run ESP_LOGCONFIG(TAG, " Offset Calibration: %" PRId32 "\n" diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index 0101368cebd..374fd707561 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -87,9 +87,6 @@ void OPT3001Sensor::read_lx_(const std::function &f) { void OPT3001Sensor::dump_config() { LOG_SENSOR("", "OPT3001", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index f805a8da545..0825d6eac98 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -27,7 +27,7 @@ void PCA6416AComponent::setup() { uint8_t value; if (!this->read_register_(PCA6416A_INPUT0, &value)) { ESP_LOGE(TAG, "PCA6416A not available under 0x%02X", this->address_); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -77,9 +77,6 @@ void PCA6416AComponent::dump_config() { } LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool PCA6416AComponent::digital_read_hw(uint8_t pin) { diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index 20f62c14721..6ac50d3b1bc 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -18,7 +18,7 @@ void PCA9505Component::setup() { // Test to see if device exists if (!this->read_bank_(0)) { ESP_LOGE(TAG, "PCA9505 not detected at 0x%02X", this->address_); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -65,9 +65,6 @@ void PCA9505Component::dump_config() { ESP_LOGCONFIG(TAG, "PCA9505:"); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool PCA9505Component::digital_read_hw(uint8_t pin) { return this->read_bank_(pin / 8); } diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index f367d140678..a12d291f6b2 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -16,7 +16,7 @@ void PCA9554Component::setup() { // Test to see if device exists if (!this->read_inputs_()) { ESP_LOGE(TAG, "PCA95xx not detected at 0x%02X", this->address_); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -64,9 +64,6 @@ void PCA9554Component::dump_config() { this->pin_count_); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool PCA9554Component::digital_read_hw(uint8_t pin) { diff --git a/esphome/components/pca9685/pca9685_output.cpp b/esphome/components/pca9685/pca9685_output.cpp index fe417ed65de..cd4688a02a4 100644 --- a/esphome/components/pca9685/pca9685_output.cpp +++ b/esphome/components/pca9685/pca9685_output.cpp @@ -21,38 +21,37 @@ static const uint8_t PCA9685_MODE1_AUTOINC = 0b00100000; static const uint8_t PCA9685_MODE1_SLEEP = 0b00010000; void PCA9685Output::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool PCA9685Output::setup_registers_() { ESP_LOGV(TAG, " Resetting devices"); if (!this->write_bytes(PCA9685_REGISTER_SOFTWARE_RESET, nullptr, 0)) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(PCA9685_REGISTER_MODE1, PCA9685_MODE1_RESTART | PCA9685_MODE1_AUTOINC)) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(PCA9685_REGISTER_MODE2, this->mode_)) { - this->mark_failed(); - return; + return false; } uint8_t mode1; if (!this->read_byte(PCA9685_REGISTER_MODE1, &mode1)) { - this->mark_failed(); - return; + return false; } mode1 = (mode1 & ~PCA9685_MODE1_RESTART) | PCA9685_MODE1_SLEEP; if (!this->write_byte(PCA9685_REGISTER_MODE1, mode1)) { - this->mark_failed(); - return; + return false; } int pre_scaler = 3; if (this->extclk_) { mode1 = mode1 | PCA9685_MODE1_EXTCLK; if (!this->write_byte(PCA9685_REGISTER_MODE1, mode1)) { - this->mark_failed(); - return; + return false; } } else { pre_scaler = static_cast((25000000 / (4096 * this->frequency_)) - 1); @@ -61,18 +60,17 @@ void PCA9685Output::setup() { ESP_LOGV(TAG, " -> Prescaler: %d", pre_scaler); } if (!this->write_byte(PCA9685_REGISTER_PRE_SCALE, pre_scaler)) { - this->mark_failed(); - return; + return false; } mode1 = (mode1 & ~PCA9685_MODE1_SLEEP) | PCA9685_MODE1_RESTART; if (!this->write_byte(PCA9685_REGISTER_MODE1, mode1)) { - this->mark_failed(); - return; + return false; } delayMicroseconds(500); this->loop(); + return true; } void PCA9685Output::dump_config() { @@ -88,9 +86,6 @@ void PCA9685Output::dump_config() { " Frequency: %.0f Hz", this->frequency_); } - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up PCA9685 failed!"); - } } void PCA9685Output::loop() { diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index dad722888f1..38a66309bb0 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -54,6 +54,8 @@ class PCA9685Output final : public Component, public i2c::I2CDevice { void set_phase_balancer(PhaseBalancer balancer) { this->balancer_ = balancer; } protected: + /// Configure the chip; false on an I2C failure. + bool setup_registers_(); friend PCA9685Channel; void set_channel_value_(uint8_t channel, uint16_t value) { diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index bafe19ed2a4..f4123eddffa 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -10,7 +10,7 @@ ESPHOME_LOG_TAG(TAG, "pcf85063"); void PCF85063Component::setup() { if (!this->read_rtc_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } @@ -19,9 +19,6 @@ void PCF85063Component::update() { this->read_time(); } void PCF85063Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF85063:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } RealTimeClock::dump_config(); } diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index 90ea4b3fd10..f20437366d6 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -10,7 +10,7 @@ ESPHOME_LOG_TAG(TAG, "PCF8563"); void PCF8563Component::setup() { if (!this->read_rtc_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } @@ -19,9 +19,6 @@ void PCF8563Component::update() { this->read_time(); } void PCF8563Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8563:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } RealTimeClock::dump_config(); } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index ea1e894a530..15ed2f8650e 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -8,7 +8,7 @@ ESPHOME_LOG_TAG(TAG, "pcf8574"); void PCF8574Component::setup() { if (!this->read_gpio_()) { ESP_LOGE(TAG, "PCF8574 not available under 0x%02X", this->address_); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -50,9 +50,6 @@ void PCF8574Component::dump_config() { YESNO(this->pcf8575_), YESNO(this->reset_)); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool PCF8574Component::digital_read_hw(uint8_t pin) { // Read all pins from hardware into input_mask_ diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 9bc10872d28..0b5af94e696 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -22,12 +22,12 @@ void PI4IOE5V6408Component::setup() { this->reg(PI4IOE5V6408_REGISTER_OUT_HIGH_IMPEDENCE) = 0b00000000; } else { if (!this->read_gpio_modes_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); ESP_LOGE(TAG, "Failed to read GPIO modes"); return; } if (!this->read_gpio_outputs_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); ESP_LOGE(TAG, "Failed to read GPIO outputs"); return; } @@ -51,9 +51,6 @@ void PI4IOE5V6408Component::dump_config() { ESP_LOGCONFIG(TAG, "PI4IOE5V6408:"); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags & gpio::FLAG_OUTPUT) { diff --git a/esphome/components/pm2005/pm2005.cpp b/esphome/components/pm2005/pm2005.cpp index 784a555e024..52dfea5fd5f 100644 --- a/esphome/components/pm2005/pm2005.cpp +++ b/esphome/components/pm2005/pm2005.cpp @@ -53,8 +53,7 @@ void PM2005Component::setup() { } if (this->read(this->data_buffer_, 12) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -107,9 +106,6 @@ void PM2005Component::dump_config() { this->sensor_type_ == PM2105); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); diff --git a/esphome/components/pmwcs3/pmwcs3.cpp b/esphome/components/pmwcs3/pmwcs3.cpp index 7493f5d97fb..60cc4da0e4c 100644 --- a/esphome/components/pmwcs3/pmwcs3.cpp +++ b/esphome/components/pmwcs3/pmwcs3.cpp @@ -55,9 +55,6 @@ void PMWCS3Component::update() { this->read_data_(); } void PMWCS3Component::dump_config() { ESP_LOGCONFIG(TAG, "PMWCS3"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "e25", this->e25_sensor_); LOG_SENSOR(" ", "ec", this->ec_sensor_); @@ -79,7 +76,7 @@ void PMWCS3Component::read_data_() { float e25, ec, temperature, vwc; if (!this->read_bytes(PMWCS3_REG_GET_DATA, (uint8_t *) &data, 8)) { ESP_LOGVV(TAG, "Reading PMWCS3_REG_GET_DATA failed"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->e25_sensor_ != nullptr) { diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 8fd92c9031f..fd38edf136a 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -21,8 +21,7 @@ void PN532::setup() { if (!this->write_command_({PN532_COMMAND_VERSION_DATA})) { ESP_LOGW(TAG, "Error sending version command, trying again"); if (!this->write_command_({PN532_COMMAND_VERSION_DATA})) { - ESP_LOGE(TAG, "Error sending version command"); - this->mark_failed(); + this->mark_failed(LOG_STR("Error sending version command")); return; } } @@ -30,8 +29,7 @@ void PN532::setup() { PN532Frame version_data; // GetFirmwareVersion returns IC, Ver, Rev and Support if (!this->read_response(PN532_COMMAND_VERSION_DATA, version_data) || version_data.size() < 3) { - ESP_LOGE(TAG, "Error getting version"); - this->mark_failed(); + this->mark_failed(LOG_STR("Error getting version")); return; } ESP_LOGD(TAG, "Found chip PN5%02X, Firmware v%d.%d", version_data[0], version_data[1], version_data[2]); @@ -42,15 +40,13 @@ void PN532::setup() { 0x14, // timeout: 20 x 50 ms (only used in virtual card mode) 0x01, // use IRQ })) { - ESP_LOGE(TAG, "No wakeup ack"); - this->mark_failed(); + this->mark_failed(LOG_STR("No wakeup ack")); return; } PN532Frame wakeup_result; if (!this->read_response(PN532_COMMAND_SAMCONFIGURATION, wakeup_result)) { - this->error_code_ = WAKEUP_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Wake Up command failed")); return; } @@ -62,8 +58,7 @@ void PN532::setup() { sam_timeout, // timeout as multiple of 50ms (actually only for virtual card mode, but shouldn't matter) 0x01, // Enable IRQ })) { - this->error_code_ = SAM_COMMAND_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("SAM command failed")); return; } @@ -73,8 +68,7 @@ void PN532::setup() { for (uint8_t dat : sam_result) { ESP_LOGV(TAG, " 0x%02X", dat); } - this->error_code_ = SAM_COMMAND_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("SAM command failed")); return; } @@ -482,16 +476,6 @@ bool PN532::mifare_read_(uint8_t address, MifareReadData &data) { void PN532::dump_config() { ESP_LOGCONFIG(TAG, "PN532:"); - switch (this->error_code_) { - case NONE: - break; - case WAKEUP_FAILED: - ESP_LOGE(TAG, "Wake Up command failed!"); - break; - case SAM_COMMAND_FAILED: - ESP_LOGE(TAG, "SAM command failed!"); - break; - } LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index 3a9099f3210..902da35c2fa 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -152,11 +152,6 @@ class PN532 : public PollingComponent { FORMAT, WRITE, }; - enum PN532Error : uint8_t { - NONE = 0, - WAKEUP_FAILED, - SAM_COMMAND_FAILED, - }; // members are ordered by alignment, widest first, to minimize padding LazyCallbackManager on_finished_write_callback_; @@ -174,7 +169,6 @@ class PN532 : public PollingComponent { uint32_t rd_start_time_{0}; // valid only while rd_started_ is set PN532ReadReady rd_ready_{WOULDBLOCK}; NfcTask next_task_{READ}; - PN532Error error_code_{NONE}; bool rd_started_{false}; bool updates_enabled_{true}; bool requested_read_{false}; diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 0898b924869..50d8c814fa4 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -34,9 +34,6 @@ PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { ESP_LOGCONFIG(TAG, "pylontech:"); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with pylontech failed!"); - } for (PylontechListener *listener : this->listeners_) { listener->dump_config(); diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index dcfa46abc5b..2c859822e93 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -29,8 +29,7 @@ void IRAM_ATTR QMC5883LComponent::gpio_intr(QMC5883LComponent *arg) { arg->enabl void QMC5883LComponent::setup() { // Soft Reset if (!this->write_byte(QMC5883L_REGISTER_CONTROL_2, 1 << 7)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(10); @@ -51,8 +50,7 @@ void QMC5883LComponent::setup() { control_1 |= this->range_ << 4; control_1 |= this->oversampling_ << 6; if (!this->write_byte(QMC5883L_REGISTER_CONTROL_1, control_1)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -61,15 +59,13 @@ void QMC5883LComponent::setup() { control_2 |= 0b0 << 6; // ROL_PNT (Pointer Roll Over) -> 0b00=disabled, 0b01=enabled control_2 |= 0b0 << 0; // INT_ENB (Interrupt) -> 0b00=disabled, 0b01=enabled if (!this->write_byte(QMC5883L_REGISTER_CONTROL_2, control_2)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t period = 0x01; // recommended value if (!this->write_byte(QMC5883L_REGISTER_PERIOD, period)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -81,9 +77,6 @@ void QMC5883LComponent::setup() { void QMC5883LComponent::dump_config() { ESP_LOGCONFIG(TAG, "QMC5883L:"); LOG_I2C_DEVICE(this); - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "X Axis", this->x_sensor_); diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index faef423f8cc..d941c5d2e1e 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -57,10 +57,6 @@ class QMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *temperature_sensor_{nullptr}; GPIOPin *drdy_pin_{nullptr}; bool drdy_use_isr_{false}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_; i2c::ErrorCode read_bytes_16_le_(uint8_t a_register, uint16_t *data, uint8_t len = 1); HighFrequencyLoopRequester high_freq_; }; diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index 5c9402c2018..da1f735fb9e 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -32,19 +32,18 @@ void QMI8658Component::setup() { // 1. Verify chip ID uint8_t who_am_i = 0; if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { - ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); return; } if (who_am_i != QMI8658_WHO_AM_I_VALUE) { ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE); - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } // 2. Soft reset if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(15); // spec: wait for reset to complete @@ -70,7 +69,6 @@ void QMI8658Component::setup() { // 6. Disable the built-in low-pass filters (leave raw data to the motion pipeline) if (!this->write_byte(QMI8658_REG_CTRL5, 0x00)) { this->mark_failed(LOG_STR("Failed to write REG_CTRL5")); - this->mark_failed(); return; } @@ -86,10 +84,8 @@ void QMI8658Component::setup() { void QMI8658Component::dump_config() { ESP_LOGCONFIG(TAG, "QMI8658 IMU:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Communication failed!"); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Accel range : %s", LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_ >> 4, AccelRangeStrings::LAST_INDEX))); diff --git a/esphome/components/qwiic_pir/qwiic_pir.cpp b/esphome/components/qwiic_pir/qwiic_pir.cpp index fffc3514d03..de9be3c8f0c 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.cpp +++ b/esphome/components/qwiic_pir/qwiic_pir.cpp @@ -9,23 +9,17 @@ void QwiicPIRComponent::setup() { // Verify I2C communcation by reading and verifying the chip ID uint8_t chip_id; if (!this->read_byte(QWIIC_PIR_CHIP_ID, &chip_id)) { - ESP_LOGE(TAG, "Failed to read chip ID"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read chip ID")); return; } if (chip_id != QWIIC_PIR_DEVICE_ID) { - ESP_LOGE(TAG, "Unknown chip ID"); - this->error_code_ = ERROR_WRONG_CHIP_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("Unknown chip ID")); return; } if (!this->write_byte_16(QWIIC_PIR_DEBOUNCE_TIME, this->debounce_time_)) { - ESP_LOGE(TAG, "Failed to configure debounce time"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to configure debounce time")); return; } @@ -33,9 +27,7 @@ void QwiicPIRComponent::setup() { // Publish the starting raw state of the PIR sensor // If NATIVE mode, the binary_sensor state would be unknown until a motion event if (!this->read_byte(QWIIC_PIR_EVENT_STATUS, &this->event_register_.reg)) { - ESP_LOGE(TAG, "Failed to read initial state"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read initial state")); return; } @@ -104,20 +96,6 @@ void QwiicPIRComponent::dump_config() { ESP_LOGCONFIG(TAG, " Debounce Time: %ums", this->debounce_time_); } - switch (this->error_code_) { - case NONE: - break; - case ERROR_COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case ERROR_WRONG_CHIP_ID: - ESP_LOGE(TAG, "Unknown chip ID"); - break; - default: - ESP_LOGE(TAG, "Error %d", (int) this->error_code_); - break; - } - LOG_I2C_DEVICE(this); LOG_BINARY_SENSOR(" ", "Binary Sensor", this); } diff --git a/esphome/components/qwiic_pir/qwiic_pir.h b/esphome/components/qwiic_pir/qwiic_pir.h index 8d3b8fb321b..39ed5b32d5a 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.h +++ b/esphome/components/qwiic_pir/qwiic_pir.h @@ -44,12 +44,6 @@ class QwiicPIRComponent final : public Component, public i2c::I2CDevice, public DebounceMode debounce_mode_{}; - enum ErrorCode { - NONE = 0, - ERROR_COMMUNICATION_FAILED, - ERROR_WRONG_CHIP_ID, - } error_code_{NONE}; - union { struct { bool raw_reading : 1; // raw state of PIR sensor diff --git a/esphome/components/rc522/rc522.cpp b/esphome/components/rc522/rc522.cpp index 6549ba9846c..9796974a411 100644 --- a/esphome/components/rc522/rc522.cpp +++ b/esphome/components/rc522/rc522.cpp @@ -76,13 +76,6 @@ void RC522::initialize_() { void RC522::dump_config() { ESP_LOGCONFIG(TAG, "RC522:"); - switch (this->error_code_) { - case NONE: - break; - case RESET_FAILED: - ESP_LOGE(TAG, "Reset command failed"); - break; - } LOG_PIN(" RESET Pin: ", this->reset_pin_); @@ -293,9 +286,7 @@ void RC522::pcd_reset_() { } if (--reset_count_ == 0) { - ESP_LOGE(TAG, "Unable to reset"); - this->error_code_ = RESET_FAILED; - mark_failed(); + this->mark_failed(LOG_STR("Reset command failed")); } } diff --git a/esphome/components/rc522/rc522.h b/esphome/components/rc522/rc522.h index fd3c8196969..00d530c81db 100644 --- a/esphome/components/rc522/rc522.h +++ b/esphome/components/rc522/rc522.h @@ -244,11 +244,6 @@ class RC522 : public PollingComponent { std::vector triggers_ontag_; std::vector triggers_ontagremoved_; std::vector current_uid_; - - enum RC522Error { - NONE = 0, - RESET_FAILED, - } error_code_{NONE}; }; class RC522BinarySensor final : public binary_sensor::BinarySensor { diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index 502f6f265d6..c94c64f8611 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -19,7 +19,7 @@ constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); void RX8025TComponent::setup() { uint8_t flags; if (!this->read_flags_(&flags)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -33,9 +33,6 @@ void RX8025TComponent::update() { this->read_time(); } void RX8025TComponent::dump_config() { ESP_LOGCONFIG(TAG, "RX8025T:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } time::RealTimeClock::dump_config(); } diff --git a/esphome/components/scd30/scd30.cpp b/esphome/components/scd30/scd30.cpp index 5802503627a..07760ebd191 100644 --- a/esphome/components/scd30/scd30.cpp +++ b/esphome/components/scd30/scd30.cpp @@ -34,8 +34,7 @@ void SCD30Component::setup() { /// Firmware version identification uint16_t raw_firmware_version[3]; if (!this->get_register(SCD30_CMD_GET_FIRMWARE_VERSION, raw_firmware_version, 3)) { - this->error_code_ = FIRMWARE_IDENTIFICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read sensor firmware version")); return; } ESP_LOGD(TAG, "SCD30 Firmware v%0d.%02d", (uint16_t(raw_firmware_version[0]) >> 8), @@ -49,9 +48,7 @@ void SCD30Component::setup() { } if (!this->write_command(SCD30_CMD_TEMPERATURE_OFFSET, temp_offset)) { - ESP_LOGE(TAG, "Sensor SCD30 error setting temperature offset."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting temperature offset")); return; } #ifdef USE_ESP32 @@ -68,9 +65,7 @@ void SCD30Component::setup() { #endif if (!this->write_command(SCD30_CMD_MEASUREMENT_INTERVAL, update_interval_)) { - ESP_LOGE(TAG, "Sensor SCD30 error setting update interval."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting update interval")); return; } #ifdef USE_ESP32 @@ -80,9 +75,7 @@ void SCD30Component::setup() { // The start measurement command disables the altitude compensation, if any, so we only set it if it's turned on if (this->altitude_compensation_ != 0xFFFF) { if (!this->write_command(SCD30_CMD_ALTITUDE_COMPENSATION, altitude_compensation_)) { - ESP_LOGE(TAG, "Sensor SCD30 error setting altitude compensation."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting altitude compensation")); return; } } @@ -91,9 +84,7 @@ void SCD30Component::setup() { #endif if (!this->write_command(SCD30_CMD_AUTOMATIC_SELF_CALIBRATION, enable_asc_ ? 1 : 0)) { - ESP_LOGE(TAG, "Sensor SCD30 error setting automatic self calibration."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting automatic self calibration")); return; } #ifdef USE_ESP32 @@ -102,9 +93,7 @@ void SCD30Component::setup() { /// Sensor initialization if (!this->write_command(SCD30_CMD_START_CONTINUOUS_MEASUREMENTS, this->ambient_pressure_compensation_)) { - ESP_LOGE(TAG, "Sensor SCD30 error starting continuous measurements."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error starting continuous measurements")); return; } @@ -118,22 +107,6 @@ void SCD30Component::setup() { void SCD30Component::dump_config() { ESP_LOGCONFIG(TAG, "scd30:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); - break; - case FIRMWARE_IDENTIFICATION_FAILED: - ESP_LOGW(TAG, "Unable to read sensor firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } if (this->altitude_compensation_ == 0xFFFF) { ESP_LOGCONFIG(TAG, " Altitude compensation: OFF"); } else { @@ -216,7 +189,6 @@ bool SCD30Component::force_recalibration_with_reference(uint16_t co2_reference) return true; } else { ESP_LOGE(TAG, "Failed to force recalibration with reference."); - this->error_code_ = FORCE_RECALIBRATION_FAILED; this->status_set_warning(); return false; } diff --git a/esphome/components/scd30/scd30.h b/esphome/components/scd30/scd30.h index 0605ab4175a..d3bb980d80c 100644 --- a/esphome/components/scd30/scd30.h +++ b/esphome/components/scd30/scd30.h @@ -29,13 +29,6 @@ class SCD30Component final : public Component, public sensirion_common::Sensirio protected: bool is_data_ready_(); - enum ErrorCode { - COMMUNICATION_FAILED, - FIRMWARE_IDENTIFICATION_FAILED, - MEASUREMENT_INIT_FAILED, - FORCE_RECALIBRATION_FAILED, - UNKNOWN - } error_code_{UNKNOWN}; bool enable_asc_{true}; uint16_t altitude_compensation_{0xFFFF}; uint16_t ambient_pressure_compensation_{0x0000}; diff --git a/esphome/components/scd4x/scd4x.cpp b/esphome/components/scd4x/scd4x.cpp index 8249a6feb1e..bd30f3a42cb 100644 --- a/esphome/components/scd4x/scd4x.cpp +++ b/esphome/components/scd4x/scd4x.cpp @@ -30,8 +30,7 @@ void SCD4XComponent::setup() { this->set_timeout(1000, [this]() { this->status_clear_error(); if (!this->write_command(SCD4X_CMD_STOP_MEASUREMENTS)) { - ESP_LOGE(TAG, "Failed to stop measurements"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to stop measurements")); return; } // According to the SCD4x datasheet the sensor will only respond to other commands after waiting 500 ms after @@ -39,9 +38,7 @@ void SCD4XComponent::setup() { this->set_timeout(500, [this]() { uint16_t raw_serial_number[3]; if (!this->get_register(SCD4X_CMD_GET_SERIAL_NUMBER, raw_serial_number, 3, 1)) { - ESP_LOGE(TAG, "Failed to read serial number"); - this->error_code_ = SERIAL_NUMBER_IDENTIFICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read serial number")); return; } ESP_LOGD(TAG, "Serial number %02d.%02d.%02d", (uint16_t(raw_serial_number[0]) >> 8), @@ -49,33 +46,25 @@ void SCD4XComponent::setup() { if (!this->write_command(SCD4X_CMD_TEMPERATURE_OFFSET, (uint16_t) (temperature_offset_ * SCD4X_TEMPERATURE_OFFSET_MULTIPLIER))) { - ESP_LOGE(TAG, "Error setting temperature offset"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting temperature offset")); return; } // If pressure compensation available use it, else use altitude if (this->ambient_pressure_) { if (!this->update_ambient_pressure_compensation_(this->ambient_pressure_)) { - ESP_LOGE(TAG, "Error setting ambient pressure compensation"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting ambient pressure compensation")); return; } } else { if (!this->write_command(SCD4X_CMD_ALTITUDE_COMPENSATION, this->altitude_compensation_)) { - ESP_LOGE(TAG, "Error setting altitude compensation"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting altitude compensation")); return; } } if (!this->write_command(SCD4X_CMD_AUTOMATIC_SELF_CALIBRATION, this->enable_asc_ ? 1 : 0)) { - ESP_LOGE(TAG, "Error setting automatic self calibration"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting automatic self calibration")); return; } @@ -110,22 +99,6 @@ void SCD4XComponent::dump_config() { ESP_LOGCONFIG(TAG, "SCD4X:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); - break; - case SERIAL_NUMBER_IDENTIFICATION_FAILED: - ESP_LOGW(TAG, "Unable to read firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } ESP_LOGCONFIG(TAG, " Automatic self calibration: %s\n" " Measurement mode: %s\n" @@ -237,7 +210,6 @@ bool SCD4XComponent::perform_forced_calibration(uint16_t current_co2_concentrati return true; } else { ESP_LOGE(TAG, "Force calibration failed"); - this->error_code_ = FRC_FAILED; this->status_set_warning(); return false; } @@ -310,7 +282,6 @@ bool SCD4XComponent::start_measurement_() { while (remaining_retries) { if (!this->write_command(measurement_command)) { ESP_LOGE(TAG, "Error starting measurements"); - this->error_code_ = MEASUREMENT_INIT_FAILED; this->status_set_warning(); if (--remaining_retries == 0) return false; diff --git a/esphome/components/scd4x/scd4x.h b/esphome/components/scd4x/scd4x.h index 4d5dedb5e94..0407af2b5ab 100644 --- a/esphome/components/scd4x/scd4x.h +++ b/esphome/components/scd4x/scd4x.h @@ -7,14 +7,6 @@ namespace esphome::scd4x { -enum ErrorCode : uint8_t { - COMMUNICATION_FAILED, - SERIAL_NUMBER_IDENTIFICATION_FAILED, - MEASUREMENT_INIT_FAILED, - FRC_FAILED, - UNKNOWN, -}; - enum MeasurementMode : uint8_t { PERIODIC, LOW_POWER_PERIODIC, @@ -54,7 +46,6 @@ class SCD4XComponent final : public PollingComponent, public sensirion_common::S uint16_t ambient_pressure_{0}; // Per datasheet, valid values are 700 to 1200 hPa; 0 is a valid sentinel value bool initialized_{false}; bool enable_asc_{false}; - ErrorCode error_code_; MeasurementMode measurement_mode_{PERIODIC}; }; diff --git a/esphome/components/sdp3x/sdp3x.cpp b/esphome/components/sdp3x/sdp3x.cpp index 98b5d9940a3..2f090108a69 100644 --- a/esphome/components/sdp3x/sdp3x.cpp +++ b/esphome/components/sdp3x/sdp3x.cpp @@ -26,20 +26,17 @@ void SDP3XComponent::setup() { this->set_timeout(20, [this] { if (!this->write_command(SDP3X_READ_ID1)) { - ESP_LOGE(TAG, "Read ID1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Read ID1 failed")); return; } if (!this->write_command(SDP3X_READ_ID2)) { - ESP_LOGE(TAG, "Read ID2 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Read ID2 failed")); return; } uint16_t data[6]; if (!this->read_data(data, 6)) { - ESP_LOGE(TAG, "Read ID failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Read ID failed")); return; } @@ -76,8 +73,7 @@ void SDP3XComponent::setup() { } if (!this->write_command(measurement_mode_ == DP_AVG ? SDP3X_START_DP_AVG : SDP3X_START_MASS_FLOW_AVG)) { - ESP_LOGE(TAG, "Start Measurements failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Start Measurements failed")); return; } ESP_LOGCONFIG(TAG, "started"); @@ -86,9 +82,6 @@ void SDP3XComponent::setup() { void SDP3XComponent::dump_config() { LOG_SENSOR(" ", "SDP3X", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Connection with failed"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/sen0321/sen0321.cpp b/esphome/components/sen0321/sen0321.cpp index 564e05e0394..420caa01b4f 100644 --- a/esphome/components/sen0321/sen0321.cpp +++ b/esphome/components/sen0321/sen0321.cpp @@ -9,7 +9,7 @@ ESPHOME_LOG_TAG(TAG, "sen0321_sensor.sensor"); void Sen0321Sensor::setup() { if (!this->write_byte(SENSOR_MODE_REGISTER, SENSOR_MODE_AUTO)) { ESP_LOGW(TAG, "Error setting measurement mode."); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); }; } @@ -18,9 +18,6 @@ void Sen0321Sensor::update() { this->read_data_(); } void Sen0321Sensor::dump_config() { ESP_LOGCONFIG(TAG, "DF Robot Ozone Sensor sen0321:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/sen21231/sen21231.cpp b/esphome/components/sen21231/sen21231.cpp index 29175d845cc..d45b247a437 100644 --- a/esphome/components/sen21231/sen21231.cpp +++ b/esphome/components/sen21231/sen21231.cpp @@ -10,10 +10,6 @@ void Sen21231Sensor::update() { this->read_data_(); } void Sen21231Sensor::dump_config() { ESP_LOGCONFIG(TAG, "SEN21231:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } - ESP_LOGI(TAG, "SEN21231: %s", this->is_failed() ? LOG_STR_LITERAL("FAILED") : LOG_STR_LITERAL("OK")); LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index f192a402887..2d7babddf81 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -60,16 +60,14 @@ void SEN5XComponent::setup() { this->set_timeout(1000, [this]() { // Check if measurement is ready before reading the value if (!this->write_command(SEN5X_CMD_GET_DATA_READY_STATUS)) { - ESP_LOGE(TAG, "Failed to write data ready status command"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write data ready status command")); return; } delay(20); // per datasheet uint16_t raw_read_status; if (!this->read_data(raw_read_status)) { - ESP_LOGE(TAG, "Failed to read data ready status"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read data ready status")); return; } @@ -78,8 +76,7 @@ void SEN5XComponent::setup() { if (raw_read_status) { ESP_LOGD(TAG, "Data is available; stopping periodic measurement"); if (!this->write_command(SEN5X_CMD_STOP_MEASUREMENTS)) { - ESP_LOGE(TAG, "Failed to stop measurements"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to stop measurements")); return; } // According to the SEN5x datasheet the sensor will only respond to other commands after waiting 200 ms after @@ -91,9 +88,7 @@ void SEN5XComponent::setup() { // this appears to be all that Sensirion uses for serial numbers, this could change uint16_t raw_serial_number[8]; if (!this->get_register(SEN5X_CMD_GET_SERIAL_NUMBER, raw_serial_number, 8, 20)) { - ESP_LOGE(TAG, "Failed to read serial number"); - this->error_code_ = SERIAL_NUMBER_IDENTIFICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read serial ID")); return; } const char *serial_number = sensirion_convert_to_string_in_place(raw_serial_number, 8); @@ -121,8 +116,7 @@ void SEN5XComponent::setup() { this->type_ = this->model_override_.value(); } else if (detected_type == Sen5xType::UNKNOWN) { this->type_ = Sen5xType::UNKNOWN; - this->error_code_ = PRODUCT_NAME_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read product name")); return; } else { this->type_ = detected_type; @@ -147,9 +141,7 @@ void SEN5XComponent::setup() { } if (!this->get_register(SEN5X_CMD_GET_FIRMWARE_VERSION, this->firmware_version_, 20)) { - ESP_LOGE(TAG, "Failed to read firmware version"); - this->error_code_ = FIRMWARE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read firmware version")); return; } this->firmware_version_ >>= 8; @@ -189,9 +181,7 @@ void SEN5XComponent::setup() { result = this->write_command(SEN5X_CMD_RHT_ACCELERATION_MODE); } if (!result) { - ESP_LOGE(TAG, "Failed to set rh/t acceleration mode"); - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to set rh/t acceleration mode")); return; } delay(20); @@ -225,9 +215,7 @@ void SEN5XComponent::setup() { } if (!this->write_command(cmd)) { - ESP_LOGE(TAG, "Error starting continuous measurements"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error starting continuous measurements")); return; } this->initialized_ = true; @@ -238,28 +226,6 @@ void SEN5XComponent::setup() { void SEN5XComponent::dump_config() { ESP_LOGCONFIG(TAG, "SEN5X:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement initialization failed"); - break; - case SERIAL_NUMBER_IDENTIFICATION_FAILED: - ESP_LOGW(TAG, "Unable to read serial ID"); - break; - case PRODUCT_NAME_FAILED: - ESP_LOGW(TAG, "Unable to read product name"); - break; - case FIRMWARE_FAILED: - ESP_LOGW(TAG, "Unable to read firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } ESP_LOGCONFIG(TAG, " Type: %s%s\n" " Firmware version: %d\n" diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index ed7689af523..07b72a1d69d 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -8,15 +8,6 @@ namespace esphome::sen5x { -enum ERRORCODE : uint8_t { - COMMUNICATION_FAILED, - SERIAL_NUMBER_IDENTIFICATION_FAILED, - MEASUREMENT_INIT_FAILED, - PRODUCT_NAME_FAILED, - FIRMWARE_FAILED, - UNKNOWN -}; - enum RhtAccelerationMode : uint16_t { LOW_ACCELERATION = 0, MEDIUM_ACCELERATION = 1, @@ -107,7 +98,6 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S uint32_t voc_baseline_time_{0}; uint16_t firmware_version_{0}; Sen5xType type_{Sen5xType::UNKNOWN}; - ERRORCODE error_code_{ERRORCODE::UNKNOWN}; bool initialized_{false}; bool store_baseline_{false}; diff --git a/esphome/components/sfa30/sfa30.cpp b/esphome/components/sfa30/sfa30.cpp index efa3d8dc61f..b2db91bb587 100644 --- a/esphome/components/sfa30/sfa30.cpp +++ b/esphome/components/sfa30/sfa30.cpp @@ -13,9 +13,7 @@ void SFA30Component::setup() { // Serial Number identification uint16_t raw_device_marking[16]; if (!this->get_register(SFA30_CMD_GET_DEVICE_MARKING, raw_device_marking, 16, 5)) { - ESP_LOGE(TAG, "Failed to read device marking"); - this->error_code_ = DEVICE_MARKING_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read device marking")); return; } @@ -26,9 +24,7 @@ void SFA30Component::setup() { ESP_LOGD(TAG, "Device Marking: '%s'", this->device_marking_); if (!this->write_command(SFA30_CMD_START_CONTINUOUS_MEASUREMENTS)) { - ESP_LOGE(TAG, "Error starting measurements."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error starting measurements")); return; } } @@ -36,19 +32,6 @@ void SFA30Component::setup() { void SFA30Component::dump_config() { ESP_LOGCONFIG(TAG, "sfa30:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case DEVICE_MARKING_READ_FAILED: - ESP_LOGW(TAG, "Unable to read device marking!"); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement initialization failed!"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error!"); - break; - } - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Device Marking: '%s'", this->device_marking_); LOG_SENSOR(" ", "Formaldehyde", this->formaldehyde_sensor_); diff --git a/esphome/components/sfa30/sfa30.h b/esphome/components/sfa30/sfa30.h index 13985b1a294..a9d85f60115 100644 --- a/esphome/components/sfa30/sfa30.h +++ b/esphome/components/sfa30/sfa30.h @@ -7,8 +7,6 @@ namespace esphome::sfa30 { class SFA30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { - enum ErrorCode { DEVICE_MARKING_READ_FAILED, MEASUREMENT_INIT_FAILED, UNKNOWN }; - public: void setup() override; void dump_config() override; @@ -21,8 +19,6 @@ class SFA30Component final : public PollingComponent, public sensirion_common::S protected: char device_marking_[32] = {0}; - ErrorCode error_code_{UNKNOWN}; - sensor::Sensor *formaldehyde_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; sensor::Sensor *temperature_sensor_{nullptr}; diff --git a/esphome/components/sfa40/sfa40.cpp b/esphome/components/sfa40/sfa40.cpp index 31a25a4f384..d0d3496afd0 100644 --- a/esphome/components/sfa40/sfa40.cpp +++ b/esphome/components/sfa40/sfa40.cpp @@ -41,15 +41,11 @@ void SFA40Component::setup() { this->write_command(SFA40_CMD_STOP_MEASUREMENT); this->set_timeout(25, [this]() { if (!this->detect_protocol_()) { - ESP_LOGE(TAG, "Failed to detect SFA40 protocol"); - this->error_code_ = PROTOCOL_DETECTION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Protocol detection failed")); return; } if (!this->write_command(SFA40_CMD_START_MEASUREMENT)) { - ESP_LOGE(TAG, "Failed to start measurements"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to start measurements")); return; } this->initialized_ = true; @@ -77,19 +73,6 @@ bool SFA40Component::detect_protocol_() { void SFA40Component::dump_config() { ESP_LOGCONFIG(TAG, "sfa40:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case PROTOCOL_DETECTION_FAILED: - ESP_LOGW(TAG, "Protocol detection failed!"); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement initialization failed!"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error!"); - break; - } - } LOG_UPDATE_INTERVAL(this); switch (this->protocol_version_) { case ProtocolVersion::PRODUCTION: diff --git a/esphome/components/sfa40/sfa40.h b/esphome/components/sfa40/sfa40.h index 6f67607753c..67995fae2ee 100644 --- a/esphome/components/sfa40/sfa40.h +++ b/esphome/components/sfa40/sfa40.h @@ -25,14 +25,8 @@ class SFA40Component final : public PollingComponent, public sensirion_common::S PRODUCTION = 1, PROTOTYPE = 2, }; - enum ErrorCode : uint8_t { - UNKNOWN_ERROR = 0, - PROTOCOL_DETECTION_FAILED, - MEASUREMENT_INIT_FAILED, - }; bool detect_protocol_(); ProtocolVersion protocol_version_{UNKNOWN}; - ErrorCode error_code_{UNKNOWN_ERROR}; char device_marking_[11]{}; bool initialized_{false}; bool wait_for_ready_{true}; diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 72827ad51b5..387dbf3c485 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -37,7 +37,7 @@ void SGP30Component::setup() { // Serial Number identification uint16_t raw_serial_number[3]; if (!this->get_register(SGP30_CMD_GET_SERIAL_ID, raw_serial_number, 3)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->serial_number_ = (static_cast(raw_serial_number[0]) << 32) | @@ -48,28 +48,24 @@ void SGP30Component::setup() { // Featureset identification for future use uint16_t raw_featureset; if (!this->get_register(SGP30_CMD_GET_FEATURESET, raw_featureset)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->featureset_ = raw_featureset; if (uint16_t(this->featureset_ >> 12) != 0x0) { if (uint16_t(this->featureset_ >> 12) == 0x1) { // ID matching a different sensor: SGPC3 - this->error_code_ = UNSUPPORTED_ID; + this->mark_failed(LOG_STR("Unsupported ID")); } else { - // Unknown ID - this->error_code_ = INVALID_ID; + this->mark_failed(LOG_STR("Invalid ID")); } - this->mark_failed(); return; } ESP_LOGV(TAG, "Product version: 0x%0X", uint16_t(this->featureset_ & 0x1FF)); // Sensor initialization if (!this->write_command(SGP30_CMD_IAQ_INIT)) { - ESP_LOGE(TAG, "sgp30_iaq_init failed"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Measurement initialization failed")); return; } @@ -227,25 +223,7 @@ void SGP30Component::write_iaq_baseline_(uint16_t eco2_baseline, uint16_t tvoc_b void SGP30Component::dump_config() { ESP_LOGCONFIG(TAG, "SGP30:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); - break; - case INVALID_ID: - ESP_LOGW(TAG, "Invalid ID"); - break; - case UNSUPPORTED_ID: - ESP_LOGW(TAG, "Unsupported ID"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } else { + if (!this->is_failed()) { ESP_LOGCONFIG(TAG, " Serial number: %" PRIu64, this->serial_number_); if (this->eco2_baseline_ != 0x0000 && this->tvoc_baseline_ != 0x0000) { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/sgp30/sgp30.h b/esphome/components/sgp30/sgp30.h index fac3c01b58f..3e95072dcfe 100644 --- a/esphome/components/sgp30/sgp30.h +++ b/esphome/components/sgp30/sgp30.h @@ -46,14 +46,6 @@ class SGP30Component final : public PollingComponent, public sensirion_common::S uint16_t tvoc_baseline_{0x0000}; bool store_baseline_; - enum ErrorCode : uint8_t { - COMMUNICATION_FAILED, - MEASUREMENT_INIT_FAILED, - INVALID_ID, - UNSUPPORTED_ID, - UNKNOWN - } error_code_{UNKNOWN}; - ESPPreferenceObject pref_; SGP30Baselines baselines_storage_; diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 854dddba059..2772aa4be82 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -13,9 +13,7 @@ void SGP4xComponent::setup() { // Serial Number identification uint16_t raw_serial_number[3]; if (!this->get_register(SGP4X_CMD_GET_SERIAL_ID, raw_serial_number, 3, 1)) { - ESP_LOGE(TAG, "Get serial number failed"); - this->error_code_ = SERIAL_NUMBER_IDENTIFICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Get serial number failed")); return; } this->serial_number_ = (uint64_t(raw_serial_number[0]) << 32) | (uint64_t(raw_serial_number[1]) << 16) | @@ -26,7 +24,7 @@ void SGP4xComponent::setup() { uint16_t featureset; if (!this->get_register(SGP4X_CMD_GET_FEATURESET, featureset, 1)) { ESP_LOGD(TAG, "Get feature set failed"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } featureset &= 0x1FF; @@ -44,7 +42,7 @@ void SGP4xComponent::setup() { this->measure_time_ = SGP41_MEASURE_TIME; } else { ESP_LOGD(TAG, "Unknown feature set 0x%0X", featureset); - this->mark_failed(); + this->mark_failed(LOG_STR("Unknown feature set")); return; } @@ -111,9 +109,7 @@ void SGP4xComponent::setup() { void SGP4xComponent::self_test_() { ESP_LOGD(TAG, "Starting self-test"); if (!this->write_command(SGP4X_CMD_SELF_TEST)) { - this->error_code_ = COMMUNICATION_FAILED; - ESP_LOGD(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -123,9 +119,8 @@ void SGP4xComponent::self_test_() { // failures bool passed = this->read_data(reply) && (this->sgp_type_ == SGP41 ? (reply & 0x0003) == 0 : (reply >> 8) == 0xD4); if (!passed) { - this->error_code_ = SELF_TEST_FAILED; ESP_LOGW(TAG, "Self-test failed (0x%X)", reply); - this->mark_failed(); + this->mark_failed(LOG_STR("Self-test failed")); return; } @@ -264,22 +259,7 @@ void SGP4xComponent::dump_config() { LOG_I2C_DEVICE(this); ESP_LOGCONFIG(TAG, " Store baseline: %s", YESNO(this->store_baseline_)); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case SERIAL_NUMBER_IDENTIFICATION_FAILED: - ESP_LOGW(TAG, "Get serial number failed"); - break; - case SELF_TEST_FAILED: - ESP_LOGW(TAG, "Self-test failed"); - break; - default: - ESP_LOGW(TAG, "Unknown error"); - break; - } - } else { + if (!this->is_failed()) { ESP_LOGCONFIG(TAG, " Type: %s\n" " Serial number: %" PRIu64 "\n" diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 2aaf06601b1..804f0cdcef9 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -59,16 +59,6 @@ class SGP4xComponent; class SGP4xComponent final : public PollingComponent, public sensor::Sensor, public sensirion_common::SensirionI2CDevice { - enum ErrorCode { - COMMUNICATION_FAILED, - MEASUREMENT_INIT_FAILED, - INVALID_ID, - UNSUPPORTED_ID, - SERIAL_NUMBER_IDENTIFICATION_FAILED, - SELF_TEST_FAILED, - UNKNOWN - } error_code_{UNKNOWN}; - public: // SGP4xComponent() {}; void set_humidity_sensor(sensor::Sensor *humidity) { humidity_sensor_ = humidity; } diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 45de59fe0dc..d46020b2d9a 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -30,7 +30,7 @@ void SHT4XComponent::read_serial_number_() { void SHT4XComponent::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -70,9 +70,6 @@ void SHT4XComponent::dump_config() { this->serial_number_); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } if (this->serial_number_ == 0) { ESP_LOGW(TAG, "Get serial number failed"); } From dc0d79635bcc826b94b817b6b7a6f317449122f9 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Fri, 16 Oct 2026 19:12:49 +0200 Subject: [PATCH 461/651] [ld2410][ld2412] Move CONF_QUERY_PARAMS to the shared constants (#20878) --- esphome/components/const/__init__.py | 1 + esphome/components/ld2410/button/__init__.py | 3 +-- esphome/components/ld2412/button/__init__.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3a531978aaa..5d900bbd5b4 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -54,6 +54,7 @@ CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" +CONF_QUERY_PARAMS = "query_params" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_RED_CHANNEL = "red_channel" diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button/__init__.py index 3826455986d..21618babe79 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import button +from esphome.components.const import CONF_QUERY_PARAMS import esphome.config_validation as cv from esphome.const import ( CONF_FACTORY_RESET, @@ -20,8 +21,6 @@ FactoryResetButton = ld2410_ns.class_("FactoryResetButton", button.Button) QueryButton = ld2410_ns.class_("QueryButton", button.Button) RestartButton = ld2410_ns.class_("RestartButton", button.Button) -CONF_QUERY_PARAMS = "query_params" - CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component), diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index 9f5c868310b..c1737a494de 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import button +from esphome.components.const import CONF_QUERY_PARAMS import esphome.config_validation as cv from esphome.const import ( CONF_FACTORY_RESET, @@ -23,8 +24,6 @@ RestartButton = LD2412_ns.class_("RestartButton", button.Button) StartDynamicBackgroundCorrectionButton = LD2412_ns.class_( "StartDynamicBackgroundCorrectionButton", button.Button ) - -CONF_QUERY_PARAMS = "query_params" CONF_START_DYNAMIC_BACKGROUND_CORRECTION = "start_dynamic_background_correction" CONFIG_SCHEMA = { From 1368ce43423c209f7c48b0942e059cdab156dee4 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Fri, 16 Oct 2026 19:31:58 +0200 Subject: [PATCH 462/651] [dfrobot_sen0609] Add restart, factory reset and query buttons (#20877) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../dfrobot_sen0609/button/__init__.py | 57 +++++++++++++ .../button/dfrobot_sen0609_button.h | 30 +++++++ .../dfrobot_sen0609/dfrobot_sen0609.cpp | 85 +++++++++++++++++-- .../dfrobot_sen0609/dfrobot_sen0609.h | 24 ++++++ .../components/dfrobot_sen0609/benchmark.yaml | 1 + tests/components/dfrobot_sen0609/common.yaml | 10 +++ .../dfrobot_sen0609_sequencer.cpp | 56 ++++++++++++ 7 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 esphome/components/dfrobot_sen0609/button/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/button/dfrobot_sen0609_button.h diff --git a/esphome/components/dfrobot_sen0609/button/__init__.py b/esphome/components/dfrobot_sen0609/button/__init__.py new file mode 100644 index 00000000000..1db608e62c5 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/button/__init__.py @@ -0,0 +1,57 @@ +import esphome.codegen as cg +from esphome.components import button +from esphome.components.const import CONF_QUERY_PARAMS +import esphome.config_validation as cv +from esphome.const import ( + CONF_FACTORY_RESET, + CONF_ID, + CONF_RESTART, + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_DATABASE, + ICON_RESTART, + ICON_RESTART_ALERT, +) +from esphome.types import ConfigType + +from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component, dfrobot_sen0609_ns + +DFRobotSEN0609Button = dfrobot_sen0609_ns.class_("DFRobotSEN0609Button", button.Button) +ButtonAction = dfrobot_sen0609_ns.enum("ButtonAction") + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_FACTORY_RESET): button.button_schema( + DFRobotSEN0609Button, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, + ), + cv.Optional(CONF_QUERY_PARAMS): button.button_schema( + DFRobotSEN0609Button, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_DATABASE, + ), + cv.Optional(CONF_RESTART): button.button_schema( + DFRobotSEN0609Button, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_RESTART, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons( + CONF_FACTORY_RESET, + hub.set_factory_reset_button, + ButtonAction.BUTTON_FACTORY_RESET, + ) + await buttons( + CONF_QUERY_PARAMS, hub.set_query_button, ButtonAction.BUTTON_QUERY_PARAMS + ) + await buttons(CONF_RESTART, hub.set_restart_button, ButtonAction.BUTTON_RESTART) diff --git a/esphome/components/dfrobot_sen0609/button/dfrobot_sen0609_button.h b/esphome/components/dfrobot_sen0609/button/dfrobot_sen0609_button.h new file mode 100644 index 00000000000..c7c03aa2b87 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/button/dfrobot_sen0609_button.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class DFRobotSEN0609Button final : public button::Button, public Parented { + public: + explicit DFRobotSEN0609Button(ButtonAction action) : action_(action) {} + + protected: + void press_action() override { + switch (this->action_) { + case BUTTON_RESTART: + this->parent_->restart(); + break; + case BUTTON_FACTORY_RESET: + this->parent_->factory_reset(); + break; + case BUTTON_QUERY_PARAMS: + this->parent_->query_params(); + break; + } + } + + ButtonAction action_; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index 26538998a2f..5f42d05a827 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -21,6 +21,9 @@ static constexpr uint32_t COMMAND_GAP_MS = 100; static constexpr uint32_t COALESCE_MS = 1000; static constexpr uint32_t DEFAULT_TIMEOUT_MS = 1000; static constexpr uint32_t LONG_TIMEOUT_MS = 2000; +static constexpr uint32_t FACTORY_RESET_TIMEOUT_MS = 3000; +// resetSystem has no answer; this is how long the module takes to boot again +static constexpr uint32_t RESTART_MS = 1500; static constexpr uint32_t GIVE_UP_BACKOFF_MS = 30000; // Presence frames come every second (setUartOutput period); this long without one means the module lost them static constexpr uint32_t FRAME_TIMEOUT_MS = 10000; @@ -113,6 +116,10 @@ static const LogString *operation_to_str(Operation operation) { return LOG_STR("stop"); case Operation::OPERATION_START: return LOG_STR("start"); + case Operation::OPERATION_RESTART: + return LOG_STR("restart"); + case Operation::OPERATION_FACTORY_RESET: + return LOG_STR("factory reset"); case Operation::OPERATION_WRITE: return LOG_STR("write"); case Operation::OPERATION_SAVE: @@ -161,6 +168,11 @@ void DFRobotSEN0609Component::dump_config() { #ifdef USE_SELECT LOG_SELECT(" ", "OutPinLevel", this->out_pin_level_select_); #endif +#ifdef USE_BUTTON + LOG_BUTTON(" ", "FactoryReset", this->factory_reset_button_); + LOG_BUTTON(" ", "Query", this->query_button_); + LOG_BUTTON(" ", "Restart", this->restart_button_); +#endif } void DFRobotSEN0609Component::setup() { @@ -436,6 +448,21 @@ void DFRobotSEN0609Component::request_write_(Param param) { this->hold_(COALESCE_MS); } +void DFRobotSEN0609Component::restart() { + this->restart_pending_ = true; + this->hold_(COMMAND_GAP_MS); +} + +void DFRobotSEN0609Component::factory_reset() { + this->factory_reset_pending_ = true; + this->hold_(COMMAND_GAP_MS); +} + +void DFRobotSEN0609Component::query_params() { + this->pending_reads_ |= this->wanted_reads_(); + this->hold_(COMMAND_GAP_MS); +} + #ifdef USE_NUMBER void DFRobotSEN0609Component::control_number(NumberSetting setting, float value) { // The module needs min_range <= trigger_range <= max_range; a value not read yet is NAN and passes @@ -511,20 +538,42 @@ void DFRobotSEN0609Component::hold_(uint32_t duration_ms) { void DFRobotSEN0609Component::run_sequencer_() { const uint32_t now = App.get_loop_component_start_time(); if (this->operation_ != Operation::OPERATION_NONE) { - const bool slow = this->operation_ == Operation::OPERATION_STOP || this->operation_ == Operation::OPERATION_SAVE; - if (now - this->sent_ms_ < (slow ? LONG_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) + uint32_t timeout = DEFAULT_TIMEOUT_MS; + switch (this->operation_) { + case Operation::OPERATION_RESTART: + timeout = RESTART_MS; + break; + case Operation::OPERATION_FACTORY_RESET: + timeout = FACTORY_RESET_TIMEOUT_MS; + break; + case Operation::OPERATION_STOP: + case Operation::OPERATION_SAVE: + timeout = LONG_TIMEOUT_MS; + break; + default: + break; + } + if (now - this->sent_ms_ < timeout) return; - ESP_LOGD(TAG, "No answer to '%s'", this->command_); - this->fail_operation_(); + if (this->operation_ == Operation::OPERATION_RESTART) { + this->finish_operation_(); + } else { + ESP_LOGD(TAG, "No answer to '%s'", this->command_); + this->fail_operation_(); + } return; } if (now - this->hold_start_ms_ < this->hold_ms_) return; - const bool work_pending = this->save_pending_ || this->pending_writes_ != 0 || this->pending_reads_ != 0; + const bool work_pending = + this->factory_reset_pending_ || this->save_pending_ || this->pending_writes_ != 0 || this->pending_reads_ != 0; Operation operation; Param param = PARAM_COUNT; - if (!work_pending) { + if (this->restart_pending_) { + // The reboot drops a write the module took but did not save; writes not sent yet are kept for after it + operation = this->stopped_ && this->save_pending_ ? Operation::OPERATION_SAVE : Operation::OPERATION_RESTART; + } else if (!work_pending) { if (!this->stopped_) { this->check_frames_(now); // Work given up on is tried again in a later sequence, so the sensor runs in between @@ -539,6 +588,8 @@ void DFRobotSEN0609Component::run_sequencer_() { operation = Operation::OPERATION_START; } else if (!this->stopped_) { operation = Operation::OPERATION_STOP; + } else if (this->factory_reset_pending_) { + operation = Operation::OPERATION_FACTORY_RESET; } else if (this->pending_writes_ != 0) { operation = Operation::OPERATION_WRITE; param = static_cast(__builtin_ctz(this->pending_writes_)); @@ -611,6 +662,12 @@ bool DFRobotSEN0609Component::build_command_(Operation operation, Param param) { case Operation::OPERATION_START: ESPHOME_strncpy_P(buf, ESPHOME_PSTR("sensorStart"), len); return true; + case Operation::OPERATION_RESTART: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("resetSystem"), len); + return true; + case Operation::OPERATION_FACTORY_RESET: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("resetCfg"), len); + return true; case Operation::OPERATION_SAVE: ESPHOME_strncpy_P(buf, ESPHOME_PSTR("saveConfig"), len); return true; @@ -785,6 +842,16 @@ void DFRobotSEN0609Component::finish_operation_() { this->status_clear_warning(); this->sequence_failed_ = false; break; + case Operation::OPERATION_RESTART: + // The module starts sensing on its own after a reboot + this->restart_pending_ = false; + this->stopped_ = false; + this->queue_configuration_(); + break; + case Operation::OPERATION_FACTORY_RESET: + this->factory_reset_pending_ = false; + this->queue_configuration_(); + break; case Operation::OPERATION_WRITE: this->deferred_writes_ &= ~param_bit(this->param_); if (PERSISTENT_PARAMS & param_bit(this->param_)) @@ -846,6 +913,12 @@ void DFRobotSEN0609Component::fail_operation_() { ESP_LOGW(TAG, "Giving up on %s '%s'", LOG_STR_ARG(operation_to_str(operation)), this->command_); this->sequence_failed_ = true; switch (operation) { + case Operation::OPERATION_RESTART: + this->restart_pending_ = false; + break; + case Operation::OPERATION_FACTORY_RESET: + this->factory_reset_pending_ = false; + break; case Operation::OPERATION_WRITE: #ifdef USE_SWITCH if (is_led(this->param_)) { diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h index 719aa6f5fe0..ca70fa14943 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -21,6 +21,9 @@ #ifdef USE_SELECT #include "esphome/components/select/select.h" #endif +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif #include "esphome/components/uart/uart.h" #include @@ -58,6 +61,12 @@ enum SwitchSetting : uint8_t { SWITCH_COUNT, }; +enum ButtonAction : uint8_t { + BUTTON_RESTART, + BUTTON_FACTORY_RESET, + BUTTON_QUERY_PARAMS, +}; + /// Module parameters; a write or read of each is one command. Pending work runs in this order. enum Param : uint8_t { PARAM_RUN_APP, @@ -81,6 +90,8 @@ enum class Operation : uint8_t { OPERATION_NONE, OPERATION_STOP, OPERATION_START, + OPERATION_RESTART, + OPERATION_FACTORY_RESET, OPERATION_WRITE, OPERATION_SAVE, OPERATION_READ, @@ -102,6 +113,11 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice #ifdef USE_SELECT SUB_SELECT(out_pin_level) #endif +#ifdef USE_BUTTON + SUB_BUTTON(factory_reset) + SUB_BUTTON(query) + SUB_BUTTON(restart) +#endif public: explicit DFRobotSEN0609Component(RunMode mode); @@ -121,6 +137,12 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice #ifdef USE_SELECT void control_out_pin_level(size_t index); #endif + /// Reboot the module, then apply the configuration and read the settings again. + void restart(); + /// Restore the module's factory settings, then apply the configuration and read the settings again. + void factory_reset(); + /// Read all settings shown by entities from the module. + void query_params(); protected: void readline_(uint8_t c); @@ -207,6 +229,8 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice bool stopped_{false}; bool save_pending_{false}; bool deferred_save_{false}; + bool restart_pending_{false}; + bool factory_reset_pending_{false}; bool sequence_failed_{false}; }; diff --git a/tests/components/dfrobot_sen0609/benchmark.yaml b/tests/components/dfrobot_sen0609/benchmark.yaml index 2a35b04794d..3c20e117389 100644 --- a/tests/components/dfrobot_sen0609/benchmark.yaml +++ b/tests/components/dfrobot_sen0609/benchmark.yaml @@ -6,3 +6,4 @@ text_sensor: number: switch: select: +button: diff --git a/tests/components/dfrobot_sen0609/common.yaml b/tests/components/dfrobot_sen0609/common.yaml index aca15b906fc..bb7ad5d622f 100644 --- a/tests/components/dfrobot_sen0609/common.yaml +++ b/tests/components/dfrobot_sen0609/common.yaml @@ -49,3 +49,13 @@ select: dfrobot_sen0609_id: sen0609_presence out_pin_level: name: Out Pin Level + +button: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + factory_reset: + name: Factory Reset + query_params: + name: Query Params + restart: + name: Restart diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp index aa39c4f6a4d..528c788490a 100644 --- a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp @@ -340,6 +340,18 @@ TEST_F(SEN0609SequencerTest, FailedWriteShowsModuleValueAgain) { EXPECT_TRUE(this->hub_.status_has_warning()); } +TEST_F(SEN0609SequencerTest, QueryKeepsGapAfterAnswer) { + this->boot_(); + this->hub_.query_params(); + this->run_(); + EXPECT_EQ(this->uart_.tx, ""); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); +} + TEST_F(SEN0609SequencerTest, AlreadyStoppedCountsAsStopped) { this->hub_.setup(); ASSERT_EQ(this->next_command_(), "sensorStop"); @@ -480,6 +492,50 @@ TEST_F(SEN0609SequencerTest, TriggerRangeOutsideRangeIsRefused) { this->expect_and_answer_("setTrigRange 8.00"); } +TEST_F(SEN0609SequencerTest, RestartReappliesConfiguration) { + this->boot_(); + this->hub_.restart(); + ASSERT_EQ(this->next_command_(), "resetSystem"); + // resetSystem has no answer; the hub waits for the reboot + EXPECT_EQ(this->next_command_(1000), ""); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); +} + +TEST_F(SEN0609SequencerTest, RestartDuringSequenceSavesWrittenChange) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->hub_.restart(); + this->answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + ASSERT_EQ(this->next_command_(), "resetSystem"); + EXPECT_EQ(this->next_command_(1000), ""); + // The change not sent before the reboot is written after it + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("getRange", "Response 1.0 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 3 5\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 3.0f); +} + +TEST_F(SEN0609SequencerTest, FactoryResetRereadsSettings) { + this->boot_(); + this->hub_.factory_reset(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("resetCfg"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 25.0\r\n"); + EXPECT_FLOAT_EQ(this->max_range_.state, 25.0f); +} + class SEN0609SettingsTest : public SEN0609Test { protected: void SetUp() override { From 5ab92ed36319eb74b14955b74abcaf392c751893 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:15:17 +0200 Subject: [PATCH 463/651] [ld2450] Handle footer bytes in zone query responses (#20852) Co-authored-by: J. Nick Koston --- esphome/components/ld2450/ld2450.cpp | 35 ++++ tests/components/ld2450/common.h | 1 + tests/components/ld2450/ld2450_zone_ack.cpp | 180 ++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 tests/components/ld2450/ld2450_zone_ack.cpp diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index d2a0e04d122..cdf542ee8b8 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -10,6 +10,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include #include #include #include @@ -131,6 +132,9 @@ static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x0 // Data Header & Footer static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xAA, 0xFF, 0x03, 0x00}; static constexpr uint8_t DATA_FRAME_FOOTER[2] = {0x55, 0xCC}; +// Successful zone query ACK: header (4) + length (2) + command/status (2) + result (2) + zone type (2) + +// 3 zones * 8 + footer (4). Zone coordinates may contain the command footer bytes. +static constexpr uint8_t ZONE_ACK_FRAME_SIZE = 40; // MAC address the module uses when Bluetooth is disabled static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01}; @@ -173,6 +177,20 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; } +// Drop the frame at the start of the buffer, keeping bytes from the next frame header (or a partial +// header at the end). Returns the new buffer length. +static inline uint8_t drop_to_next_header(uint8_t *buffer, uint8_t len) { + for (uint8_t start = 1; start < len; start++) { + const size_t n = std::min(HEADER_FOOTER_SIZE, len - start); + if (std::memcmp(&buffer[start], CMD_FRAME_HEADER, n) == 0 || + std::memcmp(&buffer[start], DATA_FRAME_HEADER, n) == 0) { + std::memmove(buffer, &buffer[start], len - start); + return len - start; + } + } + return 0; +} + void LD2450Component::setup() { #ifdef USE_NUMBER if (this->presence_timeout_number_ != nullptr) { @@ -756,6 +774,9 @@ bool LD2450Component::handle_ack_data_() { break; case CMD_QUERY_ZONE: + if (this->buffer_pos_ < ZONE_ACK_FRAME_SIZE) { + return false; // command footer bytes inside the coordinates; keep reading + } ESP_LOGV(TAG, "Query zone conf"); this->zone_type_ = this->buffer_data_[10]; this->publish_zone_type(); @@ -824,6 +845,20 @@ void LD2450Component::readline_(int readch) { } else { ESP_LOGV(TAG, "Ack Data incomplete"); } + } else if (this->buffer_pos_ == ZONE_ACK_FRAME_SIZE && this->buffer_data_[COMMAND] == CMD_QUERY_ZONE && + ld2450::validate_header_footer(CMD_FRAME_HEADER, this->buffer_data_)) { + ESP_LOGW(TAG, "Truncated zone ACK"); + // Replay the retained bytes so frames already complete inside them are dispatched. + // At most ZONE_ACK_FRAME_SIZE - 1 bytes are replayed from position 0, so neither this + // branch nor the overflow path can be re-entered during the replay. + uint8_t pending[ZONE_ACK_FRAME_SIZE]; + const uint8_t pending_len = ld2450::drop_to_next_header(this->buffer_data_, this->buffer_pos_); + std::memcpy(pending, this->buffer_data_, pending_len); + this->buffer_pos_ = 0; + this->buffer_data_[0] = 0; + for (uint8_t i = 0; i < pending_len; i++) { + this->readline_(pending[i]); // NOLINT(misc-no-recursion) + } } } diff --git a/tests/components/ld2450/common.h b/tests/components/ld2450/common.h index de912ddcbca..95b0a65de8e 100644 --- a/tests/components/ld2450/common.h +++ b/tests/components/ld2450/common.h @@ -29,6 +29,7 @@ class TestableLD2450 : public LD2450Component { using LD2450Component::buffer_data_; using LD2450Component::buffer_pos_; using LD2450Component::readline_; + using LD2450Component::zone_config_; void feed(const std::vector &data) { for (uint8_t byte : data) { diff --git a/tests/components/ld2450/ld2450_zone_ack.cpp b/tests/components/ld2450/ld2450_zone_ack.cpp new file mode 100644 index 00000000000..5f6674709d9 --- /dev/null +++ b/tests/components/ld2450/ld2450_zone_ack.cpp @@ -0,0 +1,180 @@ +#include +#include "common.h" + +namespace esphome::ld2450::testing { + +namespace { +// Successful zone query ACK (40 bytes), zone type 0x01, with the given coordinate bytes; missing ones are zero. +std::vector make_zone_ack(std::vector coords) { + coords.resize(24, 0); + std::vector frame = {0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00}; + frame.insert(frame.end(), coords.begin(), coords.end()); + frame.insert(frame.end(), {0x04, 0x03, 0x02, 0x01}); + return frame; +} + +// Zone ACK with zone 0 at (100, 200) to (1500, 3000) and the other zones cleared. +std::vector make_complete_zone_ack() { + return make_zone_ack({0x64, 0x00, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B}); +} + +// The first 16 bytes of a zone ACK whose zone 0 starts at (772, 258): its last four bytes look like a footer. +std::vector make_truncated_zone_ack() { + return {0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01}; +} + +// Successful disable-configuration ACK: a four-byte body. +std::vector make_disable_conf_ack() { + auto frame = make_ack_frame(); + frame[6] = 0xFE; + return frame; +} + +void expect_zone(const TestableLD2450 &ld2450, size_t index, int x1, int y1, int x2, int y2) { + EXPECT_EQ(ld2450.zone_config_[index].x1, x1) << "zone " << index; + EXPECT_EQ(ld2450.zone_config_[index].y1, y1) << "zone " << index; + EXPECT_EQ(ld2450.zone_config_[index].x2, x2) << "zone " << index; + EXPECT_EQ(ld2450.zone_config_[index].y2, y2) << "zone " << index; +} +} // namespace + +class LD2450ZoneAckTest : public ::testing::Test { + protected: + void SetUp() override { this->ld2450_.set_uart_parent(&this->mock_uart_); } + void set_all_zones_(int16_t x1, int16_t y1, int16_t x2, int16_t y2) { + for (auto &zone : this->ld2450_.zone_config_) { + zone = {x1, y1, x2, y2}; + } + } + void expect_all_zones_(int x1, int y1, int x2, int y2) { + for (size_t i = 0; i < MAX_ZONES; i++) { + expect_zone(this->ld2450_, i, x1, y1, x2, y2); + } + } + + MockUARTComponent mock_uart_; + TestableLD2450 ld2450_; +}; + +TEST_F(LD2450ZoneAckTest, CmdFooterBytesInZonePayloadDoNotTruncateAck) { + // x1=772 and y1=258 are the command footer bytes, followed by x2=1500 and y2=3000. + static constexpr uint8_t COORDINATES[] = {0x04, 0x03, 0x02, 0x01, 0xDC, 0x05, 0xB8, 0x0B}; + for (uint8_t zone = 0; zone < MAX_ZONES; zone++) { + std::vector coords(24, 0); + std::memcpy(coords.data() + zone * 8, COORDINATES, sizeof(COORDINATES)); + const auto frame = make_zone_ack(coords); + std::memset(this->ld2450_.buffer_data_, 0xEE, MAX_LINE_LENGTH); + + const size_t split = 12 + zone * 8 + 4; + this->ld2450_.feed(std::vector(frame.begin(), frame.begin() + split)); + EXPECT_EQ(this->ld2450_.buffer_pos_, split) << "zone " << static_cast(zone); + this->ld2450_.feed(std::vector(frame.begin() + split, frame.end())); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + for (uint8_t i = 0; i < MAX_ZONES; i++) { + if (i == zone) { + expect_zone(this->ld2450_, i, 772, 258, 1500, 3000); + } else { + expect_zone(this->ld2450_, i, 0, 0, 0, 0); + } + } + } +} + +TEST_F(LD2450ZoneAckTest, CompleteZoneAckWithoutEmbeddedFooter) { + this->set_all_zones_(100, 200, 300, 400); + this->ld2450_.feed(make_zone_ack({})); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + this->expect_all_zones_(0, 0, 0, 0); +} + +TEST_F(LD2450ZoneAckTest, FailedZoneQueryAckDoesNotWaitForCoordinates) { + this->set_all_zones_(100, 200, 300, 400); + auto frame = make_ack_frame(); + frame[6] = 0xC1; + frame[8] = 0x01; + this->ld2450_.feed(frame); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + this->expect_all_zones_(100, 200, 300, 400); +} + +TEST_F(LD2450ZoneAckTest, ValidZoneAckWithEmbeddedFooterAndHeaderIsNotResynced) { + const auto frame = make_zone_ack({0x04, 0x03, 0x02, 0x01, 0xFD, 0xFC, 0xFB, 0xFA, // 772, 258, -771, -1285 + 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00, // 30, 449, 0, 1 + 0x64, 0x00, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B}); // 100, 200, 1500, 3000 + // The first 36 bytes equal a truncated ACK followed by a complete one; only the footer at offset 36 differs. + auto stream = make_truncated_zone_ack(); + const auto complete = make_complete_zone_ack(); + stream.insert(stream.end(), complete.begin(), complete.end()); + ASSERT_TRUE(std::equal(frame.begin(), frame.begin() + 36, stream.begin())); + + this->ld2450_.feed(frame); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 772, 258, -771, -1285); + expect_zone(this->ld2450_, 1, 30, 449, 0, 1); + expect_zone(this->ld2450_, 2, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, TruncatedZoneAckThenCompleteZoneAckRecovers) { + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_complete_zone_ack()); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, TruncatedZoneAckThenPeriodicFrameIsParsed) { + this->ld2450_.zone_config_[0] = {100, 200, 300, 400}; + int frames = 0; + this->ld2450_.add_on_data_callback([&frames]() { frames++; }); + + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_periodic_frame()); + + // The truncated ACK is not decoded; the periodic frame is recovered through the data header. + EXPECT_EQ(frames, 1); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 300, 400); +} + +TEST_F(LD2450ZoneAckTest, TruncatedZoneAckThenShortAckThenCompleteZoneAckRecovers) { + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_disable_conf_ack()); + this->ld2450_.feed(make_complete_zone_ack()); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, ShortAckInRetainedSuffixIsDispatchedOnResync) { + const auto complete = make_complete_zone_ack(); + this->ld2450_.feed(make_truncated_zone_ack()); + this->ld2450_.feed(make_disable_conf_ack()); + // Ten bytes of the next zone ACK bring the buffer to 40 bytes without a footer. + this->ld2450_.feed(std::vector(complete.begin(), complete.begin() + 10)); + // The short ACK was replayed and consumed; only the new zone ACK prefix remains. + EXPECT_EQ(this->ld2450_.buffer_pos_, 10); + EXPECT_EQ(this->ld2450_.buffer_data_[6], 0xC1); + this->ld2450_.feed(std::vector(complete.begin() + 10, complete.end())); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +TEST_F(LD2450ZoneAckTest, MultipleCompleteFramesInRetainedSuffixAreDrained) { + // Truncated zone ACK without any footer, followed by two complete short ACKs (11 + 14 + 14 = 39). + const auto truncated = make_truncated_zone_ack(); + std::vector stream(truncated.begin(), truncated.begin() + 11); + const auto disable_ack = make_disable_conf_ack(); + stream.insert(stream.end(), disable_ack.begin(), disable_ack.end()); + const auto enable_ack = make_ack_frame(); + stream.insert(stream.end(), enable_ack.begin(), enable_ack.end()); + this->ld2450_.feed(stream); + EXPECT_EQ(this->ld2450_.buffer_pos_, 39); // the C1 header still claims the buffer + // The 40th byte triggers the resync; both buffered ACKs are drained, leaving only this byte. + const auto complete = make_complete_zone_ack(); + this->ld2450_.feed({complete[0]}); + EXPECT_EQ(this->ld2450_.buffer_pos_, 1); + EXPECT_EQ(this->ld2450_.buffer_data_[0], 0xFD); + this->ld2450_.feed(std::vector(complete.begin() + 1, complete.end())); + EXPECT_EQ(this->ld2450_.buffer_pos_, 0); + expect_zone(this->ld2450_, 0, 100, 200, 1500, 3000); +} + +} // namespace esphome::ld2450::testing From cd6a34401ba52255774dfa8e4eb25422f083f4a1 Mon Sep 17 00:00:00 2001 From: sebo-b <51707418+sebo-b@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:22:46 +0200 Subject: [PATCH 464/651] [web_server] Do not match internal entities by URL unless include_internal is set (#20883) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/web_server/web_server.cpp | 5 +++++ esphome/components/web_server/web_server.h | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index b0da010561e..04cd0ff43d2 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -203,6 +203,10 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { EntityMatchResult result{false, this->method.empty()}; + // Internal entities are unreachable by URL unless include_internal is set, as with the native API and MQTT. + if (!this->include_internal && entity->is_internal()) + return result; + #ifdef USE_DEVICES Device *entity_device = entity->get_device(); bool url_has_device = !this->device_name.empty(); @@ -2560,6 +2564,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // Parse URL for component routing // Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action) UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST); + match.include_internal = this->include_internal_; // Route to appropriate handler based on domain // NOLINTNEXTLINE(readability-simplify-boolean-expr) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d3bd1ae0da6..f8ef12f9bff 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -66,7 +66,8 @@ struct UrlMatch { #ifdef USE_DEVICES StringRef device_name; ///< Device name within URL, empty for main device #endif - bool valid{false}; ///< Whether this match is valid + bool valid{false}; ///< Whether this match is valid + bool include_internal{false}; ///< Whether internal entities may match, the web server's include_internal // Helper methods for string comparisons bool domain_equals(const char *str) const { return this->domain == str; } @@ -78,7 +79,7 @@ struct UrlMatch { bool method_equals(const __FlashStringHelper *str) const { return this->method == str; } #endif - /// Match entity by name + /// Match entity by name; internal entities match only if include_internal is set /// Returns EntityMatchResult with match status and whether action segment is empty EntityMatchResult match_entity(EntityBase *entity) const; }; From ab0c32c2aadea7c7a091ffd0eedfe04b368a2f80 Mon Sep 17 00:00:00 2001 From: TheLexus Date: Fri, 16 Oct 2026 20:50:13 +0200 Subject: [PATCH 465/651] [haier_water_heater] Initial water-heater implementation tested with Haier VH3W (#20765) 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 + .../components/haier_water_heater/__init__.py | 4 + .../haier_water_heater/hon_parser.h | 262 +++++++++++ .../haier_water_heater/hon_water_heater.cpp | 424 ++++++++++++++++++ .../haier_water_heater/hon_water_heater.h | 96 ++++ .../haier_water_heater/water_heater.py | 110 +++++ .../haier_water_heater/__init__.py | 0 .../haier_water_heater/test_water_heater.py | 115 +++++ .../components/haier_water_heater/__init__.py | 12 + .../components/haier_water_heater/common.yaml | 23 + .../haier_water_heater/test.esp32-idf.yaml | 3 + .../haier_water_heater/test.esp8266-ard.yaml | 3 + .../haier_water_heater/test.rp2040-ard.yaml | 3 + .../haier_water_heater/test_hon_protocol.cpp | 219 +++++++++ .../water_heater/test_hon_controller.cpp | 292 ++++++++++++ 15 files changed, 1567 insertions(+) create mode 100644 esphome/components/haier_water_heater/__init__.py create mode 100644 esphome/components/haier_water_heater/hon_parser.h create mode 100644 esphome/components/haier_water_heater/hon_water_heater.cpp create mode 100644 esphome/components/haier_water_heater/hon_water_heater.h create mode 100644 esphome/components/haier_water_heater/water_heater.py create mode 100644 tests/component_tests/haier_water_heater/__init__.py create mode 100644 tests/component_tests/haier_water_heater/test_water_heater.py create mode 100644 tests/components/haier_water_heater/__init__.py create mode 100644 tests/components/haier_water_heater/common.yaml create mode 100644 tests/components/haier_water_heater/test.esp32-idf.yaml create mode 100644 tests/components/haier_water_heater/test.esp8266-ard.yaml create mode 100644 tests/components/haier_water_heater/test.rp2040-ard.yaml create mode 100644 tests/components/haier_water_heater/test_hon_protocol.cpp create mode 100644 tests/components/haier_water_heater/water_heater/test_hon_controller.cpp diff --git a/CODEOWNERS b/CODEOWNERS index f2f24f1106f..38d2da4d870 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -252,6 +252,7 @@ esphome/components/haier/text_sensor/* @paveldn esphome/components/haier_tundra/* @fauxpark esphome/components/haier_tundra/button/* @fauxpark esphome/components/haier_tundra/switch/* @fauxpark +esphome/components/haier_water_heater/* @TheLexus esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann diff --git a/esphome/components/haier_water_heater/__init__.py b/esphome/components/haier_water_heater/__init__.py new file mode 100644 index 00000000000..7ecd409dd80 --- /dev/null +++ b/esphome/components/haier_water_heater/__init__.py @@ -0,0 +1,4 @@ +"""Haier water heater component.""" + +DOMAIN = "haier_water_heater" +CODEOWNERS = ["@TheLexus"] diff --git a/esphome/components/haier_water_heater/hon_parser.h b/esphome/components/haier_water_heater/hon_parser.h new file mode 100644 index 00000000000..1f7e22abc76 --- /dev/null +++ b/esphome/components/haier_water_heater/hon_parser.h @@ -0,0 +1,262 @@ +#pragma once + +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::haier_water_heater::water_heater_protocol { + +// A logical frame, after the FF FF separator and the FF 55 escapes are removed: +// [length][flags][5 reserved bytes][type][payload...][checksum][crc high][crc low] +// The length counts every byte before the checksum; the CRC is only present when its flag is set. +static constexpr uint8_t FRAME_FLAGS_OFFSET = 1; +static constexpr uint8_t FRAME_TYPE_OFFSET = 7; +static constexpr uint8_t FRAME_PAYLOAD_OFFSET = 8; +static constexpr uint8_t FRAME_FLAG_CRC = 0x40; +static constexpr uint8_t FRAME_FLAG_ENCRYPTED = 0x80; +static constexpr uint8_t MIN_FRAME_LENGTH = 8; +static constexpr uint8_t MAX_FRAME_LENGTH = 0xF1; +// The longest frame that is decoded is the 61-byte status report. Longer frames are still checked +// in full, but only their head is kept. +static constexpr size_t MAX_STORED_FRAME = 64; +static constexpr size_t MAX_PAYLOAD = 4; +static constexpr size_t MAX_WIRE_FRAME = 28; // 2 separators, up to 12 escaped bytes, an escaped checksum + +enum class FrameType : uint8_t { + FRAME_TYPE_CONTROL = 0x01, + FRAME_TYPE_STATUS = 0x02, + FRAME_TYPE_INVALID = 0x03, // the appliance rejected the last request + FRAME_TYPE_CONFIRM = 0x05, + FRAME_TYPE_REPORT = 0x06, // an unsolicited status, which expects a CONFIRM back + FRAME_TYPE_GET_VERSION = 0x61, + FRAME_TYPE_VERSION = 0x62, + FRAME_TYPE_GET_DEVICE_ID = 0x70, + FRAME_TYPE_DEVICE_ID = 0x71, + FRAME_TYPE_GET_NETWORK_STATUS = 0xF0, + FRAME_TYPE_NETWORK_STATUS = 0xF7, +}; + +// Bounded receive parser. The checksum and CRC are kept running while the bytes arrive, so a frame +// longer than the stored head is validated without being stored. +class FrameParser { + public: + void reset() { + this->size_ = 0; + this->separators_ = 0; + this->escape_ = false; + this->complete_ = false; + this->checksum_ = 0; + this->crc_ = 0; + } + bool complete() const { return this->complete_; } + /// The stored head of the frame, at most MAX_STORED_FRAME bytes; valid until the next feed or reset. + const uint8_t *data() const { return this->buffer_.data(); } + /// Logical bytes received, including the checksum and CRC; may exceed MAX_STORED_FRAME. + size_t size() const { return this->size_; } + size_t stored_size() const { return std::min(this->size_, MAX_STORED_FRAME); } + + /// Returns true once a valid frame is complete. A complete but invalid frame leaves complete() set. + bool feed(uint8_t value) { + if (this->complete_) { + this->reset(); + } + if (this->separators_ < 2) { + this->separators_ = value == 0xFF ? this->separators_ + 1 : 0; + return false; + } + if (this->escape_) { + this->escape_ = false; + if (value == 0xFF) { + // A new unescaped separator pair: start over without losing synchronisation. + this->size_ = 0; + this->checksum_ = 0; + this->crc_ = 0; + return false; + } + if (value != 0x55) { + this->reset(); + return false; + } + value = 0xFF; + } else if (value == 0xFF) { + this->escape_ = true; + return false; + } + if (this->size_ == 0 && (value < MIN_FRAME_LENGTH || value > MAX_FRAME_LENGTH)) { + this->reset(); + return false; + } + const uint8_t index = this->size_++; + if (index < MAX_STORED_FRAME) { + this->buffer_[index] = value; + } + const uint8_t length = this->buffer_[0]; + if (index < length) { + this->checksum_ += value; + if (value == 0xFF) { + this->checksum_ += 0x55; // The escape byte counts towards the checksum. + } + this->crc_ = crc16(&value, 1, this->crc_, 0xA001, false, false); + } else if (index == length) { + this->received_checksum_ = value; + } else if (index == length + 1) { + this->received_crc_ = static_cast(value) << 8; + } else { + this->received_crc_ |= value; + } + if (this->size_ < 2) { + return false; + } + const bool crc = (this->buffer_[FRAME_FLAGS_OFFSET] & FRAME_FLAG_CRC) != 0; + if (this->size_ != length + 1 + (crc ? 2 : 0)) { + return false; + } + this->complete_ = true; + if ((this->buffer_[FRAME_FLAGS_OFFSET] & ~FRAME_FLAG_CRC) != 0 || this->checksum_ != this->received_checksum_) { + return false; + } + return !crc || this->crc_ == this->received_crc_; + } + + protected: + std::array buffer_{}; + uint16_t crc_{0}; + uint16_t received_crc_{0}; + uint8_t size_{0}; + uint8_t separators_{0}; + uint8_t checksum_{0}; + uint8_t received_checksum_{0}; + bool escape_{false}; + bool complete_{false}; +}; + +/// Writes a clear-text frame with up to MAX_PAYLOAD bytes of payload into wire, which must hold +/// MAX_WIRE_FRAME bytes. Returns the number of bytes written, or 0 when the payload is too long. +inline size_t encode_frame(FrameType type, const uint8_t *payload, size_t size, uint8_t *wire) { + if (size > MAX_PAYLOAD) { + return 0; + } + uint8_t core[FRAME_PAYLOAD_OFFSET + MAX_PAYLOAD] = {static_cast(FRAME_PAYLOAD_OFFSET + size)}; + core[FRAME_TYPE_OFFSET] = static_cast(type); + for (size_t i = 0; i < size; i++) { + core[FRAME_PAYLOAD_OFFSET + i] = payload[i]; + } + size_t count = 0; + wire[count++] = 0xFF; + wire[count++] = 0xFF; + uint8_t checksum = 0; + for (size_t i = 0; i < FRAME_PAYLOAD_OFFSET + size; i++) { + wire[count++] = core[i]; + checksum += core[i]; + if (core[i] == 0xFF) { + wire[count++] = 0x55; + checksum += 0x55; + } + } + wire[count++] = checksum; + if (checksum == 0xFF) { + wire[count++] = 0x55; + } + return count; +} + +enum class HonMode : uint8_t { HON_MODE_UNKNOWN, HON_MODE_NORMAL, HON_MODE_ECO, HON_MODE_BPS }; + +// The appliance reports and takes target temperatures with this offset. +static constexpr uint8_t TARGET_TEMPERATURE_OFFSET = 30; +static constexpr uint8_t MAX_REPORTED_TEMPERATURE = 100; + +struct Status { + uint8_t current_temperature; + uint8_t target_temperature; + uint8_t secondary_temperature; + uint8_t estimated_heating_time; + uint8_t available_water; // 0 to 3 segments + bool powered; + bool heating; + HonMode mode; +}; + +// The mode is spread over three flag bytes; exactly one of the three indications is set in a known mode. +inline HonMode decode_mode(const uint8_t *payload) { + const bool normal = (payload[38] & 0x10) && (payload[39] & 0x10); + const bool eco = (payload[41] & 0x04) != 0; + const bool bps = (payload[40] & 0x04) != 0; + if (unsigned(normal) + unsigned(eco) + unsigned(bps) != 1) { + return HonMode::HON_MODE_UNKNOWN; + } + if (normal) { + return HonMode::HON_MODE_NORMAL; + } + return eco ? HonMode::HON_MODE_ECO : HonMode::HON_MODE_BPS; +} + +static constexpr uint8_t STATUS_FRAME_LENGTH = 60; + +/// Decodes a status report or a polled status; false for any other frame or an implausible one. +/// Offsets are into the payload, which starts with 6D 01, as captured from a VH3W. The secondary +/// temperature's physical location and the heating time's unit (assumed minutes) are unconfirmed. +/// Available water is 0 to 3 segments of usable hot water, not the tank fill level. +inline bool decode_status(const uint8_t *frame, size_t size, Status &status) { + if (size <= STATUS_FRAME_LENGTH || frame[0] != STATUS_FRAME_LENGTH) { + return false; + } + const auto type = static_cast(frame[FRAME_TYPE_OFFSET]); + if (type != FrameType::FRAME_TYPE_STATUS && type != FrameType::FRAME_TYPE_REPORT) { + return false; + } + const uint8_t *payload = frame + FRAME_PAYLOAD_OFFSET; + if (payload[0] != 0x6D || payload[1] != 0x01) { + return false; + } + status.current_temperature = payload[2]; + status.target_temperature = payload[3] + TARGET_TEMPERATURE_OFFSET; + status.estimated_heating_time = payload[7]; + status.secondary_temperature = payload[15]; + status.powered = (payload[39] & 0x01) != 0; + status.heating = (payload[41] & 0x01) != 0; + status.mode = decode_mode(payload); + status.available_water = payload[45]; + return payload[2] <= MAX_REPORTED_TEMPERATURE && payload[3] <= MAX_REPORTED_TEMPERATURE - TARGET_TEMPERATURE_OFFSET; +} + +// A control write: one parameter of the 0x5D group and its value. +enum class Parameter : uint8_t { + PARAMETER_POWER = 0x01, + PARAMETER_TARGET_TEMPERATURE = 0x02, // value is the temperature minus TARGET_TEMPERATURE_OFFSET + PARAMETER_ECO = 0x05, // 1 selects ECO, 0 selects Normal + PARAMETER_BPS = 0x17, +}; +static constexpr uint8_t CONTROL_GROUP = 0x5D; + +struct Command { + Parameter parameter; + uint8_t value; +}; + +/// Fills the four-byte control payload for a command. +inline void encode_control(const Command &command, uint8_t *payload) { + payload[0] = CONTROL_GROUP; + payload[1] = static_cast(command.parameter); + payload[2] = 0x00; + payload[3] = command.value; +} + +/// True when a status shows that the command took effect. +inline bool command_confirmed(const Command &command, const Status &status) { + switch (command.parameter) { + case Parameter::PARAMETER_POWER: + return status.powered == (command.value != 0); + case Parameter::PARAMETER_TARGET_TEMPERATURE: + return status.target_temperature == command.value + TARGET_TEMPERATURE_OFFSET; + case Parameter::PARAMETER_ECO: + return status.mode == (command.value != 0 ? HonMode::HON_MODE_ECO : HonMode::HON_MODE_NORMAL); + case Parameter::PARAMETER_BPS: + return status.mode == HonMode::HON_MODE_BPS; + } + return false; +} + +} // namespace esphome::haier_water_heater::water_heater_protocol diff --git a/esphome/components/haier_water_heater/hon_water_heater.cpp b/esphome/components/haier_water_heater/hon_water_heater.cpp new file mode 100644 index 00000000000..3f190de5b83 --- /dev/null +++ b/esphome/components/haier_water_heater/hon_water_heater.cpp @@ -0,0 +1,424 @@ +#include "hon_water_heater.h" + +#include + +#include "esphome/core/application.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::haier_water_heater { + +ESPHOME_LOG_TAG(TAG, "haier_water_heater"); +using namespace water_heater_protocol; + +// Indexed by HonMode, with Unknown as the fallback for out-of-range values. +PROGMEM_STRING_TABLE(HonModeLabels, "Unknown", "Normal", "ECO", "BPS"); + +void HonWaterHeater::setup() { + this->status_set_warning(LOG_STR("Waiting for a valid appliance status frame")); + if (this->communication_sensor_ != nullptr) + this->communication_sensor_->publish_state(false); + this->update_network_status_(); + this->set_interval(NETWORK_STATUS_INTERVAL_ID, NETWORK_STATUS_INTERVAL_MS, + [this]() { this->update_network_status_(); }); + this->set_interval(STATUS_POLL_INTERVAL_ID, POLL_INTERVAL_MS, [this]() { this->poll_status_(); }); +} + +void HonWaterHeater::update_network_status_() { +#ifdef USE_NETWORK + const bool ready = network::is_connected(); +#else + const bool ready = false; +#endif + const uint8_t status = ready ? NETWORK_READY : NETWORK_NOT_READY; + if (status != this->network_status_) { + this->network_status_ = status; + this->network_pending_ = true; + ESP_LOGI(TAG, "Local network status changed: %02X", status); + } +} + +void HonWaterHeater::send_frame_(FrameType type, const uint8_t *payload, size_t size) { + uint8_t wire[MAX_WIRE_FRAME]; + const size_t length = encode_frame(type, payload, size, wire); + if (length == 0) + return; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex[format_hex_pretty_size(sizeof(wire))]; + ESP_LOGV(TAG, "TX type=%02X: %s", static_cast(type), format_hex_pretty_to(hex, wire, length)); +#endif + this->write_array(wire, length); +} + +void HonWaterHeater::send_request_(uint32_t now, Awaiting kind, FrameType type, const uint8_t *payload, size_t size, + FrameType expected) { + this->send_frame_(type, payload, size); + this->awaiting_ = kind; + this->expected_type_ = expected; + this->request_sent_ = now; +} + +void HonWaterHeater::poll_status_() { + const uint32_t now = App.get_loop_component_start_time(); + this->check_timeouts_(now); + if (this->awaiting_ != Awaiting::AWAITING_NONE || this->command_count_ != 0 || this->pending_off_) + return; + if (this->network_pending_ || now - this->network_sent_ >= NETWORK_REFRESH_MS) + return; // the network status goes out first, from loop() + // The version and device id are read once per cycle, the status on every other pass. + static constexpr uint8_t VERSION_REQUEST[] = {0x00, 0x07}; + static constexpr uint8_t STATUS_REQUEST[] = {0x4D, 0x01}; + if (this->poll_stage_ == 0) { + this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_GET_VERSION, VERSION_REQUEST, + sizeof(VERSION_REQUEST), FrameType::FRAME_TYPE_VERSION); + } else if (this->poll_stage_ == 1) { + this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_GET_DEVICE_ID, nullptr, 0, + FrameType::FRAME_TYPE_DEVICE_ID); + } else { + this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_CONTROL, STATUS_REQUEST, + sizeof(STATUS_REQUEST), FrameType::FRAME_TYPE_STATUS); + } + this->poll_stage_ = (this->poll_stage_ + 1) % POLL_CYCLE; +} + +void HonWaterHeater::check_timeouts_(uint32_t now) { + if (this->have_status_ && now - this->last_status_ >= this->communication_timeout_) { + this->have_status_ = false; + this->status_set_warning(LOG_STR("Heater communication timed out")); + ESP_LOGW(TAG, "Heater communication lost: no valid status for %u ms", + static_cast(this->communication_timeout_)); + if (this->communication_sensor_ != nullptr) + this->communication_sensor_->publish_state(false); + } + const uint32_t waited = now - this->request_sent_; + switch (this->awaiting_) { + case Awaiting::AWAITING_NONE: + break; + case Awaiting::AWAITING_POLL: + if (waited >= RESPONSE_TIMEOUT_MS) { + ESP_LOGW(TAG, "No matching response type=%02X in %u ms", static_cast(this->expected_type_), + static_cast(RESPONSE_TIMEOUT_MS)); + this->awaiting_ = Awaiting::AWAITING_NONE; + } + break; + case Awaiting::AWAITING_CONTROL: + if (waited >= RESPONSE_TIMEOUT_MS) { + ESP_LOGW(TAG, "Control response timed out; discarding remaining commands"); + this->awaiting_ = Awaiting::AWAITING_NONE; + this->command_count_ = 0; + } + break; + case Awaiting::AWAITING_NETWORK: + if (waited >= NETWORK_ACK_TIMEOUT_MS) { + ESP_LOGW(TAG, "Network status: no CONFIRM within %u ms", static_cast(NETWORK_ACK_TIMEOUT_MS)); + this->awaiting_ = Awaiting::AWAITING_NONE; + } + break; + } +} + +void HonWaterHeater::send_pending_(uint32_t now) { + if (this->awaiting_ != Awaiting::AWAITING_NONE) + return; + if (this->pending_off_) { + this->commands_[0] = {Parameter::PARAMETER_POWER, 0}; + this->command_index_ = 0; + this->command_count_ = 1; + this->pending_off_ = false; + } + if (this->command_count_ == 0 && (this->network_pending_ || now - this->network_sent_ >= NETWORK_REFRESH_MS)) { + // Signal remains a placeholder; status represents local IP readiness, not cloud access. + const uint8_t payload[] = {0x00, this->network_status_, 0x00, 100}; + this->send_request_(now, Awaiting::AWAITING_NETWORK, FrameType::FRAME_TYPE_NETWORK_STATUS, payload, sizeof(payload), + FrameType::FRAME_TYPE_CONFIRM); + this->network_sent_ = now; + this->network_pending_ = false; + return; + } + if (this->command_count_ != 0) + this->send_next_control_(now); +} + +void HonWaterHeater::send_next_control_(uint32_t now) { + uint8_t payload[4]; + encode_control(this->commands_[this->command_index_], payload); + this->send_request_(now, Awaiting::AWAITING_CONTROL, FrameType::FRAME_TYPE_CONTROL, payload, sizeof(payload), + FrameType::FRAME_TYPE_STATUS); +} + +void HonWaterHeater::read_frames_(uint32_t now) { + if (now - this->last_byte_ > FRAME_GAP_MS) + this->parser_.reset(); + uint8_t budget = MAX_BYTES_PER_LOOP; + while (budget-- > 0 && this->available() > 0) { + uint8_t value; + if (!this->read_byte(&value)) + break; + this->last_byte_ = now; + if (this->parser_.feed(value)) { + this->process_frame_(); + } else if (this->parser_.complete()) { + if ((this->parser_.data()[FRAME_FLAGS_OFFSET] & FRAME_FLAG_ENCRYPTED) != 0) { + ESP_LOGW(TAG, "RX: XOR-encrypted frames are not supported and will be dropped"); + } else { + ESP_LOGW(TAG, "RX: unsupported flags or invalid checksum/CRC"); + } + } + } +} + +void HonWaterHeater::loop() { + const uint32_t now = App.get_loop_component_start_time(); + this->check_timeouts_(now); + this->send_pending_(now); + this->read_frames_(now); +} + +void HonWaterHeater::handle_frame_type_(FrameType type) { + if (this->awaiting_ == Awaiting::AWAITING_POLL && type == this->expected_type_) { + ESP_LOGV(TAG, "Response matched type=%02X", static_cast(type)); + this->awaiting_ = Awaiting::AWAITING_NONE; + } + switch (type) { + case FrameType::FRAME_TYPE_GET_NETWORK_STATUS: + this->network_pending_ = true; + break; + case FrameType::FRAME_TYPE_CONFIRM: + if (this->awaiting_ == Awaiting::AWAITING_NETWORK) { + ESP_LOGD(TAG, "Network status acknowledged"); + this->awaiting_ = Awaiting::AWAITING_NONE; + } + break; + case FrameType::FRAME_TYPE_INVALID: + ESP_LOGW(TAG, "Appliance rejected request"); + // Controls queued behind a rejected poll were never sent and keep their turn. + if (this->awaiting_ == Awaiting::AWAITING_CONTROL) + this->command_count_ = 0; + if (this->awaiting_ != Awaiting::AWAITING_POLL) + this->awaiting_ = Awaiting::AWAITING_NONE; + break; + case FrameType::FRAME_TYPE_REPORT: + this->send_frame_(FrameType::FRAME_TYPE_CONFIRM); + break; + default: + break; + } +} + +void HonWaterHeater::confirm_control_(const Status &status) { + const Command &command = this->commands_[this->command_index_]; + if (!command_confirmed(command, status)) + return; + ESP_LOGD(TAG, "Control confirmed: parameter=%02X value=%02X", static_cast(command.parameter), command.value); + this->awaiting_ = Awaiting::AWAITING_NONE; + if (++this->command_index_ == this->command_count_) + this->command_count_ = 0; +} + +void HonWaterHeater::process_frame_() { + const uint8_t *frame = this->parser_.data(); + const auto type = static_cast(frame[FRAME_TYPE_OFFSET]); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex[format_hex_pretty_size(MAX_STORED_FRAME)]; + ESP_LOGV(TAG, "RX decoded type=%02X flags=%02X: %s", static_cast(type), frame[FRAME_FLAGS_OFFSET], + format_hex_pretty_to(hex, frame, this->parser_.stored_size())); +#endif + this->handle_frame_type_(type); + + Status status{}; + if (!decode_status(frame, this->parser_.size(), status)) + return; + const bool recovered = !this->have_status_; + if (status.mode == HonMode::HON_MODE_UNKNOWN && (recovered || this->reported_mode_ != HonMode::HON_MODE_UNKNOWN)) { + const uint8_t *payload = frame + FRAME_PAYLOAD_OFFSET; + ESP_LOGW(TAG, + "Unknown heater mode: payload[38..41]=%02X %02X %02X %02X; using last recognized active mode, or Electric " + "if none is known", + payload[38], payload[39], payload[40], payload[41]); + } + this->have_status_ = true; + this->last_status_ = App.get_loop_component_start_time(); + this->reported_mode_ = status.mode; + if (this->awaiting_ == Awaiting::AWAITING_CONTROL && type == FrameType::FRAME_TYPE_STATUS) + this->confirm_control_(status); + this->status_clear_warning(); + this->publish_status_(status); + // Published last so an automation on the sensor sees fresh heater state and can send a control. + if (recovered) { + ESP_LOGI(TAG, "Heater communication established"); + if (this->communication_sensor_ != nullptr) + this->communication_sensor_->publish_state(true); + } +} + +void HonWaterHeater::publish_status_(const Status &status) { + this->current_temperature_ = status.current_temperature; + this->target_temperature_ = status.target_temperature; + this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, status.powered); + switch (status.mode) { + case HonMode::HON_MODE_NORMAL: + this->last_active_mode_ = water_heater::WATER_HEATER_MODE_ELECTRIC; + break; + case HonMode::HON_MODE_ECO: + this->last_active_mode_ = water_heater::WATER_HEATER_MODE_ECO; + break; + case HonMode::HON_MODE_BPS: + this->last_active_mode_ = water_heater::WATER_HEATER_MODE_PERFORMANCE; + break; + case HonMode::HON_MODE_UNKNOWN: + break; + } + // The base entity has no Unknown mode; the text sensor preserves the decoded result. + this->mode_ = status.powered ? this->last_active_mode_ : water_heater::WATER_HEATER_MODE_OFF; + if (this->primary_sensor_ != nullptr) + this->primary_sensor_->publish_state(status.current_temperature); + if (this->secondary_sensor_ != nullptr) { + this->secondary_sensor_->publish_state( + status.secondary_temperature <= MAX_REPORTED_TEMPERATURE ? status.secondary_temperature : NAN); + } + if (this->target_sensor_ != nullptr) + this->target_sensor_->publish_state(status.target_temperature); + if (this->heating_time_sensor_ != nullptr) + this->heating_time_sensor_->publish_state(status.estimated_heating_time); + if (this->water_sensor_ != nullptr) { + // Three segments of available hot water, published as 0, 33, 66 or 100 percent. + const uint8_t percent = status.available_water * 100 / 3; + this->water_sensor_->publish_state(status.available_water <= 3 ? percent : NAN); + } + if (this->power_sensor_ != nullptr) + this->power_sensor_->publish_state(status.powered); + if (this->heating_sensor_ != nullptr) + this->heating_sensor_->publish_state(status.heating); + if (this->mode_sensor_ != nullptr) { + char label[HonModeLabels::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(label, + reinterpret_cast(HonModeLabels::get_progmem_str( + static_cast(status.mode), static_cast(HonMode::HON_MODE_UNKNOWN))), + sizeof(label)); + this->mode_sensor_->publish_state(label); + } + this->publish_state(); +} + +void HonWaterHeater::dump_config() { + LOG_WATER_HEATER("", "Haier hOn", this); + ESP_LOGCONFIG(TAG, + " Polling interval: %u ms\n" + " Communication timeout: %u ms", + static_cast(POLL_INTERVAL_MS), static_cast(this->communication_timeout_)); + LOG_SENSOR(" ", "Primary temperature", this->primary_sensor_); + LOG_SENSOR(" ", "Secondary temperature", this->secondary_sensor_); + LOG_SENSOR(" ", "Target temperature", this->target_sensor_); + LOG_SENSOR(" ", "Estimated heating time", this->heating_time_sensor_); + LOG_SENSOR(" ", "Available water", this->water_sensor_); + LOG_BINARY_SENSOR(" ", "Communication", this->communication_sensor_); + LOG_BINARY_SENSOR(" ", "Power", this->power_sensor_); + LOG_BINARY_SENSOR(" ", "Heating", this->heating_sensor_); + LOG_TEXT_SENSOR(" ", "Mode", this->mode_sensor_); +} + +water_heater::WaterHeaterCallInternal HonWaterHeater::make_call() { + return water_heater::WaterHeaterCallInternal(this); +} + +// The appliance mode a water heater mode maps to; Unknown for modes the appliance does not have. +static HonMode hon_mode(water_heater::WaterHeaterMode mode) { + switch (mode) { + case water_heater::WATER_HEATER_MODE_ELECTRIC: + return HonMode::HON_MODE_NORMAL; + case water_heater::WATER_HEATER_MODE_ECO: + return HonMode::HON_MODE_ECO; + case water_heater::WATER_HEATER_MODE_PERFORMANCE: + return HonMode::HON_MODE_BPS; + default: + return HonMode::HON_MODE_UNKNOWN; + } +} + +void HonWaterHeater::control(const water_heater::WaterHeaterCall &call) { + using namespace water_heater; + const auto mode = call.get_mode(); + const auto on = call.get_on(); + const bool off = (on.has_value() && !*on) || (mode.has_value() && *mode == WATER_HEATER_MODE_OFF); + if (off) { + // Keep only the command in flight until its reply or timeout; unsent writes are dropped. + if (this->awaiting_ == Awaiting::AWAITING_CONTROL) { + this->commands_[0] = this->commands_[this->command_index_]; + this->command_index_ = 0; + this->command_count_ = 1; + } else { + this->command_count_ = 0; + } + this->pending_off_ = true; + return; + } + if (!this->have_status_ || App.get_loop_component_start_time() - this->last_status_ > CONTROL_STATUS_MAX_AGE_MS) { + ESP_LOGW(TAG, "Control rejected: no recent appliance status"); + return; + } + if (this->command_count_ != 0 || this->pending_off_) { + ESP_LOGW(TAG, "Control busy; wait for the previous request to finish"); + return; + } + const float temperature = call.get_target_temperature(); + if (!std::isnan(temperature)) { + if (!std::isfinite(temperature) || temperature < MIN_TARGET_TEMPERATURE || temperature > MAX_TARGET_TEMPERATURE || + std::round(temperature) != temperature) { + ESP_LOGW(TAG, "Target must be a whole temperature from %u to %u C", MIN_TARGET_TEMPERATURE, + MAX_TARGET_TEMPERATURE); + return; + } + const bool normal = + mode.has_value() ? *mode == WATER_HEATER_MODE_ELECTRIC : this->reported_mode_ == HonMode::HON_MODE_NORMAL; + if (!normal) { + ESP_LOGW(TAG, "Target can only be set in Normal mode"); + return; + } + } + this->command_index_ = 0; + auto append = [this](Parameter parameter, uint8_t value) { + this->commands_[this->command_count_++] = {parameter, value}; + }; + // A write is only queued when it changes something, so a repeated state costs no round trip. + if (!this->is_on() && ((on.has_value() && *on) || mode.has_value())) + append(Parameter::PARAMETER_POWER, 1); + if (mode.has_value() && hon_mode(*mode) != this->reported_mode_) { + // Captures show the appliance moves between ECO and BPS through Normal, never directly. + if (*mode != WATER_HEATER_MODE_ELECTRIC && this->reported_mode_ != HonMode::HON_MODE_NORMAL) + append(Parameter::PARAMETER_ECO, 0); + switch (*mode) { + case WATER_HEATER_MODE_ELECTRIC: + append(Parameter::PARAMETER_ECO, 0); + break; + case WATER_HEATER_MODE_ECO: + append(Parameter::PARAMETER_ECO, 1); + break; + case WATER_HEATER_MODE_PERFORMANCE: + append(Parameter::PARAMETER_BPS, 1); + break; + default: + break; + } + } + if (!std::isnan(temperature)) + append(Parameter::PARAMETER_TARGET_TEMPERATURE, static_cast(temperature - TARGET_TEMPERATURE_OFFSET)); +} + +water_heater::WaterHeaterTraits HonWaterHeater::traits() { + using namespace water_heater; + WaterHeaterTraits traits; + traits.set_supports_current_temperature(true); + traits.add_feature_flags(WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE | WATER_HEATER_SUPPORTS_OPERATION_MODE | + WATER_HEATER_SUPPORTS_ON_OFF); + traits.set_supported_modes( + {WATER_HEATER_MODE_OFF, WATER_HEATER_MODE_ELECTRIC, WATER_HEATER_MODE_ECO, WATER_HEATER_MODE_PERFORMANCE}); + traits.set_min_temperature(MIN_TARGET_TEMPERATURE); + traits.set_max_temperature(MAX_TARGET_TEMPERATURE); + traits.set_target_temperature_step(1); + return traits; +} + +} // namespace esphome::haier_water_heater diff --git a/esphome/components/haier_water_heater/hon_water_heater.h b/esphome/components/haier_water_heater/hon_water_heater.h new file mode 100644 index 00000000000..5f8ede184e7 --- /dev/null +++ b/esphome/components/haier_water_heater/hon_water_heater.h @@ -0,0 +1,96 @@ +#pragma once + +#include "hon_parser.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/water_heater/water_heater.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" + +namespace esphome::haier_water_heater { + +class HonWaterHeater : public Component, public water_heater::WaterHeater, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + water_heater::WaterHeaterCallInternal make_call() override; + + void set_communication_timeout(uint32_t value) { this->communication_timeout_ = value; } + void set_primary_temperature_sensor(sensor::Sensor *value) { this->primary_sensor_ = value; } + void set_secondary_temperature_sensor(sensor::Sensor *value) { this->secondary_sensor_ = value; } + void set_target_temperature_sensor(sensor::Sensor *value) { this->target_sensor_ = value; } + void set_estimated_heating_time_sensor(sensor::Sensor *value) { this->heating_time_sensor_ = value; } + void set_available_water_sensor(sensor::Sensor *value) { this->water_sensor_ = value; } + void set_communication_sensor(binary_sensor::BinarySensor *value) { this->communication_sensor_ = value; } + void set_power_sensor(binary_sensor::BinarySensor *value) { this->power_sensor_ = value; } + void set_heating_sensor(binary_sensor::BinarySensor *value) { this->heating_sensor_ = value; } + void set_mode_sensor(text_sensor::TextSensor *value) { this->mode_sensor_ = value; } + + protected: + static constexpr uint32_t NETWORK_STATUS_INTERVAL_ID = 0; + static constexpr uint32_t STATUS_POLL_INTERVAL_ID = 1; + static constexpr uint32_t NETWORK_STATUS_INTERVAL_MS = 500; + static constexpr uint32_t POLL_INTERVAL_MS = 5000; + static constexpr uint32_t RESPONSE_TIMEOUT_MS = 5000; // a polled or controlled reply + static constexpr uint32_t NETWORK_ACK_TIMEOUT_MS = 2000; // the CONFIRM of a network status + static constexpr uint32_t NETWORK_REFRESH_MS = 30000; // resend the network status this often + static constexpr uint32_t CONTROL_STATUS_MAX_AGE_MS = 15000; // a control needs a status this fresh + static constexpr uint32_t FRAME_GAP_MS = 300; // silence that ends a partial frame + static constexpr uint8_t POLL_CYCLE = 12; // version and id are re-read once per cycle + static constexpr uint8_t MAX_BYTES_PER_LOOP = 255; + static constexpr uint8_t MIN_TARGET_TEMPERATURE = 35; + static constexpr uint8_t MAX_TARGET_TEMPERATURE = 75; + static constexpr uint8_t NETWORK_READY = 0x07; + static constexpr uint8_t NETWORK_NOT_READY = 0x05; + + // The link carries one request at a time; this is the one whose answer is outstanding. + enum class Awaiting : uint8_t { AWAITING_NONE, AWAITING_POLL, AWAITING_NETWORK, AWAITING_CONTROL }; + + void control(const water_heater::WaterHeaterCall &call) override; + water_heater::WaterHeaterTraits traits() override; + void check_timeouts_(uint32_t now); + void send_pending_(uint32_t now); + void read_frames_(uint32_t now); + void poll_status_(); + void update_network_status_(); + void send_frame_(water_heater_protocol::FrameType type, const uint8_t *payload = nullptr, size_t size = 0); + void send_request_(uint32_t now, Awaiting kind, water_heater_protocol::FrameType type, const uint8_t *payload, + size_t size, water_heater_protocol::FrameType expected); + void send_next_control_(uint32_t now); + void process_frame_(); + void handle_frame_type_(water_heater_protocol::FrameType type); + void confirm_control_(const water_heater_protocol::Status &status); + void publish_status_(const water_heater_protocol::Status &status); + + water_heater_protocol::FrameParser parser_; + sensor::Sensor *primary_sensor_{nullptr}; + sensor::Sensor *secondary_sensor_{nullptr}; + sensor::Sensor *target_sensor_{nullptr}; + sensor::Sensor *heating_time_sensor_{nullptr}; + sensor::Sensor *water_sensor_{nullptr}; + binary_sensor::BinarySensor *communication_sensor_{nullptr}; + binary_sensor::BinarySensor *power_sensor_{nullptr}; + binary_sensor::BinarySensor *heating_sensor_{nullptr}; + text_sensor::TextSensor *mode_sensor_{nullptr}; + uint32_t request_sent_{0}; + uint32_t network_sent_{0}; + uint32_t last_status_{0}; + uint32_t last_byte_{0}; + uint32_t communication_timeout_{20000}; + water_heater::WaterHeaterMode last_active_mode_{water_heater::WATER_HEATER_MODE_ELECTRIC}; + std::array commands_{}; // power, to Normal, mode, temperature at most + water_heater_protocol::HonMode reported_mode_{water_heater_protocol::HonMode::HON_MODE_UNKNOWN}; + water_heater_protocol::FrameType expected_type_{water_heater_protocol::FrameType::FRAME_TYPE_STATUS}; + Awaiting awaiting_{Awaiting::AWAITING_NONE}; + uint8_t network_status_{NETWORK_NOT_READY}; + uint8_t command_count_{0}; + uint8_t command_index_{0}; + uint8_t poll_stage_{0}; + bool network_pending_{true}; + bool have_status_{false}; + bool pending_off_{false}; +}; + +} // namespace esphome::haier_water_heater diff --git a/esphome/components/haier_water_heater/water_heater.py b/esphome/components/haier_water_heater/water_heater.py new file mode 100644 index 00000000000..33a54a815b0 --- /dev/null +++ b/esphome/components/haier_water_heater/water_heater.py @@ -0,0 +1,110 @@ +"""Local controller for Haier hOn water heaters.""" + +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor, text_sensor, uart, water_heater +import esphome.config_validation as cv +from esphome.const import ( + CONF_MODE, + CONF_POWER, + CONF_TARGET_TEMPERATURE, + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_HEAT, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_MINUTE, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CONF_AVAILABLE_WATER = "available_water" +CONF_COMMUNICATION = "communication" +CONF_COMMUNICATION_TIMEOUT = "communication_timeout" +CONF_ESTIMATED_HEATING_TIME = "estimated_heating_time" +CONF_HEATING = "heating" +CONF_PRIMARY_TEMPERATURE = "primary_temperature" +CONF_SECONDARY_TEMPERATURE = "secondary_temperature" + +CODEOWNERS = ["@TheLexus"] +DEPENDENCIES = ["uart"] +AUTO_LOAD = ["sensor", "binary_sensor", "text_sensor"] + +haier_water_heater_ns = cg.esphome_ns.namespace("haier_water_heater") +HonWaterHeater = haier_water_heater_ns.class_( + "HonWaterHeater", water_heater.WaterHeater, cg.Component, uart.UARTDevice +) + +_TEMPERATURE_SENSOR = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + water_heater.water_heater_schema(HonWaterHeater) + .extend( + { + cv.Optional( + CONF_COMMUNICATION_TIMEOUT, default="20s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_PRIMARY_TEMPERATURE): _TEMPERATURE_SENSOR, + cv.Optional(CONF_SECONDARY_TEMPERATURE): _TEMPERATURE_SENSOR, + cv.Optional(CONF_TARGET_TEMPERATURE): _TEMPERATURE_SENSOR, + cv.Optional(CONF_ESTIMATED_HEATING_TIME): sensor.sensor_schema( + unit_of_measurement=UNIT_MINUTE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_AVAILABLE_WATER): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COMMUNICATION): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_POWER): binary_sensor.binary_sensor_schema(), + cv.Optional(CONF_HEATING): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_HEAT + ), + cv.Optional(CONF_MODE): text_sensor.text_sensor_schema(), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "haier_water_heater", + baud_rate=9600, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = await water_heater.new_water_heater(config) + await cg.register_component(var, config) + cg.add(var.set_communication_timeout(config[CONF_COMMUNICATION_TIMEOUT])) + await uart.register_uart_device(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_PRIMARY_TEMPERATURE, var.set_primary_temperature_sensor) + await sensors(CONF_SECONDARY_TEMPERATURE, var.set_secondary_temperature_sensor) + await sensors(CONF_TARGET_TEMPERATURE, var.set_target_temperature_sensor) + await sensors(CONF_ESTIMATED_HEATING_TIME, var.set_estimated_heating_time_sensor) + await sensors(CONF_AVAILABLE_WATER, var.set_available_water_sensor) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_COMMUNICATION, var.set_communication_sensor) + await binary_sensors(CONF_POWER, var.set_power_sensor) + await binary_sensors(CONF_HEATING, var.set_heating_sensor) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_MODE, var.set_mode_sensor) diff --git a/tests/component_tests/haier_water_heater/__init__.py b/tests/component_tests/haier_water_heater/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/haier_water_heater/test_water_heater.py b/tests/component_tests/haier_water_heater/test_water_heater.py new file mode 100644 index 00000000000..518b5d26867 --- /dev/null +++ b/tests/component_tests/haier_water_heater/test_water_heater.py @@ -0,0 +1,115 @@ +"""Tests for the Haier water heater configuration.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import uart +from esphome.components.haier_water_heater.water_heater import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +from esphome.const import PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + + +def test_visible_by_default(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({"name": "Heater capture"}) + assert config.get("internal", False) is False + assert "protocol" not in config + assert config["communication_timeout"].total_milliseconds == 20000 + + +@pytest.mark.parametrize("protocol", ["HON", "SMARTAIR2", "unknown"]) +def test_rejects_protocol_option( + set_core_config: SetCoreConfigCallable, protocol: str +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"name": "Heater capture", "protocol": protocol}) + + +def test_rejects_climate_control_options( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA( + {"name": "Heater capture", "control_method": "SET_GROUP_PARAMETERS"} + ) + + +@pytest.mark.parametrize("missing", [None, "rx_pin", "tx_pin"]) +@pytest.mark.parametrize( + ("setting", "value"), + [ + (None, None), + ("baud_rate", 115200), + ("data_bits", 7), + ("parity", "EVEN"), + ("stop_bits", 2), + ], +) +def test_uart_requirements( + set_core_config: SetCoreConfigCallable, + missing: str | None, + setting: str | None, + value: int | str | None, +) -> None: + bus_id = ID("heater_uart", is_declaration=True, type=uart.IDFUARTComponent) + bus = { + "id": bus_id, + "baud_rate": 9600, + "data_bits": 8, + "parity": "NONE", + "stop_bits": 1, + "rx_pin": {"number": 16}, + "tx_pin": {"number": 17}, + } + if missing is not None: + del bus[missing] + if setting is not None: + bus[setting] = value + full = Config() + full["uart"] = [bus] + full.declare_ids.append((bus_id, ["uart", 0, "id"])) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + config = {"uart_id": ID("heater_uart", type=uart.IDFUARTComponent)} + if missing is None and setting is None: + FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises(cv.Invalid): + FINAL_VALIDATE_SCHEMA(config) + + +def test_read_only_entities(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA( + { + "name": "Water Heater", + "internal": False, + "target_temperature": {"name": "Target"}, + "estimated_heating_time": {"name": "Estimated Heating Time"}, + "available_water": {"name": "Water Segments"}, + "communication": {"name": "Communication"}, + "power": {"name": "Power"}, + "heating": {"name": "Heating"}, + "mode": {"name": "Mode"}, + } + ) + assert config["target_temperature"]["unit_of_measurement"] == "°C" + assert config["estimated_heating_time"]["unit_of_measurement"] == "min" + assert config["estimated_heating_time"]["device_class"] == "duration" + assert config["communication"]["device_class"] == "connectivity" + assert config["heating"]["device_class"] == "heat" + assert config["internal"] is False + + +def test_custom_communication_timeout(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({"name": "Heater", "communication_timeout": "7500ms"}) + assert config["communication_timeout"].total_milliseconds == 7500 + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"name": "Heater", "communication_timeout": "-1s"}) diff --git a/tests/components/haier_water_heater/__init__.py b/tests/components/haier_water_heater/__init__.py new file mode 100644 index 00000000000..7a32bd70c15 --- /dev/null +++ b/tests/components/haier_water_heater/__init__.py @@ -0,0 +1,12 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Include the real entity classes used by the controller fixtures. + manifest.auto_load = [ + "uart", + "water_heater", + "sensor", + "binary_sensor", + "text_sensor", + ] diff --git a/tests/components/haier_water_heater/common.yaml b/tests/components/haier_water_heater/common.yaml new file mode 100644 index 00000000000..657fdf25334 --- /dev/null +++ b/tests/components/haier_water_heater/common.yaml @@ -0,0 +1,23 @@ +water_heater: + - platform: haier_water_heater + id: haier_water_heater_device + name: Haier Water Heater + communication_timeout: 25s + primary_temperature: + name: Primary temperature zone + secondary_temperature: + name: Secondary temperature zone + estimated_heating_time: + name: Haier Estimated Heating Time + communication: + name: Haier Communication + target_temperature: + name: Haier Target Temperature + available_water: + name: Haier Available Water + power: + name: Haier Power + heating: + name: Haier Heating + mode: + name: Haier Mode diff --git a/tests/components/haier_water_heater/test.esp32-idf.yaml b/tests/components/haier_water_heater/test.esp32-idf.yaml new file mode 100644 index 00000000000..dd692146863 --- /dev/null +++ b/tests/components/haier_water_heater/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + haier_water_heater: !include common.yaml diff --git a/tests/components/haier_water_heater/test.esp8266-ard.yaml b/tests/components/haier_water_heater/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1c3b570d552 --- /dev/null +++ b/tests/components/haier_water_heater/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + haier_water_heater: !include common.yaml diff --git a/tests/components/haier_water_heater/test.rp2040-ard.yaml b/tests/components/haier_water_heater/test.rp2040-ard.yaml new file mode 100644 index 00000000000..b7119efb4ab --- /dev/null +++ b/tests/components/haier_water_heater/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + haier_water_heater: !include common.yaml diff --git a/tests/components/haier_water_heater/test_hon_protocol.cpp b/tests/components/haier_water_heater/test_hon_protocol.cpp new file mode 100644 index 00000000000..f9feadf8a79 --- /dev/null +++ b/tests/components/haier_water_heater/test_hon_protocol.cpp @@ -0,0 +1,219 @@ +#include "esphome/components/haier_water_heater/hon_parser.h" +#include +#include +#include +#include +using namespace esphome::haier_water_heater::water_heater_protocol; +namespace esphome::haier_water_heater::testing { +std::vector bytes(const char *hex) { + std::istringstream stream(hex); + unsigned byte; + std::vector result; + while (stream >> std::hex >> byte) + result.push_back(byte); + return result; +} +unsigned feed(FrameParser &parser, const std::vector &wire) { + unsigned count = 0; + for (auto byte : wire) + count += parser.feed(byte); + return count; +} +unsigned feed_head(FrameParser &parser, const std::vector &wire, size_t count) { + unsigned frames = 0; + for (size_t i = 0; i < count; i++) + frames += parser.feed(wire[i]); + return frames; +} +TEST(HonProtocolTest, EncodesRequestsAndEscapesBytes) { + FrameParser parser; + uint8_t wire[MAX_WIRE_FRAME]; + const uint8_t version[] = {0x00, 0x07}; + const uint8_t query[] = {0x4D, 0x01}; + auto check_tx = [&](FrameType type, const uint8_t *payload, size_t size, const char *expected) { + const auto count = encode_frame(type, payload, size, wire); + EXPECT_EQ(std::vector(wire, wire + count), bytes(expected)); + ASSERT_EQ(feed(parser, std::vector(wire, wire + count)), 1u); + EXPECT_EQ(parser.data()[FRAME_FLAGS_OFFSET], 0); + EXPECT_EQ(parser.data()[FRAME_TYPE_OFFSET], static_cast(type)); + }; + check_tx(FrameType::FRAME_TYPE_GET_VERSION, version, 2, "FF FF 0A 00 00 00 00 00 00 61 00 07 72"); + check_tx(FrameType::FRAME_TYPE_GET_DEVICE_ID, nullptr, 0, "FF FF 08 00 00 00 00 00 00 70 78"); + check_tx(FrameType::FRAME_TYPE_CONTROL, query, 2, "FF FF 0A 00 00 00 00 00 00 01 4D 01 59"); + check_tx(FrameType::FRAME_TYPE_CONFIRM, nullptr, 0, "FF FF 08 00 00 00 00 00 00 05 0D"); + auto check_control = [&](Command command, const char *expected) { + uint8_t payload[4]; + encode_control(command, payload); + check_tx(FrameType::FRAME_TYPE_CONTROL, payload, sizeof(payload), expected); + }; + check_control({Parameter::PARAMETER_POWER, 1}, "FF FF 0C 00 00 00 00 00 00 01 5D 01 00 01 6C"); + check_control({Parameter::PARAMETER_ECO, 1}, "FF FF 0C 00 00 00 00 00 00 01 5D 05 00 01 70"); + check_control({Parameter::PARAMETER_ECO, 0}, "FF FF 0C 00 00 00 00 00 00 01 5D 05 00 00 6F"); + check_control({Parameter::PARAMETER_BPS, 1}, "FF FF 0C 00 00 00 00 00 00 01 5D 17 00 01 82"); + check_control({Parameter::PARAMETER_TARGET_TEMPERATURE, 6}, "FF FF 0C 00 00 00 00 00 00 01 5D 02 00 06 72"); + const uint8_t network[] = {0, 7, 0, 100}; + check_tx(FrameType::FRAME_TYPE_NETWORK_STATUS, network, 4, "FF FF 0C 00 00 00 00 00 00 F7 00 07 00 64 6E"); + const uint8_t escaped[] = {0xFF, 0x55}; + const size_t escaped_size = encode_frame(FrameType::FRAME_TYPE_CONTROL, escaped, 2, wire); + EXPECT_EQ(feed(parser, std::vector(wire, wire + escaped_size)), 1u); + EXPECT_EQ(encode_frame(FrameType::FRAME_TYPE_CONTROL, escaped, 5, wire), 0u); +} + +struct StatusCase { + const char *name; + const char *wire; + uint8_t current_temperature; + uint8_t target_temperature; + uint8_t estimated_heating_time; + bool powered; + bool heating; + HonMode mode; + uint8_t available_water; +}; + +// Captured from a VH3W on 2026-10-14; the name gives the packet of the session. +static const StatusCase STATUS_CASES[] = { + {"packet 4", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 00 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 10 00 00 00 00 00 02 00 00 00 00 00 00 2E", + 53, 35, 0, false, false, HonMode::HON_MODE_NORMAL, 2}, + {"packet 33", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 00 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 11 00 00 00 00 00 02 00 00 00 00 00 00 2F", + 53, 35, 0, true, false, HonMode::HON_MODE_NORMAL, 2}, + {"packet 36", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 00 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 00 01 00 04 00 00 00 02 00 00 00 00 00 00 13", + 53, 35, 0, true, false, HonMode::HON_MODE_ECO, 2}, + {"packet 39", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 32 00 00 00 1D 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 00 01 04 01 00 00 00 02 00 00 00 00 00 00 6D", + 53, 80, 29, true, true, HonMode::HON_MODE_BPS, 2}, + {"packet 50", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 01 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 00 01 00 04 00 00 00 02 00 00 00 00 00 00 14", + 53, 35, 0, true, false, HonMode::HON_MODE_ECO, 2}, + {"packet 52", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 32 00 01 00 1D 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 00 01 04 01 00 00 00 02 00 00 00 00 00 00 6E", + 53, 80, 29, true, true, HonMode::HON_MODE_BPS, 2}, + {"packet 55", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 01 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 11 00 00 00 00 00 02 00 00 00 00 00 00 30", + 53, 35, 0, true, false, HonMode::HON_MODE_NORMAL, 2}, + {"packet 58", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 01 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 10 00 00 00 00 00 02 00 00 00 00 00 00 2F", + 53, 35, 0, false, false, HonMode::HON_MODE_NORMAL, 2}, + {"target 35 C", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 1E 05 00 00 00 06 00 00 00 32 19 00 00 1E 00 00 00 00 13 00 " + "2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 10 00 00 00 00 00 00 00 00 00 00 00 00 04", + 30, 35, 6, false, false, HonMode::HON_MODE_NORMAL, 0}, + {"target 40 C", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 1E 0A 00 01 00 0B 00 00 00 32 19 00 00 1E 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 10 11 00 01 00 00 00 00 00 00 00 00 00 00 20", + 30, 40, 11, true, true, HonMode::HON_MODE_NORMAL, 0}, + {"target 75 C", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 1F 2D 00 01 00 2F 00 00 00 32 19 00 00 1F 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 10 11 80 01 00 00 00 00 00 00 00 00 00 00 E9", + 31, 75, 47, true, true, HonMode::HON_MODE_NORMAL, 0}, + {"target back to 35 C", + "FF FF 3C 00 00 00 00 00 00 06 6D 01 20 05 00 01 00 04 00 00 00 32 19 00 00 20 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 10 11 00 01 00 00 00 00 00 00 00 00 00 00 18", + 32, 35, 4, true, true, HonMode::HON_MODE_NORMAL, 0}, +}; + +TEST(HonProtocolTest, DecodesCapturedStatusFrames) { + FrameParser parser; + for (const auto &expected : STATUS_CASES) { + SCOPED_TRACE(expected.name); + Status status{}; + ASSERT_EQ(feed(parser, bytes(expected.wire)), 1u); + ASSERT_TRUE(decode_status(parser.data(), parser.size(), status)); + EXPECT_EQ(status.current_temperature, expected.current_temperature); + EXPECT_EQ(status.target_temperature, expected.target_temperature); + EXPECT_EQ(status.estimated_heating_time, expected.estimated_heating_time); + EXPECT_EQ(status.powered, expected.powered); + EXPECT_EQ(status.heating, expected.heating); + EXPECT_EQ(status.mode, expected.mode); + EXPECT_EQ(status.available_water, expected.available_water); + } +} + +TEST(HonProtocolTest, ConfirmsCommandsAgainstStatus) { + Status status{}; + status.powered = true; + status.target_temperature = 40; + status.mode = HonMode::HON_MODE_ECO; + EXPECT_TRUE(command_confirmed({Parameter::PARAMETER_POWER, 1}, status)); + EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_POWER, 0}, status)); + EXPECT_TRUE(command_confirmed({Parameter::PARAMETER_TARGET_TEMPERATURE, 10}, status)); + EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_TARGET_TEMPERATURE, 11}, status)); + EXPECT_TRUE(command_confirmed({Parameter::PARAMETER_ECO, 1}, status)); + EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_ECO, 0}, status)); + EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_BPS, 1}, status)); +} + +TEST(HonProtocolTest, KeepsTemperatureZonesIndependent) { + Status status{}; + uint8_t independent[61] = {}; + independent[0] = STATUS_FRAME_LENGTH; + independent[FRAME_TYPE_OFFSET] = static_cast(FrameType::FRAME_TYPE_STATUS); + independent[8] = 0x6D; + independent[9] = 1; + independent[10] = 42; + independent[11] = 5; + independent[23] = 43; + ASSERT_TRUE(decode_status(independent, sizeof(independent), status)); + EXPECT_EQ(status.current_temperature, 42); + EXPECT_EQ(status.secondary_temperature, 43); +} + +TEST(HonProtocolTest, RejectsCorruptionAndResynchronizes) { + FrameParser parser; + Status status{}; + auto valid = bytes("FF FF 3C 00 00 00 00 00 00 06 6D 01 35 32 00 00 00 1D 00 00 00 32 19 00 00 35 00 00 00 00 13 00 " + "2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 00 01 04 01 00 00 00 02 00 00 00 00 00 00 6D"); + auto damaged = valid; + damaged.back() ^= 1; + EXPECT_EQ(feed(parser, damaged), 0u); + EXPECT_EQ(feed(parser, valid), 1u); + parser.reset(); + EXPECT_EQ(feed_head(parser, valid, 20), 0u); + parser.reset(); // Partial frame timeout. + EXPECT_EQ(feed(parser, valid), 1u); + parser.reset(); + EXPECT_EQ(feed_head(parser, valid, 20), 0u); + EXPECT_EQ(feed(parser, valid), 1u); // New separator resynchronizes. + EXPECT_EQ(feed(parser, bytes("FF FF 07 7F FF 55 DA")), 0u); + EXPECT_EQ(feed(parser, valid), 1u); + auto crc = bytes("FF FF 0D 40 00 00 00 00 00 01 4D 01 FF 55 BB FF 55 FF 55 D1 3C"); + EXPECT_EQ(feed(parser, crc), 1u); + EXPECT_FALSE(decode_status(parser.data(), parser.size(), status)); + crc.back() ^= 1; + EXPECT_EQ(feed(parser, crc), 0u); + EXPECT_EQ(feed(parser, bytes("FF FF 08 00 00 00 00 00 00 05 0D")), 1u); + EXPECT_FALSE(decode_status(parser.data(), 0, status)); +} + +TEST(HonProtocolTest, ValidatesFramesLongerThanTheStoredHead) { + FrameParser parser; + std::vector frame(MAX_FRAME_LENGTH + 1, 0); + frame[0] = MAX_FRAME_LENGTH; + frame[FRAME_TYPE_OFFSET] = static_cast(FrameType::FRAME_TYPE_VERSION); + frame[MAX_FRAME_LENGTH - 1] = 0x7E; // a byte past the stored head still counts + uint8_t checksum = 0; + for (size_t i = 0; i < MAX_FRAME_LENGTH; i++) + checksum += frame[i]; + frame[MAX_FRAME_LENGTH] = checksum; + std::vector wire = {0xFF, 0xFF}; + wire.insert(wire.end(), frame.begin(), frame.end()); + EXPECT_EQ(feed(parser, wire), 1u); + EXPECT_EQ(parser.size(), MAX_FRAME_LENGTH + 1u); + EXPECT_EQ(parser.stored_size(), MAX_STORED_FRAME); + EXPECT_EQ(parser.data()[FRAME_TYPE_OFFSET], static_cast(FrameType::FRAME_TYPE_VERSION)); + wire.back() ^= 1; + EXPECT_EQ(feed(parser, wire), 0u); + EXPECT_TRUE(parser.complete()); +} +} // namespace esphome::haier_water_heater::testing diff --git a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp new file mode 100644 index 00000000000..41b40687d48 --- /dev/null +++ b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp @@ -0,0 +1,292 @@ +#include +#include +#include +#include +#include + +#include "esphome/components/haier_water_heater/hon_water_heater.h" +#include "esphome/core/application.h" + +namespace esphome::haier_water_heater::testing { + +class RecordingUART : public uart::UARTComponent { + public: + std::deque rx; + std::vector> sent; + void write_array(const uint8_t *data, size_t size) override { this->sent.emplace_back(data, data + size); } + bool read_array(uint8_t *data, size_t size) override { + if (this->rx.size() < size) + return false; + for (size_t i = 0; i < size; i++) { + data[i] = this->rx.front(); + this->rx.pop_front(); + } + return true; + } + bool peek_byte(uint8_t *data) override { + if (this->rx.empty()) + return false; + *data = this->rx.front(); + return true; + } + size_t available() override { return this->rx.size(); } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + void check_logger_conflict() override {} +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +class TestableHeater : public HonWaterHeater { + public: + using HonWaterHeater::poll_status_; + void set_network_pending(bool value) { this->network_pending_ = value; } + bool waiting_poll() const { return this->awaiting_ == Awaiting::AWAITING_POLL; } + bool pending_off() const { return this->pending_off_; } + uint8_t command_count() const { return this->command_count_; } + void cancel_test_intervals() { + this->cancel_interval(NETWORK_STATUS_INTERVAL_ID); + this->cancel_interval(STATUS_POLL_INTERVAL_ID); + } +}; + +class HonControllerTest : public ::testing::Test { + protected: + void SetUp() override { + // The test runner skips generated setup(), which normally constructs App and its scheduler. + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + this->heater_.set_uart_parent(&this->uart_); + this->heater_.set_network_pending(false); + this->set_clock_(100); + } + void TearDown() override { this->heater_.cancel_test_intervals(); } + void set_clock_(uint32_t ms) { LoopBlockingGuard clock{nullptr, nullptr, ms}; } + void status_(bool powered, bool unknown = false, bool eco = false, bool bps = false) { + std::array core{}; + core[0] = 60; + core[7] = 2; + core[8] = 0x6D; + core[9] = 1; + core[10] = 42; + core[11] = 5; + core[23] = 43; + core[46] = (unknown || eco || bps) ? 0 : 16; + core[47] = ((unknown || eco || bps) ? 0 : 16) | (powered ? 1 : 0); + core[48] = bps ? 4 : 0; + core[49] = eco ? 4 : 0; + for (size_t i = 0; i < 60; i++) { + core[60] += core[i]; + if (core[i] == 0xFF) + core[60] += 0x55; + } + this->uart_.rx.insert(this->uart_.rx.end(), {0xFF, 0xFF}); + for (uint8_t byte : core) { + this->uart_.rx.push_back(byte); + if (byte == 0xFF) + this->uart_.rx.push_back(0x55); + } + this->heater_.loop(); + } + void request_bps_() { + auto call = this->heater_.make_call(); + call.set_mode(water_heater::WATER_HEATER_MODE_PERFORMANCE); + call.perform(); + this->heater_.loop(); + } + void reject_() { + uint8_t wire[water_heater_protocol::MAX_WIRE_FRAME]; + const size_t count = + water_heater_protocol::encode_frame(water_heater_protocol::FrameType::FRAME_TYPE_INVALID, nullptr, 0, wire); + this->uart_.rx.insert(this->uart_.rx.end(), wire, wire + count); + this->heater_.loop(); + } + void request_off_() { + auto call = this->heater_.make_call(); + call.set_on(false); + call.perform(); + } + void expect_off_() { + ASSERT_FALSE(this->uart_.sent.empty()); + const std::vector expected = {0xFF, 0xFF, 0x0C, 0, 0, 0, 0, 0, 0, 1, 0x5D, 1, 0, 0, 0x6B}; + EXPECT_EQ(this->uart_.sent.back(), expected); + } + RecordingUART uart_; + TestableHeater heater_; +}; + +TEST_F(HonControllerTest, OffIgnoresTemperatureWithoutStatus) { + auto call = this->heater_.make_call(); + call.set_mode(water_heater::WATER_HEATER_MODE_OFF); + call.set_target_temperature(40); + call.perform(); + this->heater_.loop(); + this->expect_off_(); +} + +TEST_F(HonControllerTest, OffSurvivesTimeoutAndCancelsQueuedMode) { + this->status_(false); + this->request_bps_(); + ASSERT_EQ(this->heater_.command_count(), 2); + auto call = this->heater_.make_call(); + call.set_on(false); + call.set_target_temperature(999); + call.perform(); + EXPECT_TRUE(this->heater_.pending_off()); + EXPECT_EQ(this->heater_.command_count(), 1); + this->set_clock_(5101); + this->heater_.loop(); + this->expect_off_(); + EXPECT_FALSE(this->heater_.pending_off()); + EXPECT_EQ(this->uart_.sent.size(), 2); +} + +TEST_F(HonControllerTest, ActiveModeIsNotRequestedAgain) { + this->status_(true, false, false, true); + this->request_bps_(); + EXPECT_EQ(this->heater_.command_count(), 0); + EXPECT_TRUE(this->uart_.sent.empty()); + auto call = this->heater_.make_call(); + call.set_mode(water_heater::WATER_HEATER_MODE_ECO); + call.perform(); + EXPECT_EQ(this->heater_.command_count(), 2); // through Normal, then ECO +} + +TEST_F(HonControllerTest, PowerOnIsNotRequestedAgain) { + this->status_(true); + auto call = this->heater_.make_call(); + call.set_on(true); + call.set_target_temperature(40); + call.perform(); + EXPECT_EQ(this->heater_.command_count(), 1); // the temperature only +} + +TEST_F(HonControllerTest, OffFollowsConfirmedInFlightCommand) { + this->status_(false); + this->request_bps_(); + this->request_off_(); + this->set_clock_(200); + this->status_(true); + this->heater_.loop(); + this->expect_off_(); + EXPECT_EQ(this->uart_.sent.size(), 2); +} + +TEST_F(HonControllerTest, CommunicationTimeoutAndRecovery) { + binary_sensor::BinarySensor health; + this->heater_.set_communication_sensor(&health); + this->heater_.setup(); + ASSERT_TRUE(health.has_state()); + EXPECT_FALSE(health.state); + EXPECT_TRUE(this->heater_.status_has_warning()); + this->status_(false); + EXPECT_TRUE(health.state); + EXPECT_FALSE(this->heater_.status_has_warning()); + this->set_clock_(20099); + this->heater_.loop(); + EXPECT_TRUE(health.state); + this->set_clock_(20100); + this->heater_.loop(); + EXPECT_FALSE(health.state); + EXPECT_TRUE(this->heater_.status_has_warning()); + this->heater_.loop(); + EXPECT_FALSE(health.state); + this->status_(false); + EXPECT_TRUE(health.state); + EXPECT_FALSE(this->heater_.status_has_warning()); +} + +TEST_F(HonControllerTest, ControlFromCommunicationRecoveryIsAccepted) { + binary_sensor::BinarySensor health; + this->heater_.set_communication_sensor(&health); + health.add_on_state_callback([this](bool state) { + if (!state) + return; + EXPECT_TRUE(this->heater_.is_on()); + auto call = this->heater_.make_call(); + call.set_target_temperature(40); + call.perform(); + }); + this->status_(true); + EXPECT_EQ(this->heater_.command_count(), 1); +} + +TEST_F(HonControllerTest, PollTimeoutReleasesNetworkUpdateThenPollingResumes) { + this->heater_.poll_status_(); + ASSERT_TRUE(this->heater_.waiting_poll()); + this->heater_.set_network_pending(true); + this->set_clock_(5099); + this->heater_.loop(); + ASSERT_EQ(this->uart_.sent.size(), 1); + this->set_clock_(5100); + this->heater_.poll_status_(); + this->heater_.loop(); + EXPECT_FALSE(this->heater_.waiting_poll()); + ASSERT_EQ(this->uart_.sent.size(), 2); + EXPECT_EQ(this->uart_.sent.back()[9], 0xF7); + this->set_clock_(7201); + this->heater_.loop(); + this->set_clock_(10100); + this->heater_.poll_status_(); + EXPECT_TRUE(this->heater_.waiting_poll()); + EXPECT_EQ(this->uart_.sent.back()[9], 0x70); +} + +TEST_F(HonControllerTest, OffFollowsPollTimeout) { + this->heater_.poll_status_(); + this->request_off_(); + this->set_clock_(5100); + this->heater_.loop(); + this->expect_off_(); +} + +TEST_F(HonControllerTest, RejectionDropsOnlyTheControlInFlight) { + this->status_(false); + this->heater_.poll_status_(); + ASSERT_TRUE(this->heater_.waiting_poll()); + this->request_bps_(); + ASSERT_EQ(this->heater_.command_count(), 2); + this->reject_(); + EXPECT_TRUE(this->heater_.waiting_poll()); + EXPECT_EQ(this->heater_.command_count(), 2); + this->set_clock_(5100); + this->heater_.loop(); // the poll times out and the first queued control goes out + ASSERT_EQ(this->uart_.sent.size(), 2); + EXPECT_EQ(this->uart_.sent.back()[10], 0x5D); + this->reject_(); + EXPECT_EQ(this->heater_.command_count(), 0); + EXPECT_FALSE(this->heater_.waiting_poll()); +} + +TEST_F(HonControllerTest, UnknownModeRetainsActiveModeAcrossOff) { + this->status_(true, true); + EXPECT_TRUE(this->heater_.is_on()); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ELECTRIC); + this->status_(true, false, true); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ECO); + this->status_(false, true); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_OFF); + this->status_(true, true); + EXPECT_TRUE(this->heater_.is_on()); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ECO); + this->status_(true); + EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ELECTRIC); +} + +TEST_F(HonControllerTest, CustomCommunicationTimeout) { + binary_sensor::BinarySensor health; + this->heater_.set_communication_sensor(&health); + this->heater_.set_communication_timeout(7500); + this->heater_.setup(); + this->status_(false); + this->set_clock_(7599); + this->heater_.loop(); + EXPECT_TRUE(health.state); + this->set_clock_(7600); + this->heater_.loop(); + EXPECT_FALSE(health.state); + this->status_(false); + EXPECT_TRUE(health.state); +} + +} // namespace esphome::haier_water_heater::testing From 38db8c1554b06084f45de08b7d890ca1e433ce32 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:00:55 +0200 Subject: [PATCH 466/651] [ci] Fail a hung integration test instead of waiting for the job limit (#20902) --- .github/workflows/ci.yml | 5 ++++- requirements_test.txt | 1 + script/integration_test | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d97352f65f4..cfb304ef1b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -395,6 +395,8 @@ jobs: - common - determine-jobs if: needs.determine-jobs.outputs.integration-tests == 'true' + # A hung test is failed by pytest's --timeout below; this bounds the rest + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -471,7 +473,8 @@ jobs: exit 1 fi echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" - pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal \ + # A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile + pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \ --junitxml=junit-integration.xml "${test_files[@]}" - name: Upload test artifacts # Tests that compare rendered output write the image they actually got here, so a diff --git a/requirements_test.txt b/requirements_test.txt index b36562727f8..68c4bbaf426 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,6 +9,7 @@ yamlrocks==0.6.1 # used by script/sync_dependency_versions.py pytest==9.1.1 pytest-cov==7.1.0 pytest-mock==3.16.0 +pytest-timeout==2.4.0 pytest-asyncio==1.4.0 pytest-xdist==3.8.0 asyncmock==0.4.2 diff --git a/script/integration_test b/script/integration_test index d637cdd2985..5339d655765 100755 --- a/script/integration_test +++ b/script/integration_test @@ -7,4 +7,4 @@ cd "${script_dir}/.." set -x -pytest -vvs --no-cov --tb=native -n 0 tests/integration/ +pytest -vvs --no-cov --tb=native -n 0 --timeout=1800 tests/integration/ From 94400124134bafe440e701de4817f3eb9dfc5592 Mon Sep 17 00:00:00 2001 From: Nicolas Liaudat Date: Fri, 16 Oct 2026 21:14:06 +0200 Subject: [PATCH 467/651] [mics_5524] Add MiCS-5524 gas sensor platform (1/2) (#19721) 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/mics_5524/__init__.py | 19 ++ esphome/components/mics_5524/coefficients.py | 32 +++ esphome/components/mics_5524/mics_5524.cpp | 206 ++++++++++++++++++ esphome/components/mics_5524/mics_5524.h | 104 +++++++++ esphome/components/mics_5524/mics_math.h | 59 +++++ esphome/components/mics_5524/sensor.py | 153 +++++++++++++ esphome/core/defines.h | 1 + tests/component_tests/mics_5524/__init__.py | 0 .../mics_5524/config/mics_5524.yaml | 37 ++++ .../mics_5524/test_mics_5524.py | 205 +++++++++++++++++ tests/components/mics_5524/common.h | 23 ++ tests/components/mics_5524/common.yaml | 22 ++ .../mics_5524/sensor/mics_math_test.cpp | 93 ++++++++ .../components/mics_5524/test.esp32-idf.yaml | 5 + .../mics_5524/test.esp8266-ard.yaml | 5 + .../components/mics_5524/test.rp2040-ard.yaml | 5 + .../mics_5524/validate.esp32-idf.yaml | 23 ++ 18 files changed, 993 insertions(+) create mode 100644 esphome/components/mics_5524/__init__.py create mode 100644 esphome/components/mics_5524/coefficients.py create mode 100644 esphome/components/mics_5524/mics_5524.cpp create mode 100644 esphome/components/mics_5524/mics_5524.h create mode 100644 esphome/components/mics_5524/mics_math.h create mode 100644 esphome/components/mics_5524/sensor.py create mode 100644 tests/component_tests/mics_5524/__init__.py create mode 100644 tests/component_tests/mics_5524/config/mics_5524.yaml create mode 100644 tests/component_tests/mics_5524/test_mics_5524.py create mode 100644 tests/components/mics_5524/common.h create mode 100644 tests/components/mics_5524/common.yaml create mode 100644 tests/components/mics_5524/sensor/mics_math_test.cpp create mode 100644 tests/components/mics_5524/test.esp32-idf.yaml create mode 100644 tests/components/mics_5524/test.esp8266-ard.yaml create mode 100644 tests/components/mics_5524/test.rp2040-ard.yaml create mode 100644 tests/components/mics_5524/validate.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 38d2da4d870..55f58dbe696 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -391,6 +391,7 @@ esphome/components/micro_wake_word/* @jesserockz @kahrendt esphome/components/micronova/* @edenhaus @jorre05 esphome/components/microphone/* @jesserockz @kahrendt esphome/components/mics_4514/* @jesserockz +esphome/components/mics_5524/* @nliaudat esphome/components/midea/* @dudanov esphome/components/midea_ir/* @dudanov esphome/components/midea_portasplit/* @Fexiven diff --git a/esphome/components/mics_5524/__init__.py b/esphome/components/mics_5524/__init__.py new file mode 100644 index 00000000000..1b1c4111470 --- /dev/null +++ b/esphome/components/mics_5524/__init__.py @@ -0,0 +1,19 @@ +"""MiCS-5524 gas sensor platform: the DFRobot_MICS vendor model on a voltage sampler.""" + +import esphome.codegen as cg +from esphome.components import sensor + +CODEOWNERS = ["@nliaudat"] +DOMAIN = "mics_5524" + +mics_5524_ns = cg.esphome_ns.namespace("mics_5524") + +MiCS5524Component = mics_5524_ns.class_("MiCS5524Component", cg.PollingComponent) +MiCS5524GasSensor = mics_5524_ns.class_("MiCS5524GasSensor", sensor.Sensor) + +CONF_VOLTAGE_MULTIPLIER = "voltage_multiplier" +CONF_VCC = "vcc" +CONF_AIR_REFERENCE = "air_reference" +CONF_SAMPLE_COUNT = "sample_count" +CONF_SAMPLE_INTERVAL = "sample_interval" +CONF_MAX_PPM = "max_ppm" diff --git a/esphome/components/mics_5524/coefficients.py b/esphome/components/mics_5524/coefficients.py new file mode 100644 index 00000000000..cb1b0551f75 --- /dev/null +++ b/esphome/components/mics_5524/coefficients.py @@ -0,0 +1,32 @@ +"""Gas table of the DFRobot_MICS vendor model; the formula lives in mics_math.h.""" + +from dataclasses import dataclass + +from esphome.const import ( + CONF_AMMONIA, + CONF_CARBON_MONOXIDE, + CONF_ETHANOL, + CONF_HYDROGEN, + CONF_METHANE, +) + + +@dataclass(frozen=True) +class VendorCurve: + """Constants of the vendor model for one gas.""" + + label: str # vendor name of the gas + threshold: float # ratio at or below which the gas counts as detected + gain: float # ppm per unit ratio + min_ppm: float # the vendor reports 0 ppm below this + max_ppm: float # upper end of the measuring range + + +# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges. +GASES: dict[str, VendorCurve] = { + CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), + CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), + CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), + CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), +} diff --git a/esphome/components/mics_5524/mics_5524.cpp b/esphome/components/mics_5524/mics_5524.cpp new file mode 100644 index 00000000000..2023661b369 --- /dev/null +++ b/esphome/components/mics_5524/mics_5524.cpp @@ -0,0 +1,206 @@ +#include "mics_5524.h" + +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mics_5524 { + +ESPHOME_LOG_TAG(TAG, "mics_5524"); + +void MiCS5524GasSensor::publish_ratio(float ratio) { + const float ppm = micsmath::ppm_from_vendor_curve(ratio, this->curve_); + ESP_LOGD(TAG, "'%s': ratio=%.4f -> %.1f ppm", this->get_name().c_str(), ratio, ppm); + this->publish_state(ppm); +} + +void MiCS5524Component::setup() { + if (this->has_reference_()) { + ESP_LOGI(TAG, "using the air reference from the configuration (%.4f V)", this->reference_); + } + + if (this->warmup_time_ > 0) { + this->stop_poller(); + this->set_timeout(this->warmup_time_, [this]() { this->start_poller(); }); + ESP_LOGI(TAG, "warm-up of %" PRIu32 " s - no readings published before that", this->warmup_time_ / 1000); + } + + if (this->calibration_samples_ > 0) { + // Nothing is stored across reboots; the warm-up window starts here, so the delay covers it. + const uint32_t delay = std::max(this->calibration_delay_, this->warmup_time_); + this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); + ESP_LOGI(TAG, "air reference calibration in %" PRIu32 " s - the sensor must be in clean air", delay / 1000); + } else if (!this->has_reference_()) { + ESP_LOGW(TAG, "no air reference available, the PPM values stay unknown\n" + " set 'air_reference:' or enable 'calibration:'"); + } + + // The loop only samples while a clean-air calibration runs; `update()` publishes. + this->disable_loop(); +} + +void MiCS5524Component::dump_config() { + ESP_LOGCONFIG(TAG, + "MiCS-5524:\n" + " VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n" + " Warm-up: %" PRIu32 " s, calibration: %s, sample spacing: %" PRIu32 " ms", + this->vcc_, this->voltage_multiplier_, static_cast(this->samples_), this->warmup_time_ / 1000, + this->calibration_samples_ > 0 ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), + this->sample_interval_); + LOG_UPDATE_INTERVAL(this); + if (this->has_reference_()) { + ESP_LOGCONFIG(TAG, " Air reference (vcc - V_AO): %.4f V", this->reference_); + } else { + ESP_LOGCONFIG(TAG, " Air reference: not available yet"); + } +#ifdef MICS_5524_GAS_COUNT + for (auto *gas : this->gases_) { + LOG_SENSOR(" ", "Gas", gas); + ESP_LOGCONFIG(TAG, " Range: %.1f - %.1f ppm", gas->get_min_ppm(), gas->get_max_ppm()); + } +#endif +} + +float MiCS5524Component::sample_voltage_() { + // Taken back to back; waiting between them would block the main loop. + float sum = 0.0f; + for (uint8_t i = 0; i < this->samples_; i++) + sum += this->source_->sample(); + return (sum / static_cast(this->samples_)) * this->voltage_multiplier_; +} + +void MiCS5524Component::publish_ratio_(float ratio) { +#ifdef MICS_5524_GAS_COUNT + for (auto *gas : this->gases_) { + gas->publish_ratio(ratio); + } +#endif +} + +float MiCS5524Component::read_air_value_() { + const float voltage = this->sample_voltage_(); + const float x = micsmath::air_value_from_voltage(voltage, this->vcc_); + if (std::isfinite(x)) + return x; + // The status flag makes this log once per fault, not once per update. + if (!this->status_has_warning()) { + ESP_LOGW(TAG, "analog output reads %.4f V with vcc %.2f V\n check the AO wiring, 'vcc:' and 'voltage_multiplier:'", + voltage, this->vcc_); + } + this->status_set_warning(LOG_STR("analog output out of range")); + return NAN; +} + +void MiCS5524Component::update() { + // Every gas starts unknown and the reference is never cleared, so there is nothing to republish. + if (!this->has_reference_()) + return; + + const float air_value = this->read_air_value_(); + if (std::isnan(air_value)) { + this->publish_ratio_(NAN); + return; + } + this->status_clear_warning(); + + const float ratio = micsmath::ratio_from_air_value(air_value, this->reference_); + ESP_LOGD(TAG, "V_AO=%.4f V, x=%.4f, ratio=%.4f", this->vcc_ - air_value, air_value, ratio); + this->publish_ratio_(ratio); +} + +void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval) { + this->calibration_delay_ = delay; + this->calibration_samples_ = samples; + this->sample_interval_ = sample_interval; +} + +void MiCS5524Component::loop() { + if (!this->calibrating_) { + // Only a calibration samples here; the deferred start is a timer. + this->disable_loop(); + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->calibration_last_sample_ < this->sample_interval_) + return; + + this->calibration_last_sample_ = now; + this->process_calibration_(); +} + +void MiCS5524Component::begin_calibration_() { + this->calibrating_ = true; + this->calibration_last_sample_ = App.get_loop_component_start_time(); + this->calibration_count_ = 0; + this->calibration_attempts_ = 0; + this->calibration_sum_ = 0.0f; + this->calibration_min_ = INFINITY; + this->calibration_max_ = -INFINITY; + this->enable_loop(); + + ESP_LOGI(TAG, "calibrating the air reference, %" PRIu32 " samples - keep the sensor in clean air", + this->calibration_samples_); +} + +void MiCS5524Component::process_calibration_() { + this->calibration_attempts_++; + + const float reference = this->read_air_value_(); + if (!std::isnan(reference)) { + this->calibration_min_ = std::min(this->calibration_min_, reference); + this->calibration_max_ = std::max(this->calibration_max_, reference); + this->calibration_sum_ += reference; + this->calibration_count_++; + } + + if (this->calibration_attempts_ >= this->calibration_samples_) + this->finish_calibration_(); +} + +void MiCS5524Component::finish_calibration_() { + this->calibrating_ = false; + this->disable_loop(); + + const uint32_t attempts = this->calibration_attempts_; + const uint32_t valid = this->calibration_count_; + // A majority of the attempts has to have produced a sample, two at least. + const uint32_t required = std::max((attempts + 1) / 2, 2); + + if (valid < required) { + ESP_LOGW(TAG, "calibration failed, %" PRIu32 " of %" PRIu32 " samples were valid", valid, attempts); + this->schedule_calibration_retry_(); + return; + } + + const float reference = this->calibration_sum_ / static_cast(valid); + + // A sensor still settling, or not in clean air, varies too much to be the reference. + if (!micsmath::calibration_is_stable(this->calibration_min_, this->calibration_max_, reference)) { + ESP_LOGW( + TAG, "clean-air samples spread %.4f V around %.4f V (%.4f - %.4f V)\n the sensor was not settled in clean air", + this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, this->calibration_max_); + this->schedule_calibration_retry_(); + return; + } + + this->reference_ = reference; + this->status_clear_warning(); + + ESP_LOGI(TAG, + "air reference = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)\n" + " hard-code 'air_reference: %.4f' to skip the calibration at boot", + this->reference_, valid, attempts, this->reference_); +} + +void MiCS5524Component::schedule_calibration_retry_() { + this->status_set_warning(); + // A zero delay would log the same failure about five times per second. + const uint32_t delay = std::max(this->calibration_delay_, CALIBRATION_RETRY_MIN_MS); + this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); + ESP_LOGW(TAG, "retrying the calibration in %" PRIu32 " s", delay / 1000); +} + +} // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_5524.h b/esphome/components/mics_5524/mics_5524.h new file mode 100644 index 00000000000..de7263e5d78 --- /dev/null +++ b/esphome/components/mics_5524/mics_5524.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/voltage_sampler/voltage_sampler.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "mics_math.h" + +namespace esphome::mics_5524 { + +/// One gas of a MiCS-5524: converts the measured ratio with the vendor curve of that gas. +class MiCS5524GasSensor : public sensor::Sensor { + public: + /// The vendor constants from coefficients.py. + MiCS5524GasSensor(float threshold, float gain, float min_ppm, float max_ppm) + : curve_{threshold, gain, min_ppm, max_ppm} {} + + /// Publish the concentration for `ratio`; NAN publishes `unknown`. + void publish_ratio(float ratio); + + float get_min_ppm() const { return this->curve_.min_ppm; } + float get_max_ppm() const { return this->curve_.max_ppm; } + + protected: + micsmath::VendorCurve curve_; +}; + +/// The MiCS-5524 breakout: one analog output, converted for every configured gas against +/// the clean-air reference x_air from `air_reference:` or a calibration at boot. +class MiCS5524Component : public PollingComponent { + public: + /// `source` must outlive this component, which code generation guarantees. + explicit MiCS5524Component(voltage_sampler::VoltageSampler *source) : source_(source) {} + + void setup() override; + void loop() override; + void update() override; + void dump_config() override; + +#ifdef MICS_5524_GAS_COUNT + void register_gas_sensor(MiCS5524GasSensor *gas) { this->gases_.push_back(gas); } +#endif + + void set_vcc(float vcc) { this->vcc_ = vcc; } + void set_voltage_multiplier(float multiplier) { this->voltage_multiplier_ = multiplier; } + void set_samples(uint8_t samples) { this->samples_ = samples; } + void set_warmup_time(uint32_t warmup) { this->warmup_time_ = warmup; } + /// Calibrate at boot: start after `delay` ms, average `samples` readings `sample_interval` ms apart. + void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval); + void set_air_reference(float air_reference) { this->reference_ = air_reference; } + + protected: + /// Averaged, scaled output voltage (V); the conversions run back to back. + float sample_voltage_(); + /// x = vcc - V_AO, or NAN (and a status warning) when the voltage is unusable. + float read_air_value_(); + void publish_ratio_(float ratio); + + void begin_calibration_(); + void process_calibration_(); + void finish_calibration_(); + void schedule_calibration_retry_(); + bool has_reference_() const { return this->reference_ > 0.0f; } + + voltage_sampler::VoltageSampler *source_{nullptr}; + float vcc_{5.0f}; + float voltage_multiplier_{1.0f}; + uint32_t warmup_time_{0}; + + uint32_t calibration_delay_{0}; + /// 0 when calibration is disabled. + uint32_t calibration_samples_{0}; + /// Spacing of the clean-air calibration samples (ms); the ~16 ms loop tick is the floor. + uint32_t sample_interval_{20}; + + static constexpr uint32_t CALIBRATION_START_ID = 0; + /// Floor of the delay between two calibration attempts (ms). + static constexpr uint32_t CALIBRATION_RETRY_MIN_MS = 10000; + + uint32_t calibration_last_sample_{0}; + uint32_t calibration_count_{0}; + uint32_t calibration_attempts_{0}; + float calibration_sum_{0.0f}; + /// Smallest and largest clean-air sample of the run, see calibration_is_stable(). + float calibration_min_{0.0f}; + float calibration_max_{0.0f}; + + /// Clean-air reference x_air in volts, 0 while there is none. + float reference_{0.0f}; + + // Sized by code generation; the host unit tests run none, so the list compiles out there. +#ifdef MICS_5524_GAS_COUNT + StaticVector gases_; +#endif + + uint8_t samples_{4}; + bool calibrating_{false}; +}; + +} // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_math.h b/esphome/components/mics_5524/mics_math.h new file mode 100644 index 00000000000..e66def345db --- /dev/null +++ b/esphome/components/mics_5524/mics_math.h @@ -0,0 +1,59 @@ +#pragma once +// Pure math of the MiCS-5524 vendor model; the per-gas constants live in coefficients.py. +// x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain. +// No ESPHome dependencies, so the host tests cover it. + +#include + +namespace esphome::mics_5524::micsmath { + +/// Per-gas constants of the vendor model. +struct VendorCurve { + float threshold; ///< ratio at or below which the gas counts as detected + float gain; ///< ppm per unit ratio + float min_ppm; ///< the vendor reports 0 ppm below this + float max_ppm; ///< upper end of the measuring range +}; + +/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below 0 V (a +/// disconnected pin) and above VCC, which only a wrong vcc or voltage_multiplier produces. +inline float air_value_from_voltage(float voltage, float vcc) { + if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= 0.0f || voltage > vcc) + return NAN; + return vcc - voltage; +} + +/// Largest relative spread (max - min) / mean a clean-air calibration accepts. +inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f; + +/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean. +inline bool calibration_is_stable(float min_value, float max_value, float mean) { + if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f) + return false; + return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean; +} + +/// ratio = x / x_air (NaN when either value is unusable). +inline float ratio_from_air_value(float x, float x_air) { + if (!std::isfinite(x) || x < 0.0f || !std::isfinite(x_air) || x_air <= 0.0f) + return NAN; + return x / x_air; +} + +/// ratio -> ppm as DFRobot_MICS getGasData(): 0 above the threshold or below the vendor +/// range, the top of the range at ratio 0 (a saturated sensor), NaN stays NaN. +inline float ppm_from_vendor_curve(float ratio, const VendorCurve &curve) { + if (!std::isfinite(ratio)) + return NAN; + if (ratio > curve.threshold) + return 0.0f; + + const float ppm = (curve.threshold - ratio) / curve.gain; + if (ppm < curve.min_ppm) + return 0.0f; + if (ppm > curve.max_ppm) + return curve.max_ppm; + return ppm; +} + +} // namespace esphome::mics_5524::micsmath diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py new file mode 100644 index 00000000000..f2b05c8da2e --- /dev/null +++ b/esphome/components/mics_5524/sensor.py @@ -0,0 +1,153 @@ +"""MiCS-5524 sensor platform: one entry per module, one sub-sensor per configured gas.""" + +import logging + +import esphome.codegen as cg +from esphome.components import sensor, voltage_sampler +import esphome.config_validation as cv +from esphome.const import ( + CONF_CALIBRATION, + CONF_DELAY, + CONF_ID, + CONF_VOLTAGE, + CONF_WARMUP_TIME, + STATE_CLASS_MEASUREMENT, + UNIT_PARTS_PER_MILLION, +) +from esphome.types import ConfigType + +from . import ( + CONF_AIR_REFERENCE, + CONF_MAX_PPM, + CONF_SAMPLE_COUNT, + CONF_SAMPLE_INTERVAL, + CONF_VCC, + CONF_VOLTAGE_MULTIPLIER, + MiCS5524Component, + MiCS5524GasSensor, +) +from .coefficients import GASES + +_LOGGER = logging.getLogger(__name__) + +AUTO_LOAD = ["sensor", "voltage_sampler"] + +# The main loop runs every ~16 ms, so a shorter spacing cannot sample more often. +MIN_SAMPLE_INTERVAL_MS = 16 + +# Conversions per reading run back to back inside one loop pass. +MAX_SAMPLE_COUNT = 16 + +_request_gas_slot = cg.slot_counter("MICS_5524_GAS_COUNT") + + +def _gas_schema(gas_key: str) -> cv.Schema: + """Schema of one gas sub-sensor.""" + curve = GASES[gas_key] + return sensor.sensor_schema( + MiCS5524GasSensor, + unit_of_measurement=UNIT_PARTS_PER_MILLION, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:molecule", + ).extend( + { + # A ceiling above the vendor range could never be reached. + cv.Optional(CONF_MAX_PPM, default=curve.max_ppm): cv.float_range( + min=0, min_included=False, max=curve.max_ppm + ) + } + ) + + +CALIBRATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_SAMPLE_COUNT, default=10): cv.int_range( + min=2, max=MAX_SAMPLE_COUNT + ), + cv.Optional(CONF_SAMPLE_INTERVAL, default="20ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=cv.TimePeriod(milliseconds=MIN_SAMPLE_INTERVAL_MS)), + ), + } +) + + +def _validate_config(config: ConfigType) -> ConfigType: + """Reject an air reference at or above the supply; warn when there is none.""" + if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: + # The reference is vcc - V_AO in clean air, so a larger one reads 0 ppm forever. + if air_reference >= config[CONF_VCC]: + raise cv.Invalid( + f"air_reference ({air_reference:g} V) must be below vcc " + f"({config[CONF_VCC]:g} V): it is how far the analog output sits " + "below the supply in clean air" + ) + elif CONF_CALIBRATION not in config: + _LOGGER.warning( + "no air reference configured - set 'air_reference:' or 'calibration:', " + "the PPM values stay unknown until then" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MiCS5524Component), + cv.Required(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler), + cv.Optional( + CONF_VOLTAGE_MULTIPLIER, default=1.0 + ): cv.positive_not_null_float, + cv.Optional(CONF_VCC, default=5.0): cv.positive_not_null_float, + cv.Optional(CONF_AIR_REFERENCE): cv.positive_not_null_float, + cv.Optional(CONF_SAMPLE_COUNT, default=4): cv.int_range( + min=1, max=MAX_SAMPLE_COUNT + ), + cv.Optional( + CONF_WARMUP_TIME, default="3min" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CALIBRATION): CALIBRATION_SCHEMA, + **{cv.Optional(gas_key): _gas_schema(gas_key) for gas_key in GASES}, + } + ).extend(cv.polling_component_schema("30s")), + cv.has_at_least_one_key(*GASES), + cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_CALIBRATION), + _validate_config, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], await cg.get_variable(config[CONF_VOLTAGE])) + await cg.register_component(var, config) + + cg.add(var.set_vcc(config[CONF_VCC])) + cg.add(var.set_voltage_multiplier(config[CONF_VOLTAGE_MULTIPLIER])) + cg.add(var.set_samples(config[CONF_SAMPLE_COUNT])) + cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) + + if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: + cg.add(var.set_air_reference(air_reference)) + elif (calibration := config.get(CONF_CALIBRATION)) is not None: + cg.add( + var.set_calibration( + calibration[CONF_DELAY], + calibration[CONF_SAMPLE_COUNT], + calibration[CONF_SAMPLE_INTERVAL], + ) + ) + + sensors = sensor.sub_sensors(config) + for gas_key, curve in GASES.items(): + if (gas_config := config.get(gas_key)) is None: + continue + _request_gas_slot(str(var)) + await sensors( + gas_key, + var.register_gas_sensor, + curve.threshold, + curve.gain, + curve.min_ppm, + gas_config[CONF_MAX_PPM], + ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0655c4a399c..cd0f0d39103 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -165,6 +165,7 @@ #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 +#define MICS_5524_GAS_COUNT 5 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 #define NFC_TAG_LISTENER_COUNT 1 diff --git a/tests/component_tests/mics_5524/__init__.py b/tests/component_tests/mics_5524/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mics_5524/config/mics_5524.yaml b/tests/component_tests/mics_5524/config/mics_5524.yaml new file mode 100644 index 00000000000..7538fb973e1 --- /dev/null +++ b/tests/component_tests/mics_5524/config/mics_5524.yaml @@ -0,0 +1,37 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: adc + id: mics_5524_adc + name: MiCS-5524 AO + pin: GPIO34 + + # Calibrated at boot; every gas below it is published from the same ratio. + - platform: mics_5524 + id: mics_5524_boot + voltage: mics_5524_adc + voltage_multiplier: 1.5 + sample_count: 3 + calibration: + delay: 5s + sample_count: 3 + carbon_monoxide: + id: mics_5524_co + name: MiCS-5524 CO + max_ppm: 500.0 + methane: + id: mics_5524_ch4 + name: MiCS-5524 CH4 + + # The reference is hard-coded, so nothing is calibrated at boot. + - platform: mics_5524 + id: mics_5524_fixed + voltage: mics_5524_adc + air_reference: 3.6 + hydrogen: + id: mics_5524_h2 + name: MiCS-5524 H2 diff --git a/tests/component_tests/mics_5524/test_mics_5524.py b/tests/component_tests/mics_5524/test_mics_5524.py new file mode 100644 index 00000000000..53dc2f49708 --- /dev/null +++ b/tests/component_tests/mics_5524/test_mics_5524.py @@ -0,0 +1,205 @@ +"""Tests for the MiCS-5524 gas sensor code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.mics_5524.coefficients import GASES, VendorCurve +from esphome.components.mics_5524.sensor import CONFIG_SCHEMA +from esphome.const import ( + CONF_AMMONIA, + CONF_CARBON_MONOXIDE, + CONF_ETHANOL, + CONF_HYDROGEN, + CONF_METHANE, +) + + +def test_voltage_source_is_a_constructor_argument( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every reading needs the sampler, so it is passed at construction.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert ( + "new(mics_5524_boot) mics_5524::MiCS5524Component(mics_5524_adc);" in main_cpp + ) + assert ( + "new(mics_5524_fixed) mics_5524::MiCS5524Component(mics_5524_adc);" in main_cpp + ) + assert "set_source(" not in main_cpp + + +def test_every_gas_becomes_a_sub_sensor_of_its_hub( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The curve is passed to the constructor and the sub-sensor to its own hub.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + # Threshold, gain, minimum and maximum ppm of the gas. + assert ( + "new(mics_5524_co) mics_5524::MiCS5524GasSensor(0.425f, " + "0.000405f, 1.0f, 500.0f);" in main_cpp + ) + assert "mics_5524_boot->register_gas_sensor(mics_5524_co);" in main_cpp + # A gas without `max_ppm:` keeps the measuring range of the vendor. + assert ( + "new(mics_5524_ch4) mics_5524::MiCS5524GasSensor(0.786f, " + "2.3e-05f, 1000.0f, 25000.0f);" in main_cpp + ) + assert "mics_5524_boot->register_gas_sensor(mics_5524_ch4);" in main_cpp + # The gas belongs to the sensor it is configured under, not to the first one. + assert "mics_5524_fixed->register_gas_sensor(mics_5524_h2);" in main_cpp + assert "mics_5524_boot->register_gas_sensor(mics_5524_h2);" not in main_cpp + + +def test_shared_options_reach_the_hub( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The analog front end is configured once per physical sensor.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert "mics_5524_boot->set_vcc(5.0f);" in main_cpp + assert "mics_5524_boot->set_voltage_multiplier(1.5f);" in main_cpp + assert "mics_5524_boot->set_samples(3);" in main_cpp + assert "mics_5524_boot->set_warmup_time(180000);" in main_cpp + assert "mics_5524_boot->set_calibration(5000, 3, 20);" in main_cpp + assert "mics_5524_fixed->set_air_reference(3.6f);" in main_cpp + # `air_reference:` and `calibration:` exclude each other, so the sensor with the + # hard-coded reference is never calibrated at boot. + assert "mics_5524_fixed->set_calibration(" not in main_cpp + + +def test_air_reference_and_calibration_exclude_each_other() -> None: + """Two references for one sensor would silently pick one, so it is an error.""" + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, + "air_reference": 3.6, + "calibration": {"delay": "5s"}, + } + ) + + +@pytest.mark.parametrize("air_reference", [5.0, 6.0]) +def test_air_reference_at_or_above_vcc_is_rejected(air_reference: float) -> None: + """A reference at or above the supply would report every gas as 0 ppm forever.""" + with pytest.raises(cv.Invalid, match="must be below vcc"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "vcc": 5.0, + "hydrogen": {"name": "MiCS-5524 H2 (reference above vcc)"}, + "air_reference": air_reference, + } + ) + + +def test_air_reference_below_vcc_is_accepted() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "vcc": 5.0, + "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, + "air_reference": 3.6, + } + ) + + assert config["air_reference"] == 3.6 + + +def test_at_least_one_gas_is_required() -> None: + """A sensor without a gas has nothing to publish.""" + with pytest.raises(cv.Invalid, match="Must contain at least one of"): + CONFIG_SCHEMA({"id": "mics_5524", "voltage": "mics_5524_adc", "vcc": 5.0}) + + +def test_max_ppm_above_the_vendor_range_is_rejected() -> None: + """A ceiling the conversion can never reach is a configuration error.""" + with pytest.raises(cv.Invalid, match="value must be at most 1000.0"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "carbon_monoxide": { + "name": "MiCS-5524 CO (rejected)", + "max_ppm": 2000.0, + }, + "air_reference": 3.6, + } + ) + + +def test_max_ppm_at_the_vendor_range_is_accepted() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "carbon_monoxide": {"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0}, + "air_reference": 3.6, + } + ) + + assert config["carbon_monoxide"]["max_ppm"] == 1000.0 + + +def test_max_ppm_defaults_to_the_vendor_range_of_the_gas() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "carbon_monoxide": {"name": "MiCS-5524 CO (default)"}, + "air_reference": 3.6, + } + ) + + assert config["carbon_monoxide"]["max_ppm"] == 1000.0 + + +def test_calibration_cannot_sample_faster_than_the_loop() -> None: + """The main loop runs every ~16 ms, so a shorter spacing samples nothing extra.""" + with pytest.raises(cv.Invalid, match="value must be at least 16ms"): + CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "hydrogen": {"name": "MiCS-5524 H2 (tick)"}, + "calibration": {"sample_interval": "10ms"}, + } + ) + + +def test_voltage_multiplier_defaults_to_one() -> None: + config = CONFIG_SCHEMA( + { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "hydrogen": {"name": "MiCS-5524 H2 (default)"}, + "air_reference": 3.6, + } + ) + + assert config["voltage_multiplier"] == 1.0 + + +def test_vendor_table_is_the_datasheet_one() -> None: + """The whole table is pinned, not only the gases a config file happens to use.""" + expected = { + CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), + CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), + CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), + CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), + } + + assert expected == GASES diff --git a/tests/components/mics_5524/common.h b/tests/components/mics_5524/common.h new file mode 100644 index 00000000000..7f46ba9bea3 --- /dev/null +++ b/tests/components/mics_5524/common.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include + +#include "esphome/components/mics_5524/mics_math.h" + +namespace esphome::mics_5524::testing { + +using micsmath::air_value_from_voltage; +using micsmath::calibration_is_stable; +using micsmath::ppm_from_vendor_curve; +using micsmath::ratio_from_air_value; + +// Independent copy of the DFRobot table; the Python tests pin coefficients.py itself. +constexpr micsmath::VendorCurve CO{0.425f, 0.000405f, 1.0f, 1000.0f}; +constexpr micsmath::VendorCurve H2{0.279f, 0.00026f, 1.0f, 1000.0f}; +constexpr micsmath::VendorCurve NH3{0.8f, 0.0015f, 10.0f, 500.0f}; +constexpr micsmath::VendorCurve C2H5OH{0.306f, 0.00057f, 10.0f, 500.0f}; +constexpr micsmath::VendorCurve CH4{0.786f, 0.000023f, 1000.0f, 25000.0f}; + +} // namespace esphome::mics_5524::testing diff --git a/tests/components/mics_5524/common.yaml b/tests/components/mics_5524/common.yaml new file mode 100644 index 00000000000..eb5f69b14ee --- /dev/null +++ b/tests/components/mics_5524/common.yaml @@ -0,0 +1,22 @@ +sensor: + - platform: adc + id: mics_5524_adc + name: MiCS-5524 AO + pin: ${mics_pin} + + - platform: mics_5524 + id: mics_5524_module + # 10k/20k divider: the 5 V breakout's 3.33 V stays under the ADC limit. + voltage: mics_5524_adc + voltage_multiplier: 1.5 + sample_count: 3 + warmup_time: 1s + calibration: + delay: 5s + sample_count: 3 + sample_interval: 20ms + carbon_monoxide: + name: MiCS-5524 CO + max_ppm: 500.0 + hydrogen: + name: MiCS-5524 H2 diff --git a/tests/components/mics_5524/sensor/mics_math_test.cpp b/tests/components/mics_5524/sensor/mics_math_test.cpp new file mode 100644 index 00000000000..946b4fa2e2e --- /dev/null +++ b/tests/components/mics_5524/sensor/mics_math_test.cpp @@ -0,0 +1,93 @@ +#include "../common.h" + +namespace esphome::mics_5524::testing { + +TEST(MicsMath, SaturatedSensorReportsTheTopOfTheRange) { + // ratio 0 is V_AO at VCC. The vendor helpers return the top of the range there, + // so a saturated sensor must not be converted into a clean-air reading. + EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(0.0f, CO)); + EXPECT_FLOAT_EQ(H2.max_ppm, ppm_from_vendor_curve(0.0f, H2)); + EXPECT_FLOAT_EQ(NH3.max_ppm, ppm_from_vendor_curve(0.0f, NH3)); + EXPECT_FLOAT_EQ(C2H5OH.max_ppm, ppm_from_vendor_curve(0.0f, C2H5OH)); + EXPECT_FLOAT_EQ(CH4.max_ppm, ppm_from_vendor_curve(0.0f, CH4)); +} + +TEST(MicsMath, GasFreeAirReadsZero) { + // At or above the threshold the vendor reports no gas, and so does a value below + // its own measuring range. + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(CO.threshold, CO)); + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(CO.threshold + 0.01f, CO)); + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(1.0f, CO)); + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(0.4249f, CO)); // 0.25 ppm, below the range +} + +TEST(MicsMath, MidRangeReadingsFollowTheVendorFormula) { + // (0.425 - 0.2) / 0.000405 = 555.56 ppm + EXPECT_NEAR(555.556f, ppm_from_vendor_curve(0.2f, CO), 0.01f); + // (0.8 - 0.7) / 0.0015 = 66.67 ppm + EXPECT_NEAR(66.667f, ppm_from_vendor_curve(0.7f, NH3), 0.01f); + // (0.279 - 0.1) / 0.00026 = 688.46 ppm + EXPECT_NEAR(688.462f, ppm_from_vendor_curve(0.1f, H2), 0.01f); +} + +TEST(MicsMath, UnusableInputReadsUnknown) { + EXPECT_TRUE(std::isnan(ppm_from_vendor_curve(NAN, CO))); + EXPECT_TRUE(std::isnan(ppm_from_vendor_curve(INFINITY, CO))); + EXPECT_TRUE(std::isnan(air_value_from_voltage(NAN, 5.0f))); + EXPECT_TRUE(std::isnan(air_value_from_voltage(4.0f, NAN))); + EXPECT_TRUE(std::isnan(ratio_from_air_value(1.0f, NAN))); + EXPECT_TRUE(std::isnan(ratio_from_air_value(NAN, 1.0f))); + // A missing clean-air reference (x_air = 0) must not be read as a real ratio. + EXPECT_TRUE(std::isnan(ratio_from_air_value(1.0f, 0.0f))); +} + +TEST(MicsMath, AirValueIsTheVoltageBelowVcc) { + EXPECT_FLOAT_EQ(1.0f, air_value_from_voltage(4.0f, 5.0f)); + // At VCC the vendor value is 0, the saturated end of the range ... + EXPECT_FLOAT_EQ(0.0f, air_value_from_voltage(5.0f, 5.0f)); + // ... but a voltage above VCC cannot come from the sensor, so it is unusable rather + // than saturated: a wrong `vcc:` is not reported as a real reading. + EXPECT_TRUE(std::isnan(air_value_from_voltage(5.2f, 5.0f))); + // A pin at or below 0 V is disconnected, not a reading of VCC. + EXPECT_TRUE(std::isnan(air_value_from_voltage(0.0f, 5.0f))); + EXPECT_TRUE(std::isnan(air_value_from_voltage(-0.1f, 5.0f))); + // The two ends of the vendor range, and the unusable case, one by one. + EXPECT_FLOAT_EQ(1.0f, ratio_from_air_value(air_value_from_voltage(4.0f, 5.0f), 1.0f)); + EXPECT_FLOAT_EQ(0.0f, ratio_from_air_value(air_value_from_voltage(5.0f, 5.0f), 1.0f)); + EXPECT_TRUE(std::isnan(ratio_from_air_value(air_value_from_voltage(6.0f, 5.0f), 1.0f))); +} + +TEST(MicsMath, CalibrationSamplesMustAgree) { + // Clean air at 3.6 V with a few millivolts of ADC noise: a settled reference. + EXPECT_TRUE(calibration_is_stable(3.555f, 3.645f, 3.6f)); + // A sensor that is still settling or not in clean air: (4.9 - 0.1) / 2.5 = 1.92. + EXPECT_FALSE(calibration_is_stable(0.1f, 4.9f, 2.5f)); + // The limit is inclusive: 0.25 of the mean passes, a little more does not. + EXPECT_TRUE(calibration_is_stable(3.0f, 4.0f, 4.0f)); + EXPECT_FALSE(calibration_is_stable(2.9f, 4.0f, 4.0f)); + // Unusable values are never a settled reference. + EXPECT_FALSE(calibration_is_stable(NAN, 3.6f, 3.6f)); + EXPECT_FALSE(calibration_is_stable(3.6f, NAN, 3.6f)); + EXPECT_FALSE(calibration_is_stable(3.6f, 3.6f, NAN)); + EXPECT_FALSE(calibration_is_stable(3.6f, 3.6f, 0.0f)); +} + +TEST(MicsMath, ConfiguredCeilingCapsTheReading) { + // `max_ppm:` narrows the vendor range: a reading above the ceiling is reported as + // the ceiling, never as a value the sensor is not configured for. + const micsmath::VendorCurve narrow{0.425f, 0.000405f, 1.0f, 500.0f}; + EXPECT_FLOAT_EQ(500.0f, ppm_from_vendor_curve(0.2f, narrow)); // 555.56 ppm + EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(0.5f, narrow)); // above the threshold +} + +TEST(MicsMath, SaturatedSensorEndToEnd) { + // V_AO at VCC -> x = 0 -> ratio 0 -> top of the range, the case that used to read + // 0 ppm and made a saturated sensor look like clean air. + const float x = air_value_from_voltage(5.0f, 5.0f); + const float ratio = ratio_from_air_value(x, 1.0f); + + EXPECT_FLOAT_EQ(0.0f, ratio); + EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(ratio, CO)); +} + +} // namespace esphome::mics_5524::testing diff --git a/tests/components/mics_5524/test.esp32-idf.yaml b/tests/components/mics_5524/test.esp32-idf.yaml new file mode 100644 index 00000000000..1028eb373bc --- /dev/null +++ b/tests/components/mics_5524/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + mics_pin: GPIO34 + +packages: + mics_5524: !include common.yaml diff --git a/tests/components/mics_5524/test.esp8266-ard.yaml b/tests/components/mics_5524/test.esp8266-ard.yaml new file mode 100644 index 00000000000..6383f1e139a --- /dev/null +++ b/tests/components/mics_5524/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + mics_pin: A0 + +packages: + mics_5524: !include common.yaml diff --git a/tests/components/mics_5524/test.rp2040-ard.yaml b/tests/components/mics_5524/test.rp2040-ard.yaml new file mode 100644 index 00000000000..a5aff154eba --- /dev/null +++ b/tests/components/mics_5524/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + mics_pin: GPIO26 + +packages: + mics_5524: !include common.yaml diff --git a/tests/components/mics_5524/validate.esp32-idf.yaml b/tests/components/mics_5524/validate.esp32-idf.yaml new file mode 100644 index 00000000000..9ec6154be60 --- /dev/null +++ b/tests/components/mics_5524/validate.esp32-idf.yaml @@ -0,0 +1,23 @@ +# Config-only test: the external voltage sampler path and the PPM range options. +sensor: + - platform: adc + id: mics_5524_adc + name: MiCS-5524 AO + pin: GPIO35 + + - platform: mics_5524 + id: mics_5524_module + voltage: mics_5524_adc + voltage_multiplier: 1.5 + air_reference: 3.6 + vcc: 5.0 + sample_count: 2 + warmup_time: 30s + carbon_monoxide: + name: MiCS-5524 CO + max_ppm: 1000.0 + hydrogen: + name: MiCS-5524 H2 + ammonia: + name: MiCS-5524 NH3 + max_ppm: 300.0 From 04aa59563dc9cdae081f501777c08fff45225d72 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:45:03 +1100 Subject: [PATCH 468/651] [display] Drop the color order from draw_pixels_at() and ColorUtil (#20884) --- esphome/components/display/display.cpp | 61 ++++-- esphome/components/display/display.h | 50 ++++- .../components/display/display_color_utils.h | 178 ++++++++++-------- esphome/components/epaper_spi/epaper_spi.cpp | 13 +- esphome/components/epaper_spi/epaper_spi.h | 9 +- esphome/components/hub75/hub75.cpp | 17 +- esphome/components/hub75/hub75_component.h | 5 +- .../components/ili9xxx/ili9xxx_display.cpp | 18 +- esphome/components/ili9xxx/ili9xxx_display.h | 5 +- esphome/components/image/image.cpp | 4 +- esphome/components/it8951/it8951.cpp | 9 +- esphome/components/it8951/it8951.h | 11 +- esphome/components/lvgl/lvgl_esphome.cpp | 3 +- esphome/components/mipi_dsi/mipi_dsi.cpp | 11 +- esphome/components/mipi_dsi/mipi_dsi.h | 6 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 9 +- esphome/components/mipi_rgb/mipi_rgb.h | 7 +- esphome/components/mipi_spi/mipi_spi.h | 120 ++++++------ esphome/components/pixoo/pixoo.cpp | 28 ++- esphome/components/pixoo/pixoo.h | 5 +- esphome/components/qspi_dbi/qspi_dbi.cpp | 22 +-- esphome/components/qspi_dbi/qspi_dbi.h | 8 +- .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 12 +- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 5 +- esphome/components/sdl/sdl_esphome.cpp | 8 +- esphome/components/sdl/sdl_esphome.h | 5 +- .../snapshot/display/snapshot_display.cpp | 10 +- .../snapshot/display/snapshot_display.h | 6 +- esphome/components/st7701s/st7701s.cpp | 12 +- esphome/components/st7701s/st7701s.h | 7 +- esphome/components/st7735/st7735.cpp | 4 +- esphome/components/st7789v/st7789v.cpp | 3 +- .../display/bench_draw_pixels_at.cpp | 2 +- .../components/image/bench_image_draw.cpp | 6 +- .../display/test_draw_pixels_at.cpp | 159 +++++++++++++++- .../epaper_spi/display/test_draw_pixels.cpp | 86 ++++++--- tests/components/image/test_image_draw.cpp | 6 +- 37 files changed, 598 insertions(+), 332 deletions(-) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index ba8f85463f4..a5dd398e81b 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -57,17 +57,17 @@ void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_ // -Os stops inlining it once there are two call sites, which costs a call and // spills per pixel. Without PSRAM there is one call site and it changes // nothing, and draw_pixel_at() stays a virtual call either way. -template -static void __attribute__((flatten)) -draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - bool big_endian, int x_offset, int y_offset, int x_pad) { +// HIGH_BYTE_FIRST (big endian) only matters for 565; as a template argument its test leaves the pixel loop. +template +static void __attribute__((flatten)) draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, + const uint8_t *ptr, int x_offset, int y_offset, int x_pad) { const size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels // BITNESS is a constant here, so to_color()'s three per pixel divisions // become a multiply and shift and its bitness switch folds. const auto draw_source_pixel = [&](int x, int y, size_t source_idx) ESPHOME_ALWAYS_INLINE { display->draw_pixel_at( x + x_start, y + y_start, - ColorUtil::to_color(ColorUtil::read_packed(ptr, source_idx, big_endian), order, BITNESS)); + ColorUtil::to_color(ColorUtil::read_packed(ptr, source_idx, HIGH_BYTE_FIRST), BITNESS)); }; // The source and frame buffers are row-major, so walking rows keeps the read // and the write sequential. When the display swaps the axes before writing, @@ -94,20 +94,59 @@ draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, con } } +// Remove before 2027.5.0. Draws a block in the deprecated BGR or GRB order, pixel by pixel. Not inlined, so +// the RGB loops above compile as tightly as before. +static void __attribute__((noinline)) +draw_ordered_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + const size_t line_stride = x_offset + w + x_pad; + for (int y = 0; y != h; y++) { + size_t source_idx = (y_offset + y) * line_stride + x_offset; + for (int x = 0; x != w; x++, source_idx++) { + uint32_t code; + if (bitness == COLOR_BITNESS_565) { + code = ColorUtil::read_packed(ptr, source_idx, big_endian); + } else if (bitness == COLOR_BITNESS_888) { + code = ColorUtil::read_packed(ptr, source_idx, big_endian); + } else { + code = ColorUtil::read_packed(ptr, source_idx, big_endian); + } + display->draw_pixel_at(x + x_start, y + y_start, apply_color_order(ColorUtil::to_color(code, bitness), order)); + } + } +} + +void Display::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) { + // Through the deprecated form, so a driver that still overrides only that one keeps its fast path. + // Remove before 2027.5.0, when the per pixel drawing below moves here. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->draw_pixels_at(x_start, y_start, w, h, ptr, COLOR_ORDER_RGB, bitness, big_endian, x_offset, y_offset, x_pad); +#pragma GCC diagnostic pop +} + +// Must not call draw_pixels_at() without a color order or draw_pixels_internal(): those call this, and a legacy +// override's fallback ends up here. void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + if (order != COLOR_ORDER_RGB) { + draw_ordered_pixels(this, x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } switch (bitness) { case COLOR_BITNESS_565: - draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, - x_pad); + if (big_endian) { + draw_packed_pixels(this, x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); + } else { + draw_packed_pixels(this, x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); + } break; case COLOR_BITNESS_888: - draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, - x_pad); + draw_packed_pixels(this, x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); break; default: - draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, - x_pad); + draw_packed_pixels(this, x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); break; } } diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index b43a3221518..a6ac319939c 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -346,8 +346,6 @@ class Display : public PollingComponent { virtual void draw_pixel_at(int x, int y, Color color) = 0; /** Given an array of pixels encoded in the nominated format, draw these into the display's buffer. - * The naive implementation here will work in all cases, but can be overridden by sub-classes - * in order to optimise the procedure. * The parameters describe a rectangular block of pixels, potentially within a larger buffer. * * \param x_start The starting destination x position @@ -355,27 +353,49 @@ class Display : public PollingComponent { * \param w the width of the pixel block * \param h the height of the pixel block * \param ptr A pointer to the start of the data to be copied - * \param order The ordering of the colors - * \param bitness Defines the number of bits and their format for each pixel + * \param bitness Defines the number of bits and their format for each pixel. Red is always in the + * most significant bits. * \param big_endian True if 16 bit values are stored big-endian. Ignored for 888, which is always b,g,r - * in memory for RGB order, as LVGL and ESPHome images store it. + * in memory, as LVGL and ESPHome images store it. * \param x_offset The initial x-offset into the source buffer. * \param y_offset The initial y-offset into the source buffer. * \param x_pad How many pixels are in each line after the end of the pixels to be copied. * * The length of each source buffer line (stride) will be x_offset + w + x_pad. - * - * An override that copies the block as is must send any other format, byte order, a software - * rotation or an active clipping rectangle through this base version, which draws per pixel. - * The block must lie on the screen. + * The block must lie on the screen. A driver that can copy blocks itself overrides draw_pixels_internal(). */ + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, bool big_endian, + int x_offset, int y_offset, int x_pad) { + this->draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); + } + + /// Convenience overload for base case where the pixels are packed into the buffer with no gaps (e.g. suits LVGL.) + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian) { + this->draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, 0, 0, 0); + } + + // Remove before 2027.5.0 + /// The block form above with a color order. The base version draws per pixel and honors the order; + /// a driver that still overrides this one keeps receiving every block, since draw_pixels_internal() calls it. + /// Such a driver needs `using display::Display::draw_pixels_at;` so the forms above stay visible through it, + /// and its fallback must call Display::draw_pixels_at() with the order: Display::draw_pixels_internal() would + /// call the override again. + ESPDEPRECATED( + "Pixels are always RGB: call draw_pixels_at() without the ColorOrder, and override draw_pixels_internal() " + "to copy blocks. Removed in 2027.5.0", + "2026.11.0") virtual void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad); - /// Convenience overload for base case where the pixels are packed into the buffer with no gaps (e.g. suits LVGL.) + // Remove before 2027.5.0 + ESPDEPRECATED("Pixels are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, ColorBitness bitness, bool big_endian) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" this->draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, 0, 0, 0); +#pragma GCC diagnostic pop } /// Draw a straight line from the point [x1,y1] to [x2,y2] with the given color. @@ -796,6 +816,16 @@ class Display : public PollingComponent { void show_test_card() { this->show_test_card_ = true; } protected: + /** Draws a block for draw_pixels_at(); the parameters are the same. The base version draws it per pixel + * through draw_pixel_at(), so it works on any display. + * + * A driver can override it to copy blocks that are already laid out the way its own draw_pixel_at() + * writes, and must send any other format, byte order, a software rotation or an active clipping + * rectangle through Display::draw_pixels_internal(), which draws per pixel. Do not also override the + * deprecated draw_pixels_at() with a color order: Display::draw_pixels_internal() calls it. + */ + virtual void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad); bool clamp_x_(int x, int w, int &min_x, int &max_x); bool clamp_y_(int y, int h, int &min_y, int &max_y); void vprintf_(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index bd658986519..1b4f47c1250 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -9,6 +9,38 @@ enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDE enum ColorBitness : uint8_t { COLOR_BITNESS_888 = 0, COLOR_BITNESS_565 = 1, COLOR_BITNESS_332 = 2 }; inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 255) { return (max_value * i / scale); } +/// esp_scale(value, (1 << bits) - 1) for a field of 2, 3, 5, 6 or 8 bits, as a multiply and shift that gives the +/// same result for every field value. With bits a constant this avoids a division. +constexpr uint8_t expand_field(uint8_t value, uint8_t bits) { + switch (bits) { + case 2: + return (value * 170) >> 1; + case 3: + return (value * 583) >> 4; + case 5: + return (value * 1053) >> 7; + case 6: + return (value * 4145) >> 10; + case 8: + return value; + default: + return esp_scale(value, (1 << bits) - 1); + } +} + +// Remove before 2027.5.0, with the forms that take a color order. Maps a color read in RGB order to the +// given order, by moving its channels. +inline Color apply_color_order(Color color, ColorOrder order) { + switch (order) { + case COLOR_ORDER_BGR: + return Color(color.b, color.g, color.r); + case COLOR_ORDER_GRB: + return Color(color.g, color.r, color.b); + default: + return color; + } +} + class ColorUtil { public: /// Reads one packed pixel of the given bitness from a source buffer into the @@ -23,7 +55,7 @@ class ColorUtil { return (ptr[idx] << 8) + ptr[idx + 1]; return ptr[idx] + (ptr[idx + 1] << 8); } else if constexpr (BITNESS == COLOR_BITNESS_888) { - // Always the LVGL RGB888 layout, b,g,r in memory for RGB order; byte order applies to 565 only + // Always the LVGL RGB888 layout, b,g,r in memory; byte order applies to 565 only const size_t idx = index * 3; return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16); } else { @@ -31,9 +63,9 @@ class ColorUtil { } } - static Color to_color(uint32_t colorcode, ColorOrder color_order, - ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) { - uint8_t first_color, second_color, third_color; + /// Unpacks a color of the given bitness, red in the most significant bits. + static Color to_color(uint32_t colorcode, ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, + bool right_bit_aligned = true) { uint8_t first_bits = 0; uint8_t second_bits = 0; uint8_t third_bits = 0; @@ -56,74 +88,66 @@ class ColorUtil { break; } - first_color = right_bit_aligned ? esp_scale(((colorcode >> (second_bits + third_bits)) & ((1 << first_bits) - 1)), - ((1 << first_bits) - 1)) - : esp_scale(((colorcode >> 16) & 0xFF), (1 << first_bits) - 1); + const uint8_t red = + right_bit_aligned + ? expand_field((colorcode >> (second_bits + third_bits)) & ((1 << first_bits) - 1), first_bits) + : esp_scale(((colorcode >> 16) & 0xFF), (1 << first_bits) - 1); - second_color = right_bit_aligned - ? esp_scale(((colorcode >> third_bits) & ((1 << second_bits) - 1)), ((1 << second_bits) - 1)) - : esp_scale(((colorcode >> 8) & 0xFF), ((1 << second_bits) - 1)); + const uint8_t green = right_bit_aligned + ? expand_field((colorcode >> third_bits) & ((1 << second_bits) - 1), second_bits) + : esp_scale(((colorcode >> 8) & 0xFF), ((1 << second_bits) - 1)); - third_color = (right_bit_aligned ? esp_scale(((colorcode >> 0) & ((1 << third_bits) - 1)), ((1 << third_bits) - 1)) - : esp_scale(((colorcode >> 0) & 0xFF), (1 << third_bits) - 1)); + const uint8_t blue = right_bit_aligned ? expand_field(colorcode & ((1 << third_bits) - 1), third_bits) + : esp_scale(((colorcode >> 0) & 0xFF), (1 << third_bits) - 1); - Color color_return; - - switch (color_order) { - case COLOR_ORDER_RGB: - color_return.r = first_color; - color_return.g = second_color; - color_return.b = third_color; - break; - case COLOR_ORDER_BGR: - color_return.b = first_color; - color_return.g = second_color; - color_return.r = third_color; - break; - case COLOR_ORDER_GRB: - color_return.g = first_color; - color_return.r = second_color; - color_return.b = third_color; - break; - } - return color_return; + return Color(red, green, blue); + } + // Remove before 2027.5.0 + ESPDEPRECATED("Packed colors are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") + static Color to_color(uint32_t colorcode, ColorOrder color_order, + ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) { + return apply_color_order(to_color(colorcode, color_bitness, right_bit_aligned), color_order); } static inline Color rgb332_to_color(uint8_t rgb332_color) { - return to_color((uint32_t) rgb332_color, COLOR_ORDER_RGB, COLOR_BITNESS_332); + return to_color((uint32_t) rgb332_color, COLOR_BITNESS_332); } - static uint8_t color_to_332(Color color, ColorOrder color_order = ColorOrder::COLOR_ORDER_RGB) { - uint16_t red_color, green_color, blue_color; - - red_color = esp_scale8(color.red, ((1 << 3) - 1)); - green_color = esp_scale8(color.green, ((1 << 3) - 1)); - blue_color = esp_scale8(color.blue, (1 << 2) - 1); - - switch (color_order) { - case COLOR_ORDER_RGB: - return red_color << 5 | green_color << 2 | blue_color; - case COLOR_ORDER_BGR: - return blue_color << 6 | green_color << 3 | red_color; - case COLOR_ORDER_GRB: - return green_color << 5 | red_color << 2 | blue_color; - } - return 0; + static uint8_t color_to_332(Color color) { + const uint16_t red_color = esp_scale8(color.red, ((1 << 3) - 1)); + const uint16_t green_color = esp_scale8(color.green, ((1 << 3) - 1)); + const uint16_t blue_color = esp_scale8(color.blue, (1 << 2) - 1); + return red_color << 5 | green_color << 2 | blue_color; } - static uint16_t color_to_565(Color color, ColorOrder color_order = ColorOrder::COLOR_ORDER_RGB) { - uint16_t red_color, green_color, blue_color; - - red_color = esp_scale8(color.red, ((1 << 5) - 1)); - green_color = esp_scale8(color.green, ((1 << 6) - 1)); - blue_color = esp_scale8(color.blue, (1 << 5) - 1); - + // Remove before 2027.5.0 + ESPDEPRECATED("Packed colors are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") + static uint8_t color_to_332(Color color, ColorOrder color_order) { + if (color_order == COLOR_ORDER_RGB) + return color_to_332(color); + // The other orders move the 2 bit field, so they cannot reuse the RGB packing + const uint16_t red_color = esp_scale8(color.red, ((1 << 3) - 1)); + const uint16_t green_color = esp_scale8(color.green, ((1 << 3) - 1)); + if (color_order == COLOR_ORDER_BGR) + return esp_scale8(color.blue, (1 << 2) - 1) << 6 | green_color << 3 | red_color; + return green_color << 5 | red_color << 2 | esp_scale8(color.blue, (1 << 2) - 1); + } + static uint16_t color_to_565(Color color) { + const uint16_t red_color = esp_scale8(color.red, ((1 << 5) - 1)); + const uint16_t green_color = esp_scale8(color.green, ((1 << 6) - 1)); + const uint16_t blue_color = esp_scale8(color.blue, (1 << 5) - 1); + return red_color << 11 | green_color << 5 | blue_color; + } + // Remove before 2027.5.0 + ESPDEPRECATED("Packed colors are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") + static uint16_t color_to_565(Color color, ColorOrder color_order) { switch (color_order) { - case COLOR_ORDER_RGB: - return red_color << 11 | green_color << 5 | blue_color; case COLOR_ORDER_BGR: - return blue_color << 11 | green_color << 5 | red_color; + return color_to_565(Color(color.b, color.g, color.r)); case COLOR_ORDER_GRB: - return green_color << 10 | red_color << 5 | blue_color; + // Green moves to the 5 bit field at the top, so it cannot reuse the RGB packing + return esp_scale8(color.green, (1 << 6) - 1) << 10 | esp_scale8(color.red, (1 << 5) - 1) << 5 | + esp_scale8(color.blue, (1 << 5) - 1); + default: + return color_to_565(color); } - return 0; } static uint32_t color_to_grayscale4(Color color) { uint32_t gs4 = esp_scale8(color.white, 15); @@ -196,23 +220,13 @@ constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 */ class Rgb565ToMono { public: + Rgb565ToMono() { this->fill_tables_(REC601_LUMA_R, REC601_LUMA_G, REC601_LUMA_B); } + // Remove before 2027.5.0 + ESPDEPRECATED("Packed colors are always RGB, drop the ColorOrder argument. Removed in 2027.5.0", "2026.11.0") explicit Rgb565ToMono(ColorOrder order) { - // Rec.601 weight of the 5-bit, 6-bit and 5-bit fields, by the channel each one holds in this order - uint16_t high = REC601_LUMA_R, middle = REC601_LUMA_G, low = REC601_LUMA_B; - if (order == COLOR_ORDER_BGR) { - high = REC601_LUMA_B; - low = REC601_LUMA_R; - } else if (order == COLOR_ORDER_GRB) { - high = REC601_LUMA_G; - middle = REC601_LUMA_R; - } - // Each field value scaled to 0..255, then weighted - for (unsigned i = 0; i != 32; i++) { - this->high_[i] = high * esp_scale(i, 31); - this->low_[i] = low * esp_scale(i, 31); - } - for (unsigned i = 0; i != 64; i++) - this->middle_[i] = middle * esp_scale(i, 63); + // The weight of the channel each 5, 6 and 5 bit field holds in this order + const Color weights = apply_color_order(Color(REC601_LUMA_R, REC601_LUMA_G, REC601_LUMA_B), order); + this->fill_tables_(weights.r, weights.g, weights.b); } uint8_t operator()(uint16_t value) const { @@ -221,6 +235,16 @@ class Rgb565ToMono { } protected: + // Each field value scaled to 0..255, then weighted + void fill_tables_(uint16_t high, uint16_t middle, uint16_t low) { + for (unsigned i = 0; i != 32; i++) { + this->high_[i] = high * esp_scale(i, 31); + this->low_[i] = low * esp_scale(i, 31); + } + for (unsigned i = 0; i != 64; i++) + this->middle_[i] = middle * esp_scale(i, 63); + } + // Weighted contribution of each value of each field std::array high_{}; std::array middle_{}; diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index d8ead388183..a0c90a0b686 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -399,7 +399,7 @@ void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_widt // Each source column maps to one buffer coordinate and each source row to the other, each moving by 1 per pixel, so // the visible part of a block and its dirty area are worked out once for the whole block. void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, - ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad) { + bool big_endian, int x_offset, int y_offset, int x_pad) { const bool swap = this->effective_transform_ & SWAP_XY; const bool mirror_x = this->effective_transform_ & MIRROR_X; const bool mirror_y = this->effective_transform_ & MIRROR_Y; @@ -432,7 +432,7 @@ void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_star const int x_high = x_low + (swap ? rows : columns); const int y_high = y_low + (swap ? columns : rows); - const Rgb565ToMono to_mono(order); + const Rgb565ToMono to_mono; const size_t line_stride = x_offset + w + x_pad; const size_t row_width = this->row_width_; const int step = column_mirrored ? -1 : 1; @@ -464,16 +464,17 @@ void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_star // Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the // usual case): the same result as draw_pixel_at() for each pixel. Anything else goes through draw_pixel_at(). -void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void HOT EPaperBase::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { size_t contiguous_length; uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length); if (!this->buffer_is_mono_() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || contiguous_length != this->buffer_length_) { - Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } - this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, x_pad); + this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, big_endian, x_offset, y_offset, x_pad); } void EPaperBase::dump_config() { diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 37f9a1449a2..dccff3cbef1 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -111,14 +111,15 @@ class EPaperBase : public Display, int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } bool pixel_axes_swapped() const override { return this->effective_transform_ & SWAP_XY; } void draw_pixel_at(int x, int y, Color color) override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; // Make the next update a full one. Applied when that update starts, so one in progress is not affected. void request_full_update() { this->full_update_requested_ = true; } // True from the start of an update until the display has refreshed and gone back to sleep. bool is_updating() const { return this->state_ != EPaperState::IDLE; } protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_height_internal() override { return this->height_; }; int get_width_internal() override { return this->width_; }; bool is_using_partial_update_() const { return this->full_update_every_ > 1; } @@ -135,8 +136,8 @@ class EPaperBase : public Display, bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); - void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - bool big_endian, int x_offset, int y_offset, int x_pad); + void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, bool big_endian, + int x_offset, int y_offset, int x_pad); // Whether the buffer is a single plane of 1 bit per pixel, as draw_pixel_at() writes it bool buffer_is_mono_() const { return this->bits_per_pixel_ == 1 && this->planes_ == 1; } diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index 5f2149747e1..4edc1c548f6 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -130,8 +130,9 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { this->feed_wdt_per_pixel_(); } -void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void HOT HUB75Display::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (!driver_) [[unlikely]] return; if (!this->enabled_) [[unlikely]] @@ -153,15 +154,13 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co format = Hub75PixelFormat::RGB888_32; bytes_per_pixel = 4; - // Map ESPHome ColorOrder to Hub75ColorOrder - // ESPHome ColorOrder is typically BGR for little-endian 32-bit - color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR; + color_order = Hub75ColorOrder::RGB; #elif LV_COLOR_DEPTH == 24 // 24-bit: 3 bytes per pixel, tightly packed format = Hub75PixelFormat::RGB888; bytes_per_pixel = 3; - // ESPHome RGB order 888 is b,g,r in memory, which hub75 calls BGR - color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::BGR : Hub75ColorOrder::RGB; + // 888 is b,g,r in memory, which hub75 calls BGR + color_order = Hub75ColorOrder::BGR; #else ESP_LOGE(TAG, "Unsupported LV_COLOR_DEPTH: %d", LV_COLOR_DEPTH); return; @@ -170,8 +169,8 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co // Non-LVGL mode: standard 24-bit RGB888 format = Hub75PixelFormat::RGB888; bytes_per_pixel = 3; - // ESPHome RGB order 888 is b,g,r in memory, which hub75 calls BGR - color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::BGR : Hub75ColorOrder::RGB; + // 888 is b,g,r in memory, which hub75 calls BGR + color_order = Hub75ColorOrder::BGR; #endif } else { ESP_LOGE(TAG, "Unsupported bitness: %d", static_cast(bitness)); diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index 122e8960fb0..ae31cadf83a 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -30,8 +30,6 @@ class HUB75Display final : public display::Display { display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } void fill(Color color) override; void draw_pixel_at(int x, int y, Color color) override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; // Brightness control (runtime mutable) void set_brightness(uint8_t brightness); @@ -40,6 +38,9 @@ class HUB75Display final : public display::Display { display::DisplayRotation get_rotation() const override; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + // Display internal methods int get_width_internal() override { return this->driver_ != nullptr ? this->driver_->get_width() : 0; } int get_height_internal() override { return this->driver_ != nullptr ? this->driver_->get_height() : 0; } diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index d2e1bc127e5..379c45f6737 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -162,7 +162,7 @@ void ILI9XXXDisplay::fill(Color color) { } return; default: - new_color = display::ColorUtil::color_to_332(color, display::ColorOrder::COLOR_ORDER_RGB); + new_color = display::ColorUtil::color_to_332(color); break; } memset(this->buffer_, (uint8_t) new_color, this->get_buffer_length_()); @@ -183,7 +183,7 @@ void HOT ILI9XXXDisplay::draw_absolute_pixel_internal(int x, int y, Color color) break; case BITS_16: pos = pos * 2; - new_color = display::ColorUtil::color_to_565(color, display::ColorOrder::COLOR_ORDER_RGB); + new_color = display::ColorUtil::color_to_565(color); if (this->buffer_[pos] != (uint8_t) (new_color >> 8)) { this->buffer_[pos] = (uint8_t) (new_color >> 8); updated = true; @@ -192,7 +192,7 @@ void HOT ILI9XXXDisplay::draw_absolute_pixel_internal(int x, int y, Color color) new_color = new_color & 0xFF; break; default: - new_color = display::ColorUtil::color_to_332(color, display::ColorOrder::COLOR_ORDER_RGB); + new_color = display::ColorUtil::color_to_332(color); break; } @@ -315,19 +315,17 @@ void ILI9XXXDisplay::display_() { // Outside update() this bypasses the buffer and writes directly to the display. Inside it the block goes into the // buffer, or the flush at the end of update() would paint the old buffer contents over it. -void ILI9XXXDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, - display::ColorOrder order, display::ColorBitness bitness, bool big_endian, - int x_offset, int y_offset, int x_pad) { +void ILI9XXXDisplay::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (w <= 0 || h <= 0) return; // if color mapping or software rotation is required, hand this off to the parent implementation. This will // do color conversion pixel-by-pixel into the buffer and draw it later. If this is happening the user has not // configured the renderer well. if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || !big_endian || - order != display::COLOR_ORDER_RGB || this->is_clipping() || - (this->prossing_update_ && this->buffer_color_mode_ != BITS_16)) { - display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + this->is_clipping() || (this->prossing_update_ && this->buffer_color_mode_ != BITS_16)) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } // x_ and y_offset are offsets into the source buffer, unrelated to our own offsets into the display. diff --git a/esphome/components/ili9xxx/ili9xxx_display.h b/esphome/components/ili9xxx/ili9xxx_display.h index 77cee44ae68..4eabd02f3d5 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.h +++ b/esphome/components/ili9xxx/ili9xxx_display.h @@ -92,10 +92,11 @@ class ILI9XXXDisplay : public display::DisplayBuffer, void on_powerdown() override { this->command(ILI9XXX_SLPIN); } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + inline bool check_buffer_() { if (this->buffer_ == nullptr) { if (!this->is_failed()) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 60d81ad62f1..a38e84caaf9 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -59,8 +59,8 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color return; const bool rgb565 = this->type_ == IMAGE_TYPE_RGB565; display->draw_pixels_at(x + x_start, y + y_start, x_end - x_start, y_end - y_start, this->data_start_, - display::COLOR_ORDER_RGB, rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_888, - this->big_endian_, x_start, y_start, this->width_ - x_end); + rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_888, this->big_endian_, + x_start, y_start, this->width_ - x_end); return; } #endif diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 3612b795b98..6b0a4b56094 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -959,8 +959,9 @@ void HOT IT8951Display::write_pixel_native_(uint16_t x, uint16_t y, const Color } } -void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void HOT IT8951Display::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { // A writer (e.g. LVGL) may push pixels before the framebuffer is ready or // after an allocation failure; ignore those rather than dereferencing null. if (this->buffer_ == nullptr) @@ -968,7 +969,7 @@ void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, c // A clipping rectangle would need a per-pixel test; that's rare for the bulk // blit callers (LVGL, images), so fall back to the base per-pixel path then. if (this->get_clipping().is_set()) { - Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } @@ -1000,7 +1001,7 @@ void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, c if (nx < 0 || ny < 0 || nx >= this->width_ || ny >= this->height_) continue; this->write_pixel_native_(static_cast(nx), static_cast(ny), - ColorUtil::to_color(color_value, order, bitness)); + ColorUtil::to_color(color_value, bitness)); } } diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index e99e905ebf5..2b5f8d299fc 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -197,16 +197,17 @@ class IT8951Display : public Display, void fill(Color color) override; void clear() override { this->fill(Color::WHITE); } void draw_pixel_at(int x, int y, Color color) override; - // Bulk pixel blit (used by LVGL and image rendering). Overridden to write - // straight into the framebuffer, avoiding the base class's per-pixel - // draw_pixel_at overhead (watchdog feed, clipping test, dirty-box clamps). - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } bool pixel_axes_swapped() const override { return this->effective_transform_ & TRANSFORM_SWAP_XY; } protected: + // Bulk pixel blit (used by LVGL and image rendering). Overridden to write + // straight into the framebuffer, avoiding the base class's per-pixel + // draw_pixel_at overhead (watchdog feed, clipping test, dirty-box clamps). + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_height_internal() override { return this->height_; } int get_width_internal() override { return this->width_; } diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 9bc83800051..e968e329dfb 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -439,8 +439,7 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { ptr = dst; } for (auto *display : this->displays_) { - display->draw_pixels_at(x1, y1, width, height, (const uint8_t *) ptr, display::COLOR_ORDER_RGB, LV_BITNESS, - this->big_endian_); + display->draw_pixels_at(x1, y1, width, height, (const uint8_t *) ptr, LV_BITNESS, this->big_endian_); } } diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 31bf69b178c..005087b719b 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -213,15 +213,14 @@ void MipiDsi::flush_dirty_() { this->y_high_ = 0; } -void MipiDsi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void MipiDsi::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (w <= 0 || h <= 0) return; - if (bitness != this->color_depth_ || order != display::COLOR_ORDER_RGB || - (bitness == display::COLOR_BITNESS_565 && big_endian != this->big_endian_) || + if (bitness != this->color_depth_ || (bitness == display::COLOR_BITNESS_565 && big_endian != this->big_endian_) || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { - display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); this->mark_dirty_(x_start, y_start, w, h); this->flush_dirty_(); return; diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index dd1b0dc5f90..9e7c562b54d 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -72,9 +72,6 @@ class MipiDsi final : public display::Display { void setup() override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; - void draw_pixel_at(int x, int y, Color color) override; void fill(Color color) override; int get_width() override; @@ -83,6 +80,9 @@ class MipiDsi final : public display::Display { void dump_config() override; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); bool check_buffer_(); diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 2bc300e5b82..a38404030ad 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -218,13 +218,14 @@ void MipiRgb::flush_dirty_() { this->y_high_ = 0; } -void MipiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void MipiRgb::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (w <= 0 || h <= 0 || this->is_failed()) return; - if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || big_endian != this->big_endian_ || + if (bitness != display::COLOR_BITNESS_565 || big_endian != this->big_endian_ || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { - Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); this->mark_dirty_(x_start, y_start, w, h); this->flush_dirty_(); } else if (this->buffer_ != nullptr) { diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 393e052b26b..f6aec76976b 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -36,8 +36,6 @@ class MipiRgb : public display::Display { #endif void update() override; void fill(Color color) override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } @@ -69,8 +67,11 @@ class MipiRgb : public display::Display { void dump_config() override; void draw_pixel_at(int x, int y, Color color) override; - // this will be horribly slow. protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + + // this will be horribly slow. void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); bool check_buffer_(); diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index cbf025e8c31..f8fecf2e4ea 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -199,22 +199,6 @@ class MipiSpi : public display::Display, this->init_sequence_len_ = 0; } - // Drawing operations - - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { - if (this->is_failed()) - return; - if (w <= 0 || h <= 0) - return; - if (!this->is_native_block_(order, bitness, big_endian)) { - this->draw_pixels_slow(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); - return; - } - this->write_to_display_(x_start, y_start, w, h, reinterpret_cast(ptr), x_offset, y_offset, - x_pad); - } - void dump_config() override { internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, @@ -223,6 +207,22 @@ class MipiSpi : public display::Display, } protected: + // Drawing operations + + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (this->is_failed()) + return; + if (w <= 0 || h <= 0) + return; + if (!this->is_native_block_(bitness, big_endian)) { + this->draw_pixels_slow(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + this->write_to_display_(x_start, y_start, w, h, reinterpret_cast(ptr), x_offset, y_offset, + x_pad); + } + /* METHODS */ // If hardware rotation is in use, the actual display width/height changes with rotation int get_width_internal() override { @@ -421,15 +421,15 @@ class MipiSpi : public display::Display, } // True when the block is already in the buffer pixel format and can be copied without per pixel work - bool is_native_block_(display::ColorOrder order, display::ColorBitness bitness, bool big_endian) const { - return get_pixel_mode(bitness) == BUFFERPIXEL && big_endian == IS_BIG_ENDIAN && order == display::COLOR_ORDER_RGB && + bool is_native_block_(display::ColorBitness bitness, bool big_endian) const { + return get_pixel_mode(bitness) == BUFFERPIXEL && big_endian == IS_BIG_ENDIAN && (HAS_HARDWARE_ROTATION || this->rotation_ == display::DISPLAY_ROTATION_0_DEGREES) && !this->is_clipping(); } // Without a buffer there is nothing to convert a foreign block into, so it is dropped - virtual void draw_pixels_slow(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + virtual void draw_pixels_slow(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { - esph_log_e(TAG, "Unsupported color depth, order, bit order or rotation"); + esph_log_e(TAG, "Unsupported color depth, bit order or rotation"); } /** @@ -625,42 +625,6 @@ class MipiSpiBuffer #endif } - // While update() runs the writer, a block goes into the buffer slice, or the slice flush would paint over it. - // Outside update() (e.g. LVGL) it is written straight to the display. - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { - if (!this->updating_) { - MipiSpiBuffer::MipiSpi::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, - y_offset, x_pad); - return; - } - if (this->is_failed() || w <= 0 || h <= 0) - return; - if (!this->is_native_block_(order, bitness, big_endian)) { - display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); - return; - } - // Only the rows inside the current slice are kept - const int first_col = std::max(0, -x_start); - const int end_col = std::min(w, this->get_width_internal() - x_start); - const int first_row = std::max(0, this->start_line_ - y_start); - const int end_row = std::min(h, this->end_line_ - y_start); - if (first_col >= end_col || first_row >= end_row) - return; - const size_t stride = x_offset + w + x_pad; - const size_t buffer_stride = round_buffer(this->get_width_internal()); - for (int y = first_row; y != end_row; y++) { - memcpy(this->buffer_ + (y_start + y - this->start_line_) * buffer_stride + x_start + first_col, - ptr + ((y_offset + y) * stride + x_offset + first_col) * sizeof(BUFFERTYPE), - (end_col - first_col) * sizeof(BUFFERTYPE)); - } - this->x_low_ = std::min(this->x_low_, x_start + first_col); - this->y_low_ = std::min(this->y_low_, y_start + first_row); - this->x_high_ = std::max(this->x_high_, x_start + end_col - 1); - this->y_high_ = std::max(this->y_high_, y_start + end_row - 1); - } - // With hardware rotation the buffer is written in drawn coordinates. bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); } @@ -716,11 +680,45 @@ class MipiSpiBuffer } protected: - void draw_pixels_slow(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, - int x_pad) override { - display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + // While update() runs the writer, a block goes into the buffer slice, or the slice flush would paint over it. + // Outside update() (e.g. LVGL) it is written straight to the display. + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (!this->updating_) { + MipiSpiBuffer::MipiSpi::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, + x_pad); + return; + } + if (this->is_failed() || w <= 0 || h <= 0) + return; + if (!this->is_native_block_(bitness, big_endian)) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, + x_pad); + return; + } + // Only the rows inside the current slice are kept + const int first_col = std::max(0, -x_start); + const int end_col = std::min(w, this->get_width_internal() - x_start); + const int first_row = std::max(0, this->start_line_ - y_start); + const int end_row = std::min(h, this->end_line_ - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + const size_t stride = x_offset + w + x_pad; + const size_t buffer_stride = round_buffer(this->get_width_internal()); + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + (y_start + y - this->start_line_) * buffer_stride + x_start + first_col, + ptr + ((y_offset + y) * stride + x_offset + first_col) * sizeof(BUFFERTYPE), + (end_col - first_col) * sizeof(BUFFERTYPE)); + } + this->x_low_ = std::min(this->x_low_, x_start + first_col); + this->y_low_ = std::min(this->y_low_, y_start + first_row); + this->x_high_ = std::max(this->x_high_, x_start + end_col - 1); + this->y_high_ = std::max(this->y_high_, y_start + end_row - 1); + } + + void draw_pixels_slow(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); } // Convert a color to the buffer pixel format. static BUFFERTYPE convert_color(const Color &color) { diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 7e9b0bd89bd..f6ccdef27a5 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -1,4 +1,5 @@ #include "pixoo.h" +#include "esphome/components/display/display_color_utils.h" #include "esphome/core/log.h" @@ -144,17 +145,16 @@ void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { this->set_pixel_(static_cast(y) * side + x, color); } -void Pixoo::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { - // Fast path for the common LVGL/image blit: RGB565, RGB order, no rotation, no active clipping. +// flatten keeps the RGB565 decode inlined in the pixel loop, as in Display's own per pixel loop. +void __attribute__((flatten)) +Pixoo::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) { + // Fast path for the common LVGL/image blit: RGB565, no rotation, no active clipping. // Anything else defers to the base implementation, which decodes per pixel and routes through // draw_pixel_at() so rotation, clipping and other color formats stay correct. - // NOTE: the stride/index math and 565->888 expansion below mirror Display::draw_pixels_at (the - // source of truth) -- keep them in sync if the base ever changes its source layout or decoding. - if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || - this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { - display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + if (bitness != display::COLOR_BITNESS_565 || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || + this->is_clipping()) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } const int side = static_cast(this->model_); @@ -168,14 +168,10 @@ void Pixoo::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t const int dst_x = x_start + x; if (dst_x < 0 || dst_x >= side) continue; - const size_t byte_idx = source_idx * 2; - const uint16_t rgb565 = - big_endian ? (ptr[byte_idx] << 8) | ptr[byte_idx + 1] : ptr[byte_idx] | (ptr[byte_idx + 1] << 8); - const uint8_t r5 = (rgb565 >> 11) & 0x1F; - const uint8_t g6 = (rgb565 >> 5) & 0x3F; - const uint8_t b5 = rgb565 & 0x1F; this->set_pixel_(static_cast(dst_y) * side + dst_x, - Color((r5 << 3) | (r5 >> 2), (g6 << 2) | (g6 >> 4), (b5 << 3) | (b5 >> 2))); + display::ColorUtil::to_color( + display::ColorUtil::read_packed(ptr, source_idx, big_endian), + display::COLOR_BITNESS_565)); } } } diff --git a/esphome/components/pixoo/pixoo.h b/esphome/components/pixoo/pixoo.h index 4913ef85db9..e8c8efbae73 100644 --- a/esphome/components/pixoo/pixoo.h +++ b/esphome/components/pixoo/pixoo.h @@ -38,10 +38,11 @@ class Pixoo : public display::Display, void fill(Color color) override; void draw_pixel_at(int x, int y, Color color) override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_width_internal() override { return static_cast(this->model_); } int get_height_internal() override { return static_cast(this->model_); } diff --git a/esphome/components/qspi_dbi/qspi_dbi.cpp b/esphome/components/qspi_dbi/qspi_dbi.cpp index ce0df1beb50..8387652d31e 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.cpp +++ b/esphome/components/qspi_dbi/qspi_dbi.cpp @@ -72,7 +72,7 @@ void QspiDbi::draw_absolute_pixel_internal(int x, int y, Color color) { uint32_t pos = (y * this->width_) + x; bool updated = false; pos = pos * 2; - uint16_t new_color = display::ColorUtil::color_to_565(color, display::ColorOrder::COLOR_ORDER_RGB); + uint16_t new_color = display::ColorUtil::color_to_565(color); if (this->buffer_[pos] != static_cast(new_color >> 8)) { this->buffer_[pos] = static_cast(new_color >> 8); updated = true; @@ -137,20 +137,20 @@ void QspiDbi::set_addr_window_(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y this->write_command_(CASET, buf, sizeof buf); } -void QspiDbi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void QspiDbi::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (!this->setup_complete_ || this->is_failed()) return; if (w <= 0 || h <= 0) return; - if (bitness != display::COLOR_BITNESS_565 || order != this->color_mode_ || - big_endian != (this->bit_order_ == spi::BIT_ORDER_MSB_FIRST) || + if (bitness != display::COLOR_BITNESS_565 || big_endian != (this->bit_order_ == spi::BIT_ORDER_MSB_FIRST) || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { - Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } else if (this->updating_) { // Inside update() the block goes into the buffer, or the flush at its end would paint over it - this->copy_to_buffer_(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + this->copy_to_buffer_(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } else if (this->draw_from_origin_) { auto stride = x_offset + w + x_pad; @@ -169,11 +169,11 @@ void QspiDbi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } -void QspiDbi::copy_to_buffer_(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void QspiDbi::copy_to_buffer_(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) { // The buffer holds big endian RGB565 - if (!big_endian || order != display::COLOR_ORDER_RGB) { - Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + if (!big_endian) { + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } const int first_col = std::max(0, -x_start), end_col = std::min(w, static_cast(this->width_) - x_start); diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index 1c2565625b2..2d7c4572ca9 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -112,12 +112,12 @@ class QspiDbi final : public display::DisplayBuffer, } void write_sequence_(const std::vector &vec); void draw_absolute_pixel_internal(int x, int y, Color color) override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); - void copy_to_buffer_(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad); + void copy_to_buffer_(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad); /** * the RM67162 in quad SPI mode seems to work like this (not in the datasheet, this is deduced from the * sample code.) diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index 9f92a850f55..1e248780414 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -47,14 +47,14 @@ void RpiDpiRgb::loop() { esp_lcd_rgb_panel_restart(this->handle_); } -void RpiDpiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void RpiDpiRgb::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (w <= 0 || h <= 0) return; - if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || !big_endian || - this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { - display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + if (bitness != display::COLOR_BITNESS_565 || !big_endian || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || + this->is_clipping()) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index 5f1f3be0191..3c4d7f14e9b 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -24,8 +24,6 @@ class RpiDpiRgb final : public display::Display { void setup() override; void loop() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; void draw_pixel_at(int x, int y, Color color) override; display::ColorOrder get_color_mode() { return this->color_mode_; } @@ -62,6 +60,9 @@ class RpiDpiRgb final : public display::Display { void dump_config() override; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); int get_width_internal() override { return this->width_; } diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index 5dd25ec2bb8..e98cf3da482 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -148,13 +148,13 @@ void Sdl::redraw_(SDL_Rect &rect) { SDL_RenderPresent(this->renderer_); } -void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void Sdl::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (this->texture_ == nullptr) return; SDL_Rect rect{x_start, y_start, w, h}; if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || big_endian) { - Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); } else { auto stride = x_offset + w + x_pad; const auto *data = ptr + (stride * y_offset + x_offset) * 2; @@ -181,7 +181,7 @@ void Sdl::draw_pixel_at(int x, int y, Color color) { } SDL_Rect rect{x, y, 1, 1}; - auto data = (display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB)); + auto data = (display::ColorUtil::color_to_565(color)); SDL_UpdateTexture(this->texture_, &rect, &data, 2); if (x < this->x_low_) this->x_low_ = x; diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index c927004e1f6..e46a64acdc2 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -21,8 +21,6 @@ class Sdl final : public display::Display, public snapshot::Snapshot { void update() override; void loop() override; void setup() override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; void draw_pixel_at(int x, int y, Color color) override; void process_key(uint32_t keycode, bool down); void set_dimensions(uint16_t width, uint16_t height) { @@ -53,6 +51,9 @@ class Sdl final : public display::Display, public snapshot::Snapshot { bool mouse_down{}; protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } void redraw_(SDL_Rect &rect); diff --git a/esphome/components/snapshot/display/snapshot_display.cpp b/esphome/components/snapshot/display/snapshot_display.cpp index 6618d9ae74a..301e2bd47ba 100644 --- a/esphome/components/snapshot/display/snapshot_display.cpp +++ b/esphome/components/snapshot/display/snapshot_display.cpp @@ -34,12 +34,12 @@ void SnapshotDisplay::dump_config() { LOG_DISPLAY("", "Snapshot", this); } void SnapshotDisplay::draw_absolute_pixel_internal(int x, int y, Color color) { if (this->buffer_ == nullptr || x < 0 || x >= this->width_ || y < 0 || y >= this->height_) return; - this->pixels_()[y * this->width_ + x] = display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB); + this->pixels_()[y * this->width_ + x] = display::ColorUtil::color_to_565(color); } -void SnapshotDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, - display::ColorOrder order, display::ColorBitness bitness, bool big_endian, - int x_offset, int y_offset, int x_pad) { +void SnapshotDisplay::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (this->buffer_ == nullptr) return; // Anything that is not already laid out the way the buffer is, or that would reach outside it, @@ -48,7 +48,7 @@ void SnapshotDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, con bitness == display::COLOR_BITNESS_565 && !big_endian && x_start >= 0 && y_start >= 0 && x_start + w <= this->width_ && y_start + h <= this->height_; if (!copyable) { - DisplayBuffer::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + DisplayBuffer::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } const size_t stride = static_cast(x_offset) + w + x_pad; diff --git a/esphome/components/snapshot/display/snapshot_display.h b/esphome/components/snapshot/display/snapshot_display.h index 5317bc6058b..31e711c3bf5 100644 --- a/esphome/components/snapshot/display/snapshot_display.h +++ b/esphome/components/snapshot/display/snapshot_display.h @@ -23,10 +23,10 @@ class SnapshotDisplay final : public display::DisplayBuffer, public Snapshot { this->height_ = height; } - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; - protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + void draw_absolute_pixel_internal(int x, int y, Color color) override; int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 756473dae71..6bffafe7389 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -50,14 +50,14 @@ void ST7701S::loop() { esp_lcd_rgb_panel_restart(this->handle_); } -void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void ST7701S::draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) { if (w <= 0 || h <= 0) return; - if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || !big_endian || - this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { - display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + if (bitness != display::COLOR_BITNESS_565 || !big_endian || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || + this->is_clipping()) { + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index e34b980e73b..181a580ada7 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -34,8 +34,6 @@ class ST7701S final : public display::Display, void update() override { this->do_update_(); } void setup() override; void loop() override; - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } @@ -76,8 +74,11 @@ class ST7701S final : public display::Display, void dump_config() override; void draw_pixel_at(int x, int y, Color color) override; - // this will be horribly slow. protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override; + + // this will be horribly slow. void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); void write_command_(uint8_t value); diff --git a/esphome/components/st7735/st7735.cpp b/esphome/components/st7735/st7735.cpp index 94b94d17a98..c8b10fd8318 100644 --- a/esphome/components/st7735/st7735.cpp +++ b/esphome/components/st7735/st7735.cpp @@ -449,8 +449,8 @@ void HOT ST7735::write_display_data_() { if (this->eightbitcolor_) { for (size_t line = 0; line < this->get_buffer_length(); line = line + this->get_width_internal()) { for (int index = 0; index < this->get_width_internal(); ++index) { - auto color332 = display::ColorUtil::to_color(this->buffer_[index + line], display::ColorOrder::COLOR_ORDER_RGB, - display::ColorBitness::COLOR_BITNESS_332, true); + auto color332 = + display::ColorUtil::to_color(this->buffer_[index + line], display::ColorBitness::COLOR_BITNESS_332, true); auto color = display::ColorUtil::color_to_565(color332); diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index 343d196b507..84b27251053 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -182,8 +182,7 @@ void ST7789V::write_display_data() { for (size_t line = 0; line < this->get_buffer_length_(); line += width) { for (size_t index = 0; index < width; ++index) { auto color = display::ColorUtil::color_to_565( - display::ColorUtil::to_color(this->buffer_[index + line], display::ColorOrder::COLOR_ORDER_RGB, - display::ColorBitness::COLOR_BITNESS_332, true)); + display::ColorUtil::to_color(this->buffer_[index + line], display::ColorBitness::COLOR_BITNESS_332, true)); temp_buffer[temp_index++] = (uint8_t) (color >> 8); temp_buffer[temp_index++] = (uint8_t) color; if (temp_index == TEMP_BUFFER_SIZE) { diff --git a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp index da96dd83b9a..9469011cf68 100644 --- a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp +++ b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp @@ -47,7 +47,7 @@ static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_e BenchDisplay display; display.set_rotation(rotation); for (auto _ : state) { - display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), bitness, big_endian, 0, 0, 0); } state.SetItemsProcessed(state.iterations() * kWidth * kHeight); } diff --git a/tests/benchmarks/components/image/bench_image_draw.cpp b/tests/benchmarks/components/image/bench_image_draw.cpp index 6bdd0acffbc..63e2d8bac6f 100644 --- a/tests/benchmarks/components/image/bench_image_draw.cpp +++ b/tests/benchmarks/components/image/bench_image_draw.cpp @@ -42,10 +42,10 @@ class BenchDisplay : public display::DisplayBuffer { class BulkBenchDisplay : public BenchDisplay { public: BulkBenchDisplay() : frame565_(std::make_unique(kStride * kStride)) {} - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { if (bitness != display::COLOR_BITNESS_565 || big_endian) { - BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + BenchDisplay::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); return; } const size_t stride = x_offset + w + x_pad; diff --git a/tests/components/display/test_draw_pixels_at.cpp b/tests/components/display/test_draw_pixels_at.cpp index 1278e2462df..3fbf76dd713 100644 --- a/tests/components/display/test_draw_pixels_at.cpp +++ b/tests/components/display/test_draw_pixels_at.cpp @@ -52,8 +52,8 @@ static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool b RecordingDisplay rows; RecordingDisplay columns; columns.set_axes_swapped(true); - rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); - columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + rows.draw_pixels_at(10, 20, W, H, source.data(), bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(10, 20, W, H, source.data(), bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); EXPECT_EQ(rows.count(), static_cast(W * H)); EXPECT_EQ(columns.count(), static_cast(W * H)); EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels()); @@ -69,7 +69,7 @@ TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888) { expect_walks_match(COLOR_BITNE static void expect_golden(ColorBitness bitness, std::initializer_list bytes, bool big_endian, Color expected) { const std::vector source(bytes); RecordingDisplay display; - display.draw_pixels_at(3, 4, 1, 1, source.data(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + display.draw_pixels_at(3, 4, 1, 1, source.data(), bitness, big_endian, 0, 0, 0); ASSERT_EQ(display.count(), 1u); EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32)); } @@ -97,13 +97,160 @@ TEST(DrawPixelsAt, ColumnWalkChangesOrder) { RecordingDisplay rows; RecordingDisplay columns; columns.set_axes_swapped(true); - rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); - columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, - X_PAD); + rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); ASSERT_GT(rows.count(), 1u); ASSERT_GT(columns.count(), 1u); EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first } +// The multiply and shift field expansion gives exactly what dividing with esp_scale() does. +TEST(ColorUtil, ExpandFieldMatchesEspScale) { + // 2, 3, 5, 6 and 8 have a multiply and shift; the others fall back to esp_scale() + for (const uint8_t bits : {1, 2, 3, 4, 5, 6, 7, 8}) { + const uint8_t max = (1 << bits) - 1; + for (uint32_t value = 0; value <= max; value++) + EXPECT_EQ(expand_field(value, bits), esp_scale(value, max)) << "bits " << int(bits) << " value " << value; + } +} + +// to_color() decodes every 565 and 332 value as the per channel esp_scale() arithmetic does. +TEST(ColorUtil, ToColorMatchesEspScale) { + for (uint32_t value = 0; value != 0x10000; value++) { + const Color expected(esp_scale((value >> 11) & 0x1F, 31), esp_scale((value >> 5) & 0x3F, 63), + esp_scale(value & 0x1F, 31)); + ASSERT_EQ(ColorUtil::to_color(value, COLOR_BITNESS_565).raw_32, expected.raw_32) << "565 value " << value; + } + for (uint32_t value = 0; value != 0x100; value++) { + const Color expected(esp_scale((value >> 5) & 0x07, 7), esp_scale((value >> 2) & 0x07, 7), + esp_scale(value & 0x03, 3)); + ASSERT_EQ(ColorUtil::to_color(value, COLOR_BITNESS_332).raw_32, expected.raw_32) << "332 value " << value; + } +} + +// Without right_bit_aligned each channel is read from its own byte; at 8 bits it comes back unchanged. +TEST(ColorUtil, ToColorByteAligned) { + EXPECT_EQ(ColorUtil::to_color(0x563412, COLOR_BITNESS_888, false).raw_32, Color(0x56, 0x34, 0x12).raw_32); +} + +// A driver that copies blocks itself overrides the protected draw_pixels_internal() hook. +class HookDisplay : public RecordingDisplay { + public: + std::vector> calls; + const uint8_t *ptr{}; + + protected: + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { + this->calls.push_back({x_start, y_start, w, h, bitness, big_endian, x_offset, y_offset, x_pad}); + this->ptr = ptr; + } +}; + +// Both public forms reach the hook, and nothing is hidden when called through the driver's own type. +TEST(DrawPixelsAt, BothFormsReachTheHook) { + const auto source = make_source(2); + HookDisplay display; + display.draw_pixels_at(10, 20, W, H, source.data(), COLOR_BITNESS_565, true, X_OFFSET, Y_OFFSET, X_PAD); + display.draw_pixels_at(1, 2, 3, 4, source.data(), COLOR_BITNESS_888, false); + ASSERT_EQ(display.calls.size(), 2u); + EXPECT_EQ(display.calls[0], (std::vector{10, 20, W, H, COLOR_BITNESS_565, true, X_OFFSET, Y_OFFSET, X_PAD})); + EXPECT_EQ(display.calls[1], (std::vector{1, 2, 3, 4, COLOR_BITNESS_888, false, 0, 0, 0})); + EXPECT_EQ(display.ptr, source.data()); + EXPECT_EQ(display.count(), 0u); +} + +// Remove before 2027.5.0, with the draw_pixels_at() overloads that take a color order. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + +// An out of tree driver that still overrides only the form with a color order. +class LegacyDisplay : public RecordingDisplay { + public: + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + this->calls_.push_back({x_start, y_start, w, h, x_offset, y_offset, x_pad}); + this->order_ = order; + this->ptr_ = ptr; + this->bitness_ = bitness; + this->big_endian_ = big_endian; + } + + std::vector> calls_; + ColorOrder order_{COLOR_ORDER_GRB}; + const uint8_t *ptr_{}; + ColorBitness bitness_{COLOR_BITNESS_888}; + bool big_endian_{}; +}; + +// Callers use the form without a color order; a legacy override still receives every block, as RGB. +TEST(DrawPixelsAt, LegacyOverrideStillReceivesBlocks) { + const auto source = make_source(2); + LegacyDisplay legacy; + Display &display = legacy; + display.draw_pixels_at(10, 20, W, H, source.data(), COLOR_BITNESS_565, true, X_OFFSET, Y_OFFSET, X_PAD); + display.draw_pixels_at(1, 2, 3, 4, source.data(), COLOR_BITNESS_565, true); + ASSERT_EQ(legacy.calls_.size(), 2u); + EXPECT_EQ(legacy.calls_[0], (std::vector{10, 20, W, H, X_OFFSET, Y_OFFSET, X_PAD})); + EXPECT_EQ(legacy.calls_[1], (std::vector{1, 2, 3, 4, 0, 0, 0})); + EXPECT_EQ(legacy.order_, COLOR_ORDER_RGB); + EXPECT_EQ(legacy.ptr_, source.data()); + EXPECT_EQ(legacy.bitness_, COLOR_BITNESS_565); + EXPECT_TRUE(legacy.big_endian_); + EXPECT_EQ(legacy.count(), 0u); +} + +// The deprecated form still honors a color order other than RGB when it draws per pixel. +static void expect_ordered(ColorBitness bitness, std::initializer_list bytes, uint32_t code, ColorOrder order, + Color expected) { + const std::vector source(bytes); + RecordingDisplay display; + display.draw_pixels_at(3, 4, 1, 1, source.data(), order, bitness, true); + ASSERT_EQ(display.count(), 1u); + EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32)); + EXPECT_EQ(expected.raw_32, ColorUtil::to_color(code, order, bitness).raw_32); +} + +// 888 is b, g, r in memory: 0x563412 +TEST(DrawPixelsAt, DeprecatedOrderRgb) { + expect_ordered(COLOR_BITNESS_888, {0x12, 0x34, 0x56}, 0x563412, COLOR_ORDER_RGB, Color(0x56, 0x34, 0x12)); +} +TEST(DrawPixelsAt, DeprecatedOrderBgr) { + expect_ordered(COLOR_BITNESS_888, {0x12, 0x34, 0x56}, 0x563412, COLOR_ORDER_BGR, Color(0x12, 0x34, 0x56)); +} +TEST(DrawPixelsAt, DeprecatedOrderGrb) { + expect_ordered(COLOR_BITNESS_888, {0x12, 0x34, 0x56}, 0x563412, COLOR_ORDER_GRB, Color(0x34, 0x56, 0x12)); +} +// 565 big endian 0xE007 is r 230, g 0, b 57 in RGB order +TEST(DrawPixelsAt, DeprecatedOrderBgr565) { + expect_ordered(COLOR_BITNESS_565, {0xE0, 0x07}, 0xE007, COLOR_ORDER_BGR, Color(57, 0, 230)); +} +TEST(DrawPixelsAt, DeprecatedOrderGrb565) { + expect_ordered(COLOR_BITNESS_565, {0xE0, 0x07}, 0xE007, COLOR_ORDER_GRB, Color(0, 230, 57)); +} +// 332 0xE0 is r 255, g 0, b 0 in RGB order +TEST(DrawPixelsAt, DeprecatedOrderBgr332) { + expect_ordered(COLOR_BITNESS_332, {0xE0}, 0xE0, COLOR_ORDER_BGR, Color(0, 0, 255)); +} +TEST(DrawPixelsAt, DeprecatedOrderGrb332) { + expect_ordered(COLOR_BITNESS_332, {0xE0}, 0xE0, COLOR_ORDER_GRB, Color(0, 255, 0)); +} + +// The deprecated packing functions still honor every order; with RGB they match the new ones. +// 0xC0, 0x60, 0x30 is red 24, green 24, blue 6 in 565 and red 6, green 3, blue 0 in 332. +TEST(DrawPixelsAt, DeprecatedPackingHonorsTheOrder) { + const Color color(0xC0, 0x60, 0x30); + EXPECT_EQ(ColorUtil::color_to_565(color), 0xC306); + EXPECT_EQ(ColorUtil::color_to_565(color, COLOR_ORDER_RGB), 0xC306); + EXPECT_EQ(ColorUtil::color_to_565(color, COLOR_ORDER_BGR), 0x3318); + EXPECT_EQ(ColorUtil::color_to_565(color, COLOR_ORDER_GRB), 0x6306); + EXPECT_EQ(ColorUtil::color_to_332(color), 0xCC); + EXPECT_EQ(ColorUtil::color_to_332(color, COLOR_ORDER_RGB), 0xCC); + EXPECT_EQ(ColorUtil::color_to_332(color, COLOR_ORDER_BGR), 0x1E); + EXPECT_EQ(ColorUtil::color_to_332(color, COLOR_ORDER_GRB), 0x78); +} + +#pragma GCC diagnostic pop + } // namespace esphome::display::testing diff --git a/tests/components/epaper_spi/display/test_draw_pixels.cpp b/tests/components/epaper_spi/display/test_draw_pixels.cpp index a3b47ea8fc1..1933637efdb 100644 --- a/tests/components/epaper_spi/display/test_draw_pixels.cpp +++ b/tests/components/epaper_spi/display/test_draw_pixels.cpp @@ -42,11 +42,11 @@ template class TestableDisplay : public Driver { std::vector bounds() const { return {this->x_low_, this->y_low_, this->x_high_, this->y_high_}; } - void draw_one_by_one(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + void draw_one_by_one(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) { // The per-pixel path the fast one must match - display::Display::draw_pixels_at( // NOLINT(bugprone-parent-virtual-call) - x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + display::Display::draw_pixels_internal( // NOLINT(bugprone-parent-virtual-call) + x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); } }; @@ -100,10 +100,10 @@ TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) { } // a 3 pixel left offset, 2 rows skipped and 4 pixels of padding const auto data = random_bytes((3 + block.w + 4) * (2 + block.h), seed++); - fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, - display::COLOR_BITNESS_565, big_endian, 3, 2, 4); - slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, - display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_BITNESS_565, big_endian, + 3, 2, 4); + slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_BITNESS_565, big_endian, + 3, 2, 4); const auto where = ::testing::Message() << "rotation " << rotation << " mirroring " << int(mirroring) << " big endian " << big_endian << " block " << block.x << "," << block.y << " " << block.w << "x" << block.h; @@ -115,16 +115,28 @@ TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) { } } -/// The colour order reaches the RGB565 block writer. -TEST(EPaperDrawPixels, ColourOrderIsHonoured) { - for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { - TestableDisplay fast(21, 13); - TestableDisplay slow(21, 13); - const auto data = random_bytes(21 * 13, 99); - fast.draw_pixels_at(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); - slow.draw_one_by_one(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); - EXPECT_EQ(fast.buffer(), slow.buffer()) << "order " << int(order); - } +/// Every form of draw_pixels_at() can be called through the driver's own type and draws the same result. +TEST(EPaperDrawPixels, AllFormsAreVisibleThroughTheDriver) { + const auto data = rgb565(24 * 16, 11); + TestableDisplay slow(24, 16); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + TestableDisplay full(24, 16); + TestableDisplay packed(24, 16); + full.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + packed.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false); + EXPECT_EQ(full.buffer(), slow.buffer()); + EXPECT_EQ(packed.buffer(), slow.buffer()); + // Remove before 2027.5.0, with the forms that take a color order +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + TestableDisplay old_full(24, 16); + TestableDisplay old_packed(24, 16); + old_full.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + old_packed.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false); +#pragma GCC diagnostic pop + EXPECT_EQ(old_full.buffer(), slow.buffer()); + EXPECT_EQ(old_packed.buffer(), slow.buffer()); } /// With a clipping region set the block goes through draw_pixel_at(), which honours it. @@ -134,8 +146,8 @@ TEST(EPaperDrawPixels, ClippingFallsBackToDrawingPixelByPixel) { const auto data = rgb565(24 * 16, 7); for (auto *display : {&fast, &slow}) display->start_clipping(4, 4, 12, 10); - fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); - slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); EXPECT_EQ(fast.buffer(), slow.buffer()); EXPECT_EQ(fast.bounds(), (std::vector{4, 4, 12, 10})); } @@ -145,13 +157,28 @@ TEST(EPaperDrawPixels, OtherBufferLayoutsDrawPixelByPixel) { TestableDisplay fast(16, 4); TestableDisplay slow(16, 4); const auto data = rgb565(16 * 4, 11); - fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); - slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); EXPECT_EQ(fast.buffer(), slow.buffer()); } -/// The RGB565 lookup gives exactly what converting the colour and thresholding it does, for every value and order. -TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) { +/// The RGB565 lookup gives exactly what converting the color and thresholding it does, for every value. +TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColorConversion) { + const Rgb565ToMono to_mono; + size_t mismatches = 0; + for (uint32_t value = 0; value != 0x10000; value++) { + const uint8_t expected = color_to_mono(ColorUtil::to_color(value, display::COLOR_BITNESS_565)); + if (to_mono(static_cast(value)) != expected) + mismatches++; + } + EXPECT_EQ(mismatches, 0u); +} + +// Remove before 2027.5.0 +/// The deprecated lookup that takes a color order still matches the deprecated conversion with that order. +TEST(EPaperDrawPixels, DeprecatedOrderedLookupMatchesTheColorConversion) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { const Rgb565ToMono to_mono(order); size_t mismatches = 0; @@ -162,6 +189,7 @@ TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) { } EXPECT_EQ(mismatches, 0u) << "order " << int(order); } +#pragma GCC diagnostic pop } /// Other bit depths, and a buffer split into several blocks, go through draw_pixel_at() and give the same result. @@ -172,10 +200,8 @@ TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) { fast.split_buffer(7); slow.split_buffer(7); const auto data = rgb565(24 * 16, 5); - fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, - 0); - slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, - 0); + fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_BITNESS_565, false, 0, 0, 0); EXPECT_EQ(fast.buffer(), slow.buffer()) << "split buffer"; EXPECT_EQ(fast.bounds(), slow.bounds()) << "split buffer"; } @@ -183,8 +209,8 @@ TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) { TestableDisplay fast(24, 16); TestableDisplay slow(24, 16); const auto data = random_bytes(24 * 16, 9); - fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); - slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), bitness, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), bitness, false, 0, 0, 0); EXPECT_EQ(fast.buffer(), slow.buffer()) << "bitness " << int(bitness); EXPECT_EQ(fast.bounds(), slow.bounds()) << "bitness " << int(bitness); } diff --git a/tests/components/image/test_image_draw.cpp b/tests/components/image/test_image_draw.cpp index 80db8a4ad2a..516f07fea52 100644 --- a/tests/components/image/test_image_draw.cpp +++ b/tests/components/image/test_image_draw.cpp @@ -26,8 +26,8 @@ struct Blit { // Records the bulk calls and counts the per pixel calls Image::draw() makes. class RecordingDisplay : public TestDisplay { public: - void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { this->blits.push_back({x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad}); } void draw_pixel_at(int x, int y, Color color) override { this->pixels++; } @@ -154,7 +154,7 @@ static void expect_fallback_decodes(bool big_endian) { for (const auto &pixel : display.pixels) { const uint32_t rgb565 = display::ColorUtil::read_packed(data.data(), pixel.x + pixel.y * W, big_endian); - const Color expected = display::ColorUtil::to_color(rgb565, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565); + const Color expected = display::ColorUtil::to_color(rgb565, display::COLOR_BITNESS_565); EXPECT_EQ(pixel.raw, expected.raw_32) << "at " << pixel.x << "," << pixel.y; } } From 57dd09018efa1d5b390c8b6f0815bb8facb9d8a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:00:58 -1000 Subject: [PATCH 469/651] Bump yamlrocks from 0.6.1 to 0.7.0 (#20904) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 68c4bbaf426..f0dd218adf2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_ve ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin -yamlrocks==0.6.1 # used by script/sync_dependency_versions.py +yamlrocks==0.7.0 # used by script/sync_dependency_versions.py # Unit tests pytest==9.1.1 From c0b7c5e59023f9bc821dfd7d35b66c8f0ddc70ab Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:03:07 +0200 Subject: [PATCH 470/651] [dfrobot_sen0395] Forward trigger arguments in settings actions (#20885) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/dfrobot_sen0395/automation.h | 17 +- tests/components/dfrobot_sen0395/common.yaml | 15 ++ .../dfrobot_sen0395/settings_action.cpp | 145 ++++++++++++++++++ 3 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 tests/components/dfrobot_sen0395/settings_action.cpp diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index c3632085310..58dbb9afb09 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -26,17 +26,22 @@ class DfrobotSen0395SettingsAction final : public Action, public Parented TEMPLATABLE_VALUE(float, det_min4) TEMPLATABLE_VALUE(float, det_max4) - void play(const Ts &...x) { + void play(const Ts &...x) override { this->parent_->enqueue(make_unique(0)); if (this->factory_reset_.has_value() && this->factory_reset_.value(x...) == true) { this->parent_->enqueue(make_unique()); } if (this->det_min1_.has_value() && this->det_max1_.has_value()) { - if (this->det_min1_.value() >= 0 && this->det_max1_.value() >= 0) { - this->parent_->enqueue(make_unique( - this->det_min1_.value_or(-1), this->det_max1_.value_or(-1), this->det_min2_.value_or(-1), - this->det_max2_.value_or(-1), this->det_min3_.value_or(-1), this->det_max3_.value_or(-1), - this->det_min4_.value_or(-1), this->det_max4_.value_or(-1))); + // Each segment is read once, so the guard and the command see the same values. + const float min1 = this->det_min1_.value(x...); + if (min1 >= 0) { + const float max1 = this->det_max1_.value(x...); + if (max1 >= 0) { + this->parent_->enqueue(make_unique( + min1, max1, this->det_min2_.value_or(x..., -1), this->det_max2_.value_or(x..., -1), + this->det_min3_.value_or(x..., -1), this->det_max3_.value_or(x..., -1), + this->det_min4_.value_or(x..., -1), this->det_max4_.value_or(x..., -1))); + } } } if (this->delay_after_detect_.has_value() && this->delay_after_disappear_.has_value()) { diff --git a/tests/components/dfrobot_sen0395/common.yaml b/tests/components/dfrobot_sen0395/common.yaml index 7f980574ed9..48226193bc2 100644 --- a/tests/components/dfrobot_sen0395/common.yaml +++ b/tests/components/dfrobot_sen0395/common.yaml @@ -17,6 +17,21 @@ esphome: dfrobot_sen0395: - id: mmwave +number: + - platform: template + id: mmwave_max_distance + name: mmwave max distance + min_value: 1 + max_value: 9 + step: 0.5 + set_action: + - dfrobot_sen0395.settings: + id: mmwave + detection_segments: + - 0cm + - !lambda "return x;" + sensitivity: 5 + binary_sensor: - platform: dfrobot_sen0395 id: mmwave_detected diff --git a/tests/components/dfrobot_sen0395/settings_action.cpp b/tests/components/dfrobot_sen0395/settings_action.cpp new file mode 100644 index 00000000000..92a75b81677 --- /dev/null +++ b/tests/components/dfrobot_sen0395/settings_action.cpp @@ -0,0 +1,145 @@ +#include +#include +#include "esphome/components/dfrobot_sen0395/automation.h" + +namespace esphome::dfrobot_sen0395::testing { +namespace { +struct CallRecord { + std::array segment_calls{}; + std::array seen_float{}; + std::array seen_int{}; + int sensitivity_calls{0}; + float sensitivity_argument{0}; +}; + +// TemplatableFn stores only a function pointer, so the test lambdas cannot capture. They record into this +// function-local static instead, which SetUp() clears before every test. +CallRecord &call_record() { + static CallRecord record{}; + return record; +} + +float segment_value(size_t index, float x, int y = 0) { + CallRecord &record = call_record(); + record.segment_calls[index]++; + record.seen_float[index] = x; + record.seen_int[index] = y; + return x + y + static_cast(index); +} +} // namespace + +// The component is final and its command queue protected, so these tests check the segment +// reads and their arguments; the enqueued DetRangeCfgCommand itself is not inspected. +class SettingsActionTest : public ::testing::Test { + protected: + void SetUp() override { call_record() = CallRecord{}; } + + DfrobotSen0395Component radar_; +}; + +TEST_F(SettingsActionTest, EmptyTriggerPreservesSegmentCallableCounts) { + DfrobotSen0395SettingsAction<> action; + action.set_parent(&this->radar_); + action.set_det_min1([]() -> float { return segment_value(0, 0); }); + action.set_det_max1([]() -> float { return segment_value(1, 0); }); + action.set_det_min2([]() -> float { return segment_value(2, 0); }); + action.set_det_max2([]() -> float { return segment_value(3, 0); }); + action.set_det_min3([]() -> float { return segment_value(4, 0); }); + action.set_det_max3([]() -> float { return segment_value(5, 0); }); + action.set_det_min4([]() -> float { return segment_value(6, 0); }); + action.set_det_max4([]() -> float { return segment_value(7, 0); }); + action.play(); + for (size_t i = 0; i < call_record().segment_calls.size(); i++) { + EXPECT_EQ(call_record().segment_calls[i], 1) << i; + } +} + +TEST_F(SettingsActionTest, EmptyTriggerWithoutSegmentsIsValid) { + DfrobotSen0395SettingsAction<> action; + action.set_parent(&this->radar_); + action.play(); + for (auto calls : call_record().segment_calls) { + EXPECT_EQ(calls, 0); + } +} + +TEST_F(SettingsActionTest, EmptyTriggerNegativeMinKeepsShortCircuit) { + DfrobotSen0395SettingsAction<> action; + action.set_parent(&this->radar_); + action.set_det_min1([]() -> float { return segment_value(0, -1); }); + action.set_det_max1([]() -> float { return segment_value(1, 0); }); + action.play(); + EXPECT_EQ(call_record().segment_calls[0], 1); + EXPECT_EQ(call_record().segment_calls[1], 0); +} + +TEST_F(SettingsActionTest, FloatTriggerForwardsAllSegmentsAndPreservesCounts) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_det_min1([](float x) -> float { return segment_value(0, x); }); + action.set_det_max1([](float x) -> float { return segment_value(1, x); }); + action.set_det_min2([](float x) -> float { return segment_value(2, x); }); + action.set_det_max2([](float x) -> float { return segment_value(3, x); }); + action.set_det_min3([](float x) -> float { return segment_value(4, x); }); + action.set_det_max3([](float x) -> float { return segment_value(5, x); }); + action.set_det_min4([](float x) -> float { return segment_value(6, x); }); + action.set_det_max4([](float x) -> float { return segment_value(7, x); }); + action.play(2.5f); + for (size_t i = 0; i < call_record().segment_calls.size(); i++) { + EXPECT_EQ(call_record().segment_calls[i], 1) << i; + EXPECT_FLOAT_EQ(call_record().seen_float[i], 2.5f) << i; + } +} + +TEST_F(SettingsActionTest, MultipleTriggerArgumentsReachConfiguredSegments) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_det_min1([](float x, int y) -> float { return segment_value(0, x, y); }); + action.set_det_max1([](float x, int y) -> float { return segment_value(1, x, y); }); + action.play(1.5f, 2); + for (size_t i = 0; i < 2; i++) { + EXPECT_EQ(call_record().segment_calls[i], 1) << i; + EXPECT_FLOAT_EQ(call_record().seen_float[i], 1.5f) << i; + EXPECT_EQ(call_record().seen_int[i], 2) << i; + } + for (size_t i = 2; i < call_record().segment_calls.size(); i++) { + EXPECT_EQ(call_record().segment_calls[i], 0) << i; + } +} + +TEST_F(SettingsActionTest, FloatTriggerWithoutSegmentsStillEvaluatesSensitivity) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_sensitivity([](float x) -> int8_t { + call_record().sensitivity_calls++; + call_record().sensitivity_argument = x; + return 5; + }); + action.play(3.25f); + EXPECT_EQ(call_record().sensitivity_calls, 1); + EXPECT_FLOAT_EQ(call_record().sensitivity_argument, 3.25f); + for (auto calls : call_record().segment_calls) { + EXPECT_EQ(calls, 0); + } +} + +TEST_F(SettingsActionTest, FloatTriggerNegativeMinKeepsShortCircuit) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_det_min1([](float x) -> float { return segment_value(0, x); }); + action.set_det_max1([](float x) -> float { return segment_value(1, x); }); + action.play(-1.0f); + EXPECT_EQ(call_record().segment_calls[0], 1); + EXPECT_EQ(call_record().segment_calls[1], 0); + EXPECT_FLOAT_EQ(call_record().seen_float[0], -1.0f); +} + +TEST_F(SettingsActionTest, FloatTriggerUnpairedSegmentIsNotEvaluated) { + DfrobotSen0395SettingsAction action; + action.set_parent(&this->radar_); + action.set_det_min1([](float x) -> float { return segment_value(0, x); }); + action.play(4.0f); + EXPECT_EQ(call_record().segment_calls[0], 0); +} + +} // namespace esphome::dfrobot_sen0395::testing From 6fa7b87e6aa5433dd64a21f3999d93399ff1c0f0 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:04:45 +0200 Subject: [PATCH 471/651] [lp5562] Add the current per channel and logarithmic dimming (#20903) --- esphome/components/lp5562/__init__.py | 5 +++ esphome/components/lp5562/lp5562_output.cpp | 24 ++++++++++- esphome/components/lp5562/lp5562_output.h | 10 +++++ esphome/components/lp5562/output.py | 42 ++++++++++++++++++- tests/component_tests/lp5562/__init__.py | 0 tests/component_tests/lp5562/test_channels.py | 33 +++++++++++++++ tests/component_tests/lp5562/test_current.py | 23 ++++++++++ tests/components/lp5562/common.yaml | 4 ++ 8 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lp5562/__init__.py create mode 100644 tests/component_tests/lp5562/test_channels.py create mode 100644 tests/component_tests/lp5562/test_current.py diff --git a/esphome/components/lp5562/__init__.py b/esphome/components/lp5562/__init__.py index b8ace97fc9d..4a5b77f36bb 100644 --- a/esphome/components/lp5562/__init__.py +++ b/esphome/components/lp5562/__init__.py @@ -9,6 +9,8 @@ DEPENDENCIES = ["i2c"] DOMAIN = "lp5562" MULTI_CONF = True +CONF_LOGARITHMIC_DIMMING = "logarithmic_dimming" + lp5562_ns = cg.esphome_ns.namespace("lp5562") LP5562Output = lp5562_ns.class_("LP5562Output", cg.Component, i2c.I2CDevice) @@ -16,6 +18,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(LP5562Output), + cv.Optional(CONF_LOGARITHMIC_DIMMING, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) @@ -27,3 +30,5 @@ 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_LOGARITHMIC_DIMMING]: + cg.add(var.set_logarithmic_dimming(True)) diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp index b55e8b297d2..da392ea5c04 100644 --- a/esphome/components/lp5562/lp5562_output.cpp +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -15,6 +15,7 @@ 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_ENABLE_LOG_EN = 0x80; 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 @@ -26,13 +27,22 @@ static const uint8_t LP5562_REG_PWM[4] PROGMEM = { 0x0e, // white }; +// Current registers, same order +static const uint8_t LP5562_REG_CURRENT[4] PROGMEM = { + 0x05, // blue + 0x06, // green + 0x07, // red + 0x0f, // 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)) { + const uint8_t enable = LP5562_ENABLE_CHIP_EN | (this->logarithmic_dimming_ ? LP5562_ENABLE_LOG_EN : 0); + if (!this->write_byte(LP5562_REG_ENABLE, enable)) { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -42,11 +52,21 @@ void LP5562Output::setup() { 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(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + for (size_t channel = 0; channel < this->currents_.size(); channel++) { + if (!this->write_byte(progmem_read_byte(&LP5562_REG_CURRENT[channel]), this->currents_[channel])) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } } } void LP5562Output::dump_config() { - ESP_LOGCONFIG(TAG, "LP5562:"); + ESP_LOGCONFIG(TAG, + "LP5562:\n" + " Logarithmic dimming: %s", + YESNO(this->logarithmic_dimming_)); LOG_I2C_DEVICE(this); } diff --git a/esphome/components/lp5562/lp5562_output.h b/esphome/components/lp5562/lp5562_output.h index a67a4b0658b..51e17eeb6c0 100644 --- a/esphome/components/lp5562/lp5562_output.h +++ b/esphome/components/lp5562/lp5562_output.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/i2c/i2c.h" @@ -26,10 +28,18 @@ class LP5562Output final : public Component, public i2c::I2CDevice { void dump_config() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } + void set_logarithmic_dimming(bool logarithmic_dimming) { this->logarithmic_dimming_ = logarithmic_dimming; } + /// Current limit of a channel in steps of 0.1 mA, written to the chip in setup(). + void set_channel_current(uint8_t channel, uint8_t current) { this->currents_[channel] = current; } + protected: friend class LP5562Channel; void set_channel_value_(uint8_t channel, uint8_t value); + + // 17.5 mA, the chip's reset value + std::array currents_{175, 175, 175, 175}; + bool logarithmic_dimming_{false}; }; } // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/output.py b/esphome/components/lp5562/output.py index 4866eb7b9c9..a9d94ac09b9 100644 --- a/esphome/components/lp5562/output.py +++ b/esphome/components/lp5562/output.py @@ -1,7 +1,8 @@ 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.const import CONF_CHANNEL, CONF_CURRENT, CONF_ID +import esphome.final_validate as fv from esphome.types import ConfigType from . import LP5562Output, lp5562_ns @@ -10,6 +11,8 @@ DEPENDENCIES = ["lp5562"] LP5562Channel = lp5562_ns.class_("LP5562Channel", output.FloatOutput) CONF_LP5562_ID = "lp5562_id" +# Chip reset value of the current registers, 17.5 mA; matches currents_ in lp5562_output.h +CURRENT_RESET_TENTHS = 175 # Indices match the LP5562_REG_PWM lookup table in lp5562_output.cpp LP5562_CHANNELS = { @@ -19,17 +22,54 @@ LP5562_CHANNELS = { "white": 3, } + +def _tenths(amps: float) -> int: + """The register value of a current: steps of 0.1 mA.""" + return round(amps * 10000) + + +def _validate_current(amps: float) -> float: + if abs(amps * 10000 - _tenths(amps)) > 1e-6: + raise cv.Invalid("current must be a multiple of 0.1mA") + if not 0 <= _tenths(amps) <= 255: + raise cv.Invalid("current must be between 0mA and 25.5mA") + return amps + + 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), + cv.Optional(CONF_CURRENT, default="17.5mA"): cv.All( + cv.current, _validate_current + ), } ) +def _final_validate(config: ConfigType) -> ConfigType: + # Two outputs on one channel would fight over its PWM, and the last current would win + used = fv.full_config.get().data.setdefault("lp5562_channels", set()) + hub, channel = str(config[CONF_LP5562_ID]), str(config[CONF_CHANNEL]) + if (hub, channel) in used: + raise cv.Invalid( + f"Channel '{channel}' of '{hub}' is already used by another output", + [CONF_CHANNEL], + ) + used.add((hub, channel)) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_LP5562_ID]) + parent = await cg.get_variable(config[CONF_LP5562_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) + # The hub already holds the chip's reset value, 17.5 mA + if (current := _tenths(config[CONF_CURRENT])) != CURRENT_RESET_TENTHS: + cg.add(parent.set_channel_current(config[CONF_CHANNEL], current)) await output.register_output(var, config) diff --git a/tests/component_tests/lp5562/__init__.py b/tests/component_tests/lp5562/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/lp5562/test_channels.py b/tests/component_tests/lp5562/test_channels.py new file mode 100644 index 00000000000..a64acf2b348 --- /dev/null +++ b/tests/component_tests/lp5562/test_channels.py @@ -0,0 +1,33 @@ +"""Tests for the lp5562 duplicate channel check.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.lp5562.output import CONF_LP5562_ID, _final_validate +from esphome.config import Config +from esphome.const import CONF_CHANNEL, PlatformFramework +from esphome.core import ID + +from ..types import SetCoreConfigCallable + +RED = "red" +BLUE = "blue" + + +def _output(hub: str, channel: str) -> dict: + return {CONF_LP5562_ID: ID(hub), CONF_CHANNEL: channel} + + +def test_each_channel_once_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=Config()) + _final_validate(_output("lp5562_1", RED)) + _final_validate(_output("lp5562_1", BLUE)) + # Same channel on a second chip is fine + _final_validate(_output("lp5562_2", RED)) + + +def test_same_channel_twice_is_rejected(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=Config()) + _final_validate(_output("lp5562_1", RED)) + with pytest.raises(cv.Invalid, match="Channel 'red' of 'lp5562_1' is already used"): + _final_validate(_output("lp5562_1", RED)) diff --git a/tests/component_tests/lp5562/test_current.py b/tests/component_tests/lp5562/test_current.py new file mode 100644 index 00000000000..91b57a2e6f3 --- /dev/null +++ b/tests/component_tests/lp5562/test_current.py @@ -0,0 +1,23 @@ +"""Tests for the lp5562 output current option.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.lp5562.output import _tenths, _validate_current + + +def test_current_is_stored_in_steps_of_0_1_ma() -> None: + # The register counts in 0.1 mA: 17.5 mA is 175, the full scale 25.5 mA is 255 + assert _tenths(_validate_current(cv.current("17.5mA"))) == 175 + assert _tenths(_validate_current(cv.current("25.5mA"))) == 255 + assert _tenths(_validate_current(cv.current("0mA"))) == 0 + + +def test_current_between_two_steps_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="multiple of 0.1mA"): + _validate_current(cv.current("10.05mA")) + + +def test_current_above_25_5_ma_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="between 0mA and 25.5mA"): + _validate_current(cv.current("25.6mA")) diff --git a/tests/components/lp5562/common.yaml b/tests/components/lp5562/common.yaml index 5bf3406938b..60cf15cf3a8 100644 --- a/tests/components/lp5562/common.yaml +++ b/tests/components/lp5562/common.yaml @@ -2,20 +2,24 @@ lp5562: - address: 0x30 id: lp5562_1 i2c_id: i2c_bus + logarithmic_dimming: true output: - platform: lp5562 id: lp5562_red channel: red lp5562_id: lp5562_1 + current: 10mA - platform: lp5562 id: lp5562_green channel: green lp5562_id: lp5562_1 + current: 25.5mA - platform: lp5562 id: lp5562_blue channel: blue lp5562_id: lp5562_1 + current: 0.5mA - platform: lp5562 id: lp5562_white channel: white From 10d3ced897c50595e1f63d77b0116a64d2383d15 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:11:56 +0200 Subject: [PATCH 472/651] [core] Parse unit values as one decimal so listed floats match, fixing at581x power consumption (#20889) Co-authored-by: J. Nick Koston --- esphome/components/at581x/__init__.py | 2 +- esphome/config_validation.py | 11 +- tests/component_tests/at581x/__init__.py | 0 .../component_tests/at581x/test_validation.py | 105 ++++++++++++++++++ tests/components/at581x/common.yaml | 4 + tests/unit_tests/test_config_validation.py | 12 ++ 6 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/at581x/__init__.py create mode 100644 tests/component_tests/at581x/test_validation.py diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 50c9080c2dd..fd1a8002921 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -93,7 +93,7 @@ def _megahertz(value: float) -> int: def _microamps(value: float) -> int: - return int(value * 1000000) + return round(value * 1000000) RADAR_SETTINGS_SCHEMA = cv.Schema( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index e7d2501e5df..32da6e41855 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -16,6 +16,7 @@ from ipaddress import ( ip_network, ) import logging +import math import os from pathlib import Path import re @@ -1416,12 +1417,16 @@ def float_with_unit(quantity, regex_suffix, optional_unit=False): if match is None: raise Invalid(f"Expected {quantity} with unit, got {value}") - mantissa = float(match.group(1)) if match.group(2) not in METRIC_SUFFIXES: raise Invalid(f"Invalid {quantity} suffix {match.group(2)}") - multiplier = METRIC_SUFFIXES[match.group(2)] - return mantissa * multiplier + # Parse the scaled decimal in one go: "91uA" is then the same float as the + # literal 91e-6, which a product of two floats is not always. + exponent = round(math.log10(METRIC_SUFFIXES[match.group(2)])) + try: + return float(f"{match.group(1)}e{exponent}") + except ValueError as err: + raise Invalid(f"Expected {quantity} with unit, got {value}") from err return validator diff --git a/tests/component_tests/at581x/__init__.py b/tests/component_tests/at581x/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/at581x/test_validation.py b/tests/component_tests/at581x/test_validation.py new file mode 100644 index 00000000000..7ef209a4b6f --- /dev/null +++ b/tests/component_tests/at581x/test_validation.py @@ -0,0 +1,105 @@ +"""Tests for at581x power_consumption validation.""" + +from collections.abc import Callable +from decimal import Decimal + +import pytest + +from esphome import config_validation as cv +from esphome.components.at581x import CONF_POWER_CONSUMPTION, RADAR_SETTINGS_SCHEMA +from esphome.const import CONF_FREQUENCY, CONF_ID +from esphome.core import Lambda +from esphome.types import ConfigType + +ALLOWED_UA = [48, 56, 63, 70, 77, 91, 105, 115, 40, 44, 47, 51, 54, 61, 68, 78] + + +def _settings(**extra: object) -> ConfigType: + return RADAR_SETTINGS_SCHEMA( + {CONF_ID: "waveradar", CONF_FREQUENCY: "5800MHz", **extra} + ) + + +def _power(value: object) -> object: + return _settings(**{CONF_POWER_CONSUMPTION: value})[CONF_POWER_CONSUMPTION] + + +UNIT_SPELLINGS = [ + lambda ua: f"{ua}uA", + lambda ua: f"{ua}µA", + lambda ua: f"{ua} uA", + lambda ua: f"{Decimal(ua) / 1000}mA", + lambda ua: f"{Decimal(ua) / 1000000}A", + lambda ua: f"{ua * 1000}nA", +] + + +@pytest.mark.parametrize("spell", UNIT_SPELLINGS) +@pytest.mark.parametrize("microamps", ALLOWED_UA) +def test_every_listed_value_in_every_unit( + spell: Callable[[int], str], microamps: int +) -> None: + result = _power(spell(microamps)) + assert result == microamps + assert type(result) is int + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("91uA", 91), + ("105uA", 105), + ("115uA", 115), + ("40uA", 40), + ("0.07mA", 70), + ("0.091mA", 91), + ("0.000091A", 91), + ("0.000000091kA", 91), + ("+91uA", 91), + ], +) +def test_previously_broken_and_equivalent_spellings(value: str, expected: int) -> None: + assert _power(value) == expected + + +def test_default_is_70() -> None: + assert _settings()[CONF_POWER_CONSUMPTION] == 70 + + +def test_lambda_is_passed_through() -> None: + assert isinstance(_power(Lambda("return 70;")), Lambda) + + +@pytest.mark.parametrize( + "value", + [ + # Near-miss values must not be rounded onto a supported setting. + "91.00000000001uA", + "90.99999999999uA", + "91.00000000009uA", + "91.0000000001uA", + "90.9999999999uA", + "91.000000001uA", + "91.0001uA", + "91.4uA", + "0.09100000000001mA", + ], +) +def test_fractional_microamps_are_rejected(value: str) -> None: + with pytest.raises(cv.Invalid): + _power(value) + + +def test_sub_ulp_digits_are_indistinguishable_parse_noise() -> None: + # 1e-15 is below half an ulp of 91.0, so the mantissa parses to exactly 91.0. + assert float("91.000000000000001") == 91.0 + assert _power("91.000000000000001uA") == 91 + + +@pytest.mark.parametrize( + "value", + ["92uA", "0uA", "-91uA", "-0.091mA", "9" * 400 + "uA", "infuA", "nanuA", "nan"], +) +def test_unsupported_and_nonfinite_are_rejected(value: str) -> None: + with pytest.raises(cv.Invalid): + _power(value) diff --git a/tests/components/at581x/common.yaml b/tests/components/at581x/common.yaml index dfb3a9a05eb..fbe5bef16ea 100644 --- a/tests/components/at581x/common.yaml +++ b/tests/components/at581x/common.yaml @@ -17,6 +17,10 @@ esphome: frequency: !lambda "return 5800;" poweron_selfcheck_time: !lambda "return 2000;" power_consumption: !lambda "return 70;" + - at581x.settings: + id: waveradar + frequency: 5800MHz + power_consumption: 91uA - at581x.reset: id: waveradar diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 0f68ccb34ab..66732dd04db 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2101,6 +2101,18 @@ def test_float_with_unit_with_suffix() -> None: assert cv.frequency("10kHz") == 10000.0 +def test_float_with_unit_matches_the_literal() -> None: + """The scaled decimal is parsed once, so it equals the same value written as a literal.""" + assert cv.current("91uA") == 91e-6 + assert cv.current("0.091mA") == 91e-6 + assert cv.frequency("5.8GHz") == 5.8e9 + + +def test_float_with_unit_empty_mantissa() -> None: + with pytest.raises(cv.Invalid): + cv.current("uA") + + def test_float_with_unit_no_match() -> None: with pytest.raises(Invalid, match="Expected frequency with unit"): cv.frequency("!!") From 1ddacb49310eb212df6227fcd731631f166b5b0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 13:24:34 -1000 Subject: [PATCH 473/651] [tests] Set i2c_id in the component tests that clash with tca9548a (#20963) --- tests/components/bq27220/common.yaml | 1 + tests/components/bq76972/common.yaml | 1 + tests/components/fusb302b/common.yaml | 1 + tests/components/max17048/common.yaml | 1 + tests/components/spd2010/common.yaml | 3 +++ 5 files changed, 7 insertions(+) diff --git a/tests/components/bq27220/common.yaml b/tests/components/bq27220/common.yaml index 99d1e1f7f85..fad1ef169cd 100644 --- a/tests/components/bq27220/common.yaml +++ b/tests/components/bq27220/common.yaml @@ -1,6 +1,7 @@ sensor: - platform: bq27220 id: bq27220_id + i2c_id: i2c_bus voltage: name: "Battery Voltage" current: diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml index 42a9f633aff..4b2e62eb20b 100644 --- a/tests/components/bq76972/common.yaml +++ b/tests/components/bq76972/common.yaml @@ -1,5 +1,6 @@ bq76972: id: bq76972_id + i2c_id: i2c_bus address: 0x08 crc_enabled: true reg_disabled: true diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml index 94e5f2da2bc..e2c4ee87ca7 100644 --- a/tests/components/fusb302b/common.yaml +++ b/tests/components/fusb302b/common.yaml @@ -1,5 +1,6 @@ fusb302b: id: pd + i2c_id: i2c_bus interrupt_pin: ${interrupt_pin} request_voltage: 20 contract: diff --git a/tests/components/max17048/common.yaml b/tests/components/max17048/common.yaml index de0247ed4e2..628bf7ce5c2 100644 --- a/tests/components/max17048/common.yaml +++ b/tests/components/max17048/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: max17048 + i2c_id: i2c_bus battery_voltage: name: Battery Voltage battery_level: diff --git a/tests/components/spd2010/common.yaml b/tests/components/spd2010/common.yaml index 0692137638d..2bd89ebfccd 100644 --- a/tests/components/spd2010/common.yaml +++ b/tests/components/spd2010/common.yaml @@ -1,15 +1,18 @@ display: - platform: ssd1306_i2c id: spd2010_display + i2c_id: i2c_bus model: SSD1306_128X64 touchscreen: - platform: spd2010 + i2c_id: i2c_bus id: spd2010_touchscreen display: spd2010_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} - platform: spd2010 + i2c_id: i2c_bus id: spd2010_polling_touchscreen display: spd2010_display address: 0x54 From b3f5789dc6e6da005a63b406961303015a8a17cf Mon Sep 17 00:00:00 2001 From: Hasan Khalil Date: Fri, 16 Oct 2026 19:39:44 -0400 Subject: [PATCH 474/651] [mipi_rgb] direct-to-PSRAM fast rotated blitting (#20905) --- esphome/components/mipi_rgb/display.py | 2 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 50 +++++++----- esphome/components/mipi_rgb/mipi_rgb.h | 21 +---- .../components/mipi_rgb/mipi_rgb_blitter.h | 76 +++++++++++++++++++ .../mipi_rgb/test_mipi_rgb_config.py | 10 +-- tests/components/mipi_rgb/__init__.py | 5 ++ .../mipi_rgb/test.esp32-s3-idf.yaml | 1 + tests/components/mipi_rgb/test_blitter.cpp | 49 ++++++++++++ 8 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 esphome/components/mipi_rgb/mipi_rgb_blitter.h create mode 100644 tests/components/mipi_rgb/__init__.py create mode 100644 tests/components/mipi_rgb/test_blitter.cpp diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 6e508dd7c44..e973d78ae4d 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -242,7 +242,7 @@ def _config_schema(config: ConfigType) -> ConfigType: config[CONF_ID], width, height, - model.rotation_as_transform(config), + has_hardware_rotation=True, byte_order=config[CONF_BYTE_ORDER], has_writer=requires_buffer(config) or config.get(CONF_AUTO_CLEAR_ENABLED) is True, diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index a38404030ad..beb39ce8473 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -175,6 +175,15 @@ void MipiRgb::common_setup_() { err = esp_lcd_panel_reset(this->handle_); if (err == ESP_OK) err = esp_lcd_panel_init(this->handle_); + if (err == ESP_OK) { + void *fb = nullptr; + err = esp_lcd_rgb_panel_get_frame_buffer(this->handle_, 1, &fb); + if (err == ESP_OK && fb != nullptr) { + this->fb_ = reinterpret_cast(fb); + } else if (err == ESP_OK) { + err = ESP_ERR_NO_MEM; + } + } if (err != ESP_OK) { ESP_LOGE(TAG, "lcd setup failed: %s", esp_err_to_name(err)); this->mark_failed(LOG_STR("lcd setup failed")); @@ -223,30 +232,31 @@ void MipiRgb::draw_pixels_internal(int x_start, int y_start, int w, int h, const int x_pad) { if (w <= 0 || h <= 0 || this->is_failed()) return; - if (bitness != display::COLOR_BITNESS_565 || big_endian != this->big_endian_ || - this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { + if (bitness != display::COLOR_BITNESS_565 || big_endian != this->big_endian_ || this->is_clipping()) { Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); this->mark_dirty_(x_start, y_start, w, h); this->flush_dirty_(); - } else if (this->buffer_ != nullptr) { - // With a buffer the block goes through it, or a flush of the old contents would paint over it - const int first_col = std::max(0, -x_start), end_col = std::min(w, static_cast(this->width_) - x_start); - const int first_row = std::max(0, -y_start), end_row = std::min(h, static_cast(this->height_) - y_start); - if (first_col >= end_col || first_row >= end_row) - return; - const size_t stride = x_offset + w + x_pad; - for (int y = first_row; y != end_row; y++) { - memcpy(this->buffer_ + (y_start + y) * this->width_ + x_start + first_col, - ptr + ((y_offset + y) * stride + x_offset + first_col) * 2, (end_col - first_col) * 2); + return; + } + + const int log_w = this->get_width(); + const int log_h = this->get_height(); + + if (x_start < 0 || y_start < 0 || x_start + w > log_w || y_start + h > log_h) { + return; + } + + uint16_t *dst = this->buffer_ != nullptr ? this->buffer_ : this->fb_; + const size_t stride = static_cast(x_offset) + w + x_pad; + const auto *src_origin = + reinterpret_cast(ptr) + (static_cast(y_offset) * stride + x_offset); + + blit_rotated_565(dst, this->width_, this->height_, this->rotation_, src_origin, x_start, y_start, w, h, stride); + if (dst == this->buffer_) { + this->mark_dirty_(x_start, y_start, w, h); + if (!this->updating_) { + this->flush_dirty_(); } - if (this->updating_) { - this->mark_dirty_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row); - } else { - this->write_to_display_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row, ptr, - x_offset + first_col, y_offset + first_row, x_pad + w - end_col); - } - } else { - this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } } diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index f6aec76976b..199dfcfbfc7 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -5,6 +5,7 @@ #include "esphome/core/log.h" #include "esphome/components/display/display.h" #include "esphome/components/display/display_color_utils.h" +#include "mipi_rgb_blitter.h" #include #include #ifdef USE_SPI @@ -83,24 +84,7 @@ class MipiRgb : public display::Display { /// Send the region touched since the last flush to the panel. void flush_dirty_(); // Maps drawn coordinates to the buffer the way draw_pixel_at() stores them - void rotate_(int &x, int &y) const { - switch (this->rotation_) { - case display::DISPLAY_ROTATION_0_DEGREES: - break; - case display::DISPLAY_ROTATION_90_DEGREES: - std::swap(x, y); - x = this->width_ - x - 1; - break; - case display::DISPLAY_ROTATION_180_DEGREES: - x = this->width_ - x - 1; - y = this->height_ - y - 1; - break; - case display::DISPLAY_ROTATION_270_DEGREES: - std::swap(x, y); - y = this->height_ - y - 1; - break; - } - } + void rotate_(int &x, int &y) const { rotate_point(x, y, this->width_, this->height_, this->rotation_); } void mark_dirty_(int x_start, int y_start, int w, int h); void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void setup_enables_(); @@ -134,6 +118,7 @@ class MipiRgb : public display::Display { uint16_t y_high_{0}; esp_lcd_panel_handle_t handle_{}; + uint16_t *fb_{nullptr}; }; #ifdef USE_SPI diff --git a/esphome/components/mipi_rgb/mipi_rgb_blitter.h b/esphome/components/mipi_rgb/mipi_rgb_blitter.h new file mode 100644 index 00000000000..1f64b40f5b6 --- /dev/null +++ b/esphome/components/mipi_rgb/mipi_rgb_blitter.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include +#include +#include +#include +#include + +namespace esphome::mipi_rgb { + +inline void rotate_point(int &x, int &y, int width, int height, display::DisplayRotation rotation) { + switch (rotation) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = width - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = width - x - 1; + y = height - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = height - y - 1; + break; + } +} + +inline void blit_rotated_565(uint16_t *fb, int fb_width, int fb_height, display::DisplayRotation rotation, + const uint16_t *src_origin, int x_start, int y_start, int w, int h, size_t stride) { + switch (rotation) { + case display::DISPLAY_ROTATION_0_DEGREES: { + for (int py = 0; py < h; py++) { + memcpy(&fb[(y_start + py) * fb_width + x_start], src_origin + static_cast(py) * stride, + w * sizeof(uint16_t)); + } + break; + } + case display::DISPLAY_ROTATION_90_DEGREES: { + const size_t x_dest_offset = fb_width - y_start - h; + for (int px = 0; px < w; px++) { + uint16_t *dest = &fb[(x_start + px) * fb_width + x_dest_offset]; + for (int py = 0; py < h; py++) { + *dest++ = src_origin[static_cast(h - 1 - py) * stride + px]; + } + } + break; + } + case display::DISPLAY_ROTATION_180_DEGREES: { + const size_t x_dest_offset = fb_width - x_start - w; + for (int py = 0; py < h; py++) { + uint16_t *dest = &fb[(fb_height - 1 - (y_start + py)) * fb_width + x_dest_offset]; + const uint16_t *src_row = src_origin + static_cast(py) * stride; + for (int px = 0; px < w; px++) { + *dest++ = src_row[w - 1 - px]; + } + } + break; + } + case display::DISPLAY_ROTATION_270_DEGREES: { + for (int px = 0; px < w; px++) { + uint16_t *dest = &fb[(fb_height - 1 - x_start - px) * fb_width + y_start]; + const uint16_t *s = src_origin + px; + for (int py = 0; py < h; py++) { + *dest++ = *s; + s += stride; + } + } + break; + } + } +} + +} // namespace esphome::mipi_rgb diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index 58e7ba9d959..96da849e3c4 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -139,14 +139,14 @@ def test_st7701s_only_supports_mirror_x( CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) -def test_pure_rgb_model_has_no_hardware_transform( +def test_pure_rgb_model_has_hardware_rotation( set_core_config: SetCoreConfigCallable, ) -> None: """A model with no init sequence has no command interface, so no transform - can be applied in hardware and every rotation must be done in software. + can be applied in panel silicon (MADCTL). - Previously such models claimed mirror_x/mirror_y support, so a rotation of - 180 was handed to the (nonexistent) hardware and the display was not rotated. + The driver itself handles rotation directly in the framebuffer without + allocating software rotation buffers, so has_hardware_rotation is True. """ _set_s3(set_core_config) @@ -162,7 +162,7 @@ def test_pure_rgb_model_has_no_hardware_transform( assert not model.rotation_as_transform(config) metadata = get_display_metadata(config["id"]) assert metadata.rotation == rotation - assert not metadata.has_hardware_rotation + assert metadata.has_hardware_rotation with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"): CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) diff --git a/tests/components/mipi_rgb/__init__.py b/tests/components/mipi_rgb/__init__.py new file mode 100644 index 00000000000..584c6e74b42 --- /dev/null +++ b/tests/components/mipi_rgb/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["display"] diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index 12b45ee160e..8a3a6fd3237 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -9,3 +9,4 @@ ch422g: display: - platform: mipi_rgb model: WAVESHARE-5-1024X600 + rotation: 90 diff --git a/tests/components/mipi_rgb/test_blitter.cpp b/tests/components/mipi_rgb/test_blitter.cpp new file mode 100644 index 00000000000..48da6d12199 --- /dev/null +++ b/tests/components/mipi_rgb/test_blitter.cpp @@ -0,0 +1,49 @@ +#include +#include +#include "esphome/components/mipi_rgb/mipi_rgb_blitter.h" + +namespace esphome::mipi_rgb::testing { + +static void test_rotation(display::DisplayRotation rotation) { + constexpr int fb_width = 40; + constexpr int fb_height = 30; + constexpr int w = 7, h = 5, x_offset = 2, y_offset = 3, x_pad = 4; + constexpr size_t stride = x_offset + w + x_pad; + constexpr size_t lines = y_offset + h; + + std::vector source(stride * lines); + uint16_t val = 0x1000; + for (size_t y = 0; y < lines; y++) { + for (size_t x = 0; x < stride; x++) { + source[y * stride + x] = val++; + } + } + + int x_start = 10; + int y_start = 8; + + // Run blit_rotated_565 + std::vector fb_actual(fb_width * fb_height, 0); + const uint16_t *src_origin = source.data() + (y_offset * stride + x_offset); + blit_rotated_565(fb_actual.data(), fb_width, fb_height, rotation, src_origin, x_start, y_start, w, h, stride); + + // Compute reference per-pixel using rotate_point + std::vector fb_expected(fb_width * fb_height, 0); + for (int py = 0; py < h; py++) { + for (int px = 0; px < w; px++) { + int rx = x_start + px; + int ry = y_start + py; + rotate_point(rx, ry, fb_width, fb_height, rotation); + fb_expected[ry * fb_width + rx] = src_origin[py * stride + px]; + } + } + + EXPECT_EQ(fb_actual, fb_expected); +} + +TEST(MipiRgbBlitter, Rotation0) { test_rotation(display::DISPLAY_ROTATION_0_DEGREES); } +TEST(MipiRgbBlitter, Rotation90) { test_rotation(display::DISPLAY_ROTATION_90_DEGREES); } +TEST(MipiRgbBlitter, Rotation180) { test_rotation(display::DISPLAY_ROTATION_180_DEGREES); } +TEST(MipiRgbBlitter, Rotation270) { test_rotation(display::DISPLAY_ROTATION_270_DEGREES); } + +} // namespace esphome::mipi_rgb::testing From b3ef5cf2f3cefbe2da7b5b2d8eb36779292e915b Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Sat, 17 Oct 2026 01:58:12 +0200 Subject: [PATCH 475/651] [online_image] Report an error when a download can never complete (#20594) Co-authored-by: J. Nick Koston Co-authored-by: guillempages Co-authored-by: J. Nick Koston --- .../components/online_image/online_image.cpp | 90 ++++-- .../components/online_image/online_image.h | 2 + .../components/runtime_image/bmp_decoder.h | 9 +- .../components/runtime_image/image_decoder.h | 3 + .../components/runtime_image/qoi_decoder.h | 9 +- .../runtime_image/runtime_image.cpp | 2 + .../components/runtime_image/runtime_image.h | 3 + tests/components/online_image/__init__.py | 14 + .../online_image/test_download_end.cpp | 289 ++++++++++++++++++ .../online_image_stalled_download.yaml | 54 ++++ .../test_online_image_stalled_download.py | 121 ++++++++ 11 files changed, 566 insertions(+), 30 deletions(-) create mode 100644 tests/components/online_image/__init__.py create mode 100644 tests/components/online_image/test_download_end.cpp create mode 100644 tests/integration/fixtures/online_image_stalled_download.yaml create mode 100644 tests/integration/test_online_image_stalled_download.py diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 4497d5cad12..59c1db38a58 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -1,5 +1,6 @@ #include "online_image.h" #include "esphome/components/runtime_image/image_decoder.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -51,6 +52,13 @@ void OnlineImage::update() { return; } + // A failed allocation at boot, or while growing the buffer for a JPEG, leaves no download buffer + if (this->download_buffer_.size() == 0 && this->download_buffer_.resize(this->download_buffer_initial_size_) == 0) { + // resize() logged the failed allocation + this->download_error_callback_.call(); + return; + } + ESP_LOGD(TAG, "Updating image from %s", this->url_.c_str()); std::vector headers; @@ -133,12 +141,17 @@ void OnlineImage::update() { } // JPEG requires the complete image in the download buffer before decoding - if (format == runtime_image::JPEG && total_size > this->download_buffer_.size()) { - this->download_buffer_.resize(total_size); + if (format == runtime_image::JPEG && total_size > this->download_buffer_.size() && + this->download_buffer_.resize(total_size) == 0) { + // resize() logged the failed allocation; the next update() allocates the configured buffer again + this->end_connection_(); + this->download_error_callback_.call(); + return; } ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size); this->start_time_ = millis(); + this->last_data_time_ = this->start_time_; this->enable_loop(); } @@ -161,18 +174,7 @@ void OnlineImage::loop() { if (this->is_decode_finished() || (this->downloader_->content_length > 0 && this->downloader_->get_bytes_read() >= this->downloader_->content_length && this->download_buffer_.unread() == 0)) { - // Finalize decoding - this->end_decode(); - - ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(), - millis() - this->start_time_); - - // Save caching headers - this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME); - this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME); - - this->download_finished_callback_.call(false); - this->end_connection_(); + this->finish_download_(); return; } @@ -184,6 +186,7 @@ void OnlineImage::loop() { auto len = this->downloader_->read(this->download_buffer_.append(), available); if (len > 0) { + this->last_data_time_ = App.get_loop_component_start_time(); this->download_buffer_.write(len); // Feed data to decoder @@ -204,25 +207,76 @@ void OnlineImage::loop() { this->end_connection_(); this->download_error_callback_.call(); return; + } else if (this->downloader_->is_read_complete()) { + // The response has ended, so data left in the buffer can never be completed + size_t unread = this->download_buffer_.unread(); + if (unread == 0 && this->downloader_->get_bytes_read() > 0) { + // A decoder that counts pixels knows the image was cut off; PNG cannot tell, so it passes + if (this->is_decode_finished() || !this->decoder_knows_end()) { + this->finish_download_(); + return; + } + ESP_LOGE(TAG, "Download ended before the image was complete"); + } else if (unread == 0) { + ESP_LOGE(TAG, "Server sent an empty response"); + } else { + ESP_LOGE(TAG, "Download ended with %zu bytes the decoder could not use", unread); + } + this->end_connection_(); + this->download_error_callback_.call(); + return; + } else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) { + // ESP-IDF keeps returning 0 after the server closes the connection early + ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout()); + this->end_connection_(); + this->download_error_callback_.call(); + return; } } else { // Buffer is full, need to decode some data first auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread()); if (consumed > 0) { + this->last_data_time_ = App.get_loop_component_start_time(); this->download_buffer_.read(consumed); } else if (consumed < 0) { ESP_LOGE(TAG, "Decode error with full buffer: %d", consumed); this->end_connection_(); this->download_error_callback_.call(); return; - } else { - // Decoder can't process more data, might need complete image - // This is normal for JPEG which needs complete data - ESP_LOGV(TAG, "Decoder waiting for more data"); + } else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) { + // The decoder needs more data than the buffer can hold, so it can never make progress. The + // no-data timeout gives a decoder that only had to wait the chance to show otherwise. + ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size", + this->download_buffer_.size()); + this->end_connection_(); + this->download_error_callback_.call(); + return; } } } +void OnlineImage::finish_download_() { + // Must be read before end_decode() ends the decoding session + bool decoder_finished = this->is_decode_finished(); + this->end_decode(); + + ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(), + millis() - this->start_time_); + + // Only cache an image the decoder confirmed as complete; otherwise a cut-off image would be kept by 304 responses + if (decoder_finished) { + this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME); + this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME); + } else { + ESP_LOGW(TAG, "Decoder could not confirm the image is complete; not caching it"); + this->etag_.clear(); + this->last_modified_.clear(); + } + + this->download_finished_callback_.call(false); + this->end_connection_(); +} + void OnlineImage::end_connection_() { // Abort any in-progress decode; the decoder object is kept warm for the next decode. // Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release(). diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index 8b3485b8f74..7397c6c5c32 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -80,6 +80,7 @@ class OnlineImage final : public PollingComponent, protected: bool validate_url_(const std::string &url); void end_connection_(); + void finish_download_(); CallbackManager download_finished_callback_{}; CallbackManager download_error_callback_{}; @@ -107,6 +108,7 @@ class OnlineImage final : public PollingComponent, std::string last_modified_; uint32_t start_time_{0}; + uint32_t last_data_time_{0}; }; } // namespace esphome::online_image diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index 01acc41f91f..cea7f11c784 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -26,13 +26,10 @@ class BmpDecoder : public ImageDecoder { void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; + /// The header gives the pixel count, so the end is known once it has been parsed + bool knows_end() const override { return this->bits_per_pixel_ != 0; } bool is_finished() const override { - if (this->bits_per_pixel_ == 0) { - // header not yet received, so dimensions not yet determined - return false; - } - // BMP is finished when we've decoded all pixel data - return this->paint_index_ >= static_cast(this->width_ * this->height_); + return this->knows_end() && this->paint_index_ >= static_cast(this->width_ * this->height_); } protected: diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 8e0399ac354..6472638261e 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -126,6 +126,9 @@ class ImageDecoder { return false; } + /// Whether is_finished() can tell a complete image from a cut-off one + virtual bool knows_end() const { return this->expected_size_ > 0; } + protected: /// The image's pixel buffer, for decoders that write their output straight into it uint8_t *image_buffer_() const; diff --git a/esphome/components/runtime_image/qoi_decoder.h b/esphome/components/runtime_image/qoi_decoder.h index c44afd83cf2..e66b10027d8 100644 --- a/esphome/components/runtime_image/qoi_decoder.h +++ b/esphome/components/runtime_image/qoi_decoder.h @@ -25,13 +25,10 @@ class QoiDecoder : public ImageDecoder { void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; + /// The header gives the pixel count, so the end is known once it has been parsed + bool knows_end() const override { return this->bits_per_pixel_ != 0; } bool is_finished() const override { - if (this->bits_per_pixel_ == 0) { - // header not yet received, so dimensions not yet determined - return false; - } - // QOI is finished when we've decoded all pixel data - return this->paint_index_ >= static_cast(this->width_ * this->height_); + return this->knows_end() && this->paint_index_ >= static_cast(this->width_ * this->height_); } protected: diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 14e0d5665a6..1c6a2bc6002 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -262,6 +262,8 @@ bool RuntimeImage::end_decode() { bool RuntimeImage::is_decode_finished() const { return this->is_decoding() && this->decoder_->is_finished(); } +bool RuntimeImage::decoder_knows_end() const { return this->is_decoding() && this->decoder_->knows_end(); } + void RuntimeImage::release() { this->release_buffer_(); // End any active decode session; decoders free the format-specific working buffers diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 2a3ab40a5c1..4dc60213649 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -96,6 +96,9 @@ class RuntimeImage : public image::Image { */ bool is_decode_finished() const; + /// True while decoding and the decoder can tell a complete image from a cut-off one + bool decoder_knows_end() const; + /** * @brief Check if an image is currently loaded. */ diff --git a/tests/components/online_image/__init__.py b/tests/components/online_image/__init__.py new file mode 100644 index 00000000000..8635a2ba4ee --- /dev/null +++ b/tests/components/online_image/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.runtime_image import get_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The real to_code needs an image config. The unit tests decode BMP and PNG, and use + # JPEG for the download buffer resize, so compile in those three decoders. + async def to_code_testing(config: ConfigType) -> None: + get_format("BMP").actions() + get_format("JPEG").actions() + get_format("PNG").actions() + + manifest.to_code = to_code_testing diff --git a/tests/components/online_image/test_download_end.cpp b/tests/components/online_image/test_download_end.cpp new file mode 100644 index 00000000000..d844cf845e8 --- /dev/null +++ b/tests/components/online_image/test_download_end.cpp @@ -0,0 +1,289 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/http_request/http_request.h" +#include "esphome/components/online_image/online_image.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::online_image::testing { + +// Serves a fixed body, handing out at most `available` bytes of it. +class FakeContainer : public http_request::HttpContainer { + public: + FakeContainer(std::vector body, size_t available, bool chunked) + : body_(std::move(body)), available_(std::min(available, this->body_.size())) { + this->status_code = 200; + this->content_length = chunked ? 0 : this->body_.size(); + this->set_chunked(chunked); + } + + int read(uint8_t *buf, size_t max_len) override { + size_t len = std::min(max_len, this->available_ - this->bytes_read_); + std::memcpy(buf, this->body_.data() + this->bytes_read_, len); + this->bytes_read_ += len; + return static_cast(len); + } + void end() override {} + + // Like ESP-IDF: a chunked response is complete once its last chunk has been read + bool is_read_complete() const override { + if (this->is_chunked_) + return this->bytes_read_ >= this->body_.size(); + return HttpContainer::is_read_complete(); + } + + void set_content_length(size_t length) { this->content_length = length; } + void add_response_header(const std::string &name, const std::string &value) { + this->response_headers_.push_back({name, value}); + } + + protected: + std::vector body_; + size_t available_; +}; + +class FakeHttpRequest : public http_request::HttpRequestComponent { + public: + std::shared_ptr next; + std::vector sent_headers; + + bool sent_header(const std::string &name) const { + return std::any_of(this->sent_headers.begin(), this->sent_headers.end(), + [&name](const http_request::Header &header) { return header.name == name; }); + } + + protected: + std::shared_ptr perform(const char *url, const char *method, const std::string &body, + const std::vector &request_headers, + const std::vector &collect_headers, + uint32_t timeout) override { + this->sent_headers = request_headers; + return std::move(this->next); + } +}; + +// A 24-bit BMP, 2x2 pixels: 54 header bytes, then two rows of 6 pixel bytes and 2 padding bytes. +static constexpr size_t BMP_HEADER_SIZE = 54; +static constexpr size_t BMP_ROW_SIZE = 8; + +static std::vector make_bmp() { + std::vector bmp(BMP_HEADER_SIZE + 2 * BMP_ROW_SIZE, 0); + auto put32 = [&bmp](size_t offset, uint32_t value) { + for (size_t i = 0; i < 4; i++) + bmp[offset + i] = (value >> (8 * i)) & 0xFF; + }; + bmp[0] = 'B'; + bmp[1] = 'M'; + put32(2, bmp.size()); + put32(10, BMP_HEADER_SIZE); + put32(14, 40); // DIB header size + put32(18, 2); // width + put32(22, 2); // height + bmp[26] = 1; // color planes + bmp[28] = 24; // bits per pixel + return bmp; +} + +// An 8x8, 8-bit BMP whose 256-color table puts the pixels at offset 1078, past a 256 byte buffer. +static std::vector make_palette_bmp() { + constexpr size_t data_offset = 14 + 40 + 256 * 4; + std::vector bmp(data_offset + 8 * 8, 0); + auto put32 = [&bmp](size_t offset, uint32_t value) { + for (size_t i = 0; i < 4; i++) + bmp[offset + i] = (value >> (8 * i)) & 0xFF; + }; + bmp[0] = 'B'; + bmp[1] = 'M'; + put32(2, bmp.size()); + put32(10, data_offset); + put32(14, 40); // DIB header size + put32(18, 8); // width + put32(22, 8); // height + bmp[26] = 1; // color planes + bmp[28] = 8; // bits per pixel + put32(34, 64); // image size + put32(46, 256); // colors in the table + return bmp; +} + +// A 1x1 RGB PNG: 8 signature bytes, a 25 byte IHDR chunk, a 24 byte IDAT chunk and a 12 byte IEND chunk. +static constexpr size_t PNG_HEADER_SIZE = 8 + 25; +static constexpr std::array PNG_1X1{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, + 0x0C, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xC9, + 0xFE, 0x92, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82}; + +static void set_loop_time(uint32_t now) { LoopBlockingGuard clock(nullptr, LOG_STR("test"), now); } + +class OnlineImageDownloadEnd : public ::testing::Test { + protected: + std::unique_ptr make_image_(runtime_image::ImageFormat format) { + auto img = std::make_unique("http://example.com/image", 0, 0, format, image::IMAGE_TYPE_RGB, + image::TRANSPARENCY_OPAQUE, nullptr, 256); + img->set_parent(&this->http_); + return img; + } + + void start_(OnlineImage &image, const std::shared_ptr &container) { + image.add_on_finished_callback([this](bool) { this->finished_++; }); + image.add_on_error_callback([this]() { this->errors_++; }); + this->http_.next = container; + image.update(); + } + + FakeHttpRequest http_; + int finished_{0}; + int errors_{0}; +}; + +// A chunked stream can end without the decoder reporting it is finished. PNG cannot tell a +// complete image from a cut-off one, so when the decoder used every byte the download succeeds, +// but the image is not cached: a 304 response must not keep it. +TEST_F(OnlineImageDownloadEnd, ChunkedPngCutOffFinishesWithoutCaching) { + std::vector png(PNG_1X1.begin(), PNG_1X1.begin() + PNG_HEADER_SIZE); // ends after IHDR + auto container = std::make_shared(png, png.size(), true); + container->add_response_header("etag", "\"abc\""); + container->add_response_header("last-modified", "Mon, 12 Oct 2026 10:00:00 GMT"); + auto image = this->make_image_(runtime_image::PNG); + this->start_(*image, container); + + for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++) + image->loop(); + + EXPECT_EQ(this->finished_, 1); + EXPECT_EQ(this->errors_, 0); + + this->http_.next = std::make_shared(png, 0, false); + image->update(); + EXPECT_FALSE(this->http_.sent_header("if-none-match")); + EXPECT_FALSE(this->http_.sent_header("if-modified-since")); +} + +// BMP knows its pixel count from the header, so a chunked BMP cut off at a row boundary is an error. +TEST_F(OnlineImageDownloadEnd, ChunkedBmpCutOffAtRowReportsError) { + std::vector bmp = make_bmp(); + bmp.resize(BMP_HEADER_SIZE + BMP_ROW_SIZE); // only the first row + auto container = std::make_shared(bmp, bmp.size(), true); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++) + image->loop(); + + EXPECT_EQ(this->errors_, 1); + EXPECT_EQ(this->finished_, 0); +} + +// An image the decoder confirmed as complete is cached. +TEST_F(OnlineImageDownloadEnd, CompleteImageIsCached) { + std::vector bmp = make_bmp(); + auto container = std::make_shared(bmp, bmp.size(), false); + container->add_response_header("etag", "\"abc\""); + container->add_response_header("last-modified", "Mon, 12 Oct 2026 10:00:00 GMT"); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++) + image->loop(); + ASSERT_EQ(this->finished_, 1); + + this->http_.next = std::make_shared(bmp, 0, false); + image->update(); + EXPECT_TRUE(this->http_.sent_header("if-none-match")); + EXPECT_TRUE(this->http_.sent_header("if-modified-since")); +} + +// ESP-IDF keeps returning 0 after the server closes the connection early. +TEST_F(OnlineImageDownloadEnd, NoDataForTimeoutReportsError) { + this->http_.set_timeout(100); + auto container = std::make_shared(make_bmp(), 20, false); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + const uint32_t now = millis(); + set_loop_time(now); + image->loop(); + ASSERT_EQ(container->get_bytes_read(), 20u); + set_loop_time(now + 99); + image->loop(); + EXPECT_EQ(this->errors_, 0); + + set_loop_time(now + 100); + image->loop(); + EXPECT_EQ(this->errors_, 1); + EXPECT_EQ(this->finished_, 0); +} + +// The time spent connecting and reading the headers must not count as time without data. +TEST_F(OnlineImageDownloadEnd, NoDataTimeoutStartsAfterRequest) { + this->http_.set_timeout(100); + auto container = std::make_shared(make_bmp(), 0, false); + auto image = this->make_image_(runtime_image::BMP); + // update() runs in a dispatch that started before the blocking request, here one second earlier + set_loop_time(millis() - 1000); + this->start_(*image, container); + + const uint32_t now = millis(); + set_loop_time(now); + image->loop(); + EXPECT_EQ(this->errors_, 0); + + set_loop_time(now + 100); + image->loop(); + EXPECT_EQ(this->errors_, 1); +} + +// A full buffer the decoder cannot use waits for the no-data timeout before it fails, so a decoder +// that only had to wait is not cut off early. +TEST_F(OnlineImageDownloadEnd, FullBufferFailsAfterTimeout) { + this->http_.set_timeout(100); + auto bmp = make_palette_bmp(); + auto container = std::make_shared(bmp, bmp.size(), false); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + const uint32_t now = millis(); + set_loop_time(now); + for (int i = 0; i < 8; i++) + image->loop(); + EXPECT_EQ(this->errors_, 0); + set_loop_time(now + 99); + image->loop(); + EXPECT_EQ(this->errors_, 0); + + set_loop_time(now + 100); + image->loop(); + EXPECT_EQ(this->errors_, 1); + EXPECT_EQ(this->finished_, 0); +} + +// A JPEG too big for the heap must fail, and the next download must get the configured buffer back. +TEST_F(OnlineImageDownloadEnd, JpegBufferAllocationFailureReportsError) { + auto image = this->make_image_(runtime_image::JPEG); + auto huge = std::make_shared(std::vector(16), 16, false); + huge->set_content_length(SIZE_MAX / 2); + this->start_(*image, huge); + EXPECT_EQ(this->errors_, 1); + + // A chunked response does not resize the buffer. The body is not a real JPEG, so the decoder + // rejects it, but only once it has been read into the buffer. + auto next = std::make_shared(std::vector(100, 0xAA), 100, true); + this->http_.next = next; + image->update(); + image->loop(); + EXPECT_EQ(next->get_bytes_read(), 100u); + EXPECT_EQ(this->errors_, 2); + EXPECT_EQ(this->finished_, 0); +} + +} // namespace esphome::online_image::testing diff --git a/tests/integration/fixtures/online_image_stalled_download.yaml b/tests/integration/fixtures/online_image_stalled_download.yaml new file mode 100644 index 00000000000..1be383ee9be --- /dev/null +++ b/tests/integration/fixtures/online_image_stalled_download.yaml @@ -0,0 +1,54 @@ +esphome: + name: online-image-stalled + +host: + +http_request: + timeout: 500ms + +display: + +image: + - platform: online_image + url: http://127.0.0.1:HTTP_PORT/truncated.bmp + id: truncated_img + format: BMP + type: RGB + on_download_finished: + logger.log: "download finished: truncated" + on_error: + logger.log: "download error: truncated" + - platform: online_image + url: http://127.0.0.1:HTTP_PORT/large_header.bmp + id: large_header_img + format: BMP + type: RGB + buffer_size: 256 + on_download_finished: + logger.log: "download finished: large_header" + on_error: + logger.log: "download error: large_header" + - platform: online_image + url: http://127.0.0.1:HTTP_PORT/empty.bmp + id: empty_img + format: BMP + type: RGB + on_download_finished: + logger.log: "download finished: empty" + on_error: + logger.log: "download error: empty" + +api: + actions: + - action: fetch_truncated + then: + - component.update: truncated_img + - action: fetch_large_header + then: + - component.update: large_header_img + - action: fetch_empty + then: + - component.update: empty_img + +logger: + level: DEBUG diff --git a/tests/integration/test_online_image_stalled_download.py b/tests/integration/test_online_image_stalled_download.py new file mode 100644 index 00000000000..35c549afe60 --- /dev/null +++ b/tests/integration/test_online_image_stalled_download.py @@ -0,0 +1,121 @@ +"""Downloads that can never complete must report an error instead of waiting forever.""" + +from __future__ import annotations + +import asyncio +import struct + +import pytest + +from .online_image_utils import BMP_IMAGE +from .types import APIClientConnectedFactory, RunCompiledFunction + +# 24-bit BMP cut off one byte into the sixth pixel: the decoder keeps that byte, +# waiting for the rest of the pixel, but the response has already ended. +TRUNCATED_BMP = BMP_IMAGE[: 54 + 5 * 3 + 1] + + +def _make_palette_bmp() -> bytes: + """Build an 8x8, 8-bit BMP whose 256-color table puts the pixels at offset 1078.""" + width = height = 8 + data_offset = 14 + 40 + 256 * 4 + file_size = data_offset + width * height + file_header = b"BM" + struct.pack(" None: + try: + async with asyncio.timeout(1.0): + request_line = await reader.readuntil(b"\r\n") + await reader.readuntil(b"\r\n\r\n") + path = request_line.split(b" ")[1].decode() + body = IMAGES[path] + writer.write( + b"\r\n".join( + [ + b"HTTP/1.1 200 OK", + b"Content-Length: %d" % len(body), + b"Content-Type: image/bmp", + b"Connection: close", + b"", + b"", + ] + ) + + body + ) + await writer.drain() + finally: + writer.close() + + +@pytest.mark.asyncio +async def test_online_image_stalled_download( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Esphome should report an error when a download can never complete.""" + loop = asyncio.get_running_loop() + expected_logs = { + "truncated_reason": "Download ended with 1 bytes the decoder could not use", + "truncated_error": "download error: truncated", + "large_header_reason": "Image does not fit in the 256 byte download buffer", + "large_header_error": "download error: large_header", + "empty_reason": "Server sent an empty response", + "empty_error": "download error: empty", + } + log_futures = {key: loop.create_future() for key in expected_logs} + finished_lines: list[str] = [] + + def check_output(line: str) -> None: + for key, text in expected_logs.items(): + if text in line and not log_futures[key].done(): + log_futures[key].set_result(True) + if "download finished" in line: + finished_lines.append(line) + + server = await asyncio.start_server(handle_http, "127.0.0.1", 0) + http_server_port = server.sockets[0].getsockname()[1] + config = yaml_config.replace("HTTP_PORT", str(http_server_port)) + + async with ( + server, + run_compiled(config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + actions = {s.name: s for s in services} + + await client.execute_service(actions["fetch_truncated"], {}) + async with asyncio.timeout(2.0): + await log_futures["truncated_reason"] + await log_futures["truncated_error"] + + await client.execute_service(actions["fetch_large_header"], {}) + async with asyncio.timeout(2.0): + await log_futures["large_header_reason"] + await log_futures["large_header_error"] + + await client.execute_service(actions["fetch_empty"], {}) + async with asyncio.timeout(2.0): + await log_futures["empty_reason"] + await log_futures["empty_error"] + + assert not finished_lines, f"Unexpected successful download: {finished_lines}" From 51ae3bb64245d8441492664c1554f3f157d16b90 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:31:06 +1100 Subject: [PATCH 476/651] [ci] Rename merge conflict label to has-conflicts (#20969) --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7507eb4a424..1b0d478bd86 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -27,7 +27,7 @@ jobs: days-before-close: 7 stale-label: stale exempt-label: not-stale - conflict-label: needs-rebase + conflict-label: has-conflicts ignored-users: esphbot,codecov-commenter stale-pr-message: > There hasn't been any activity on this pull request recently. This From d9969dd53376fda4d8658260367f91b3ba2c3cc5 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, 17 Oct 2026 02:55:08 +0200 Subject: [PATCH 477/651] [teleinfo] Skip sensor values that are not numbers instead of publishing 0 (#20960) Co-authored-by: Claude --- .../teleinfo/sensor/teleinfo_sensor.cpp | 9 +++- tests/components/teleinfo/__init__.py | 11 +++++ tests/components/teleinfo/sensor/__init__.py | 10 ++++ .../teleinfo/teleinfo_sensor_test.cpp | 47 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 tests/components/teleinfo/__init__.py create mode 100644 tests/components/teleinfo/sensor/__init__.py create mode 100644 tests/components/teleinfo/teleinfo_sensor_test.cpp diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp index 443d4be8a10..6929f587d10 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp @@ -6,8 +6,13 @@ namespace esphome::teleinfo { ESPHOME_LOG_TAG(TAG, "teleinfo_sensor"); TeleInfoSensor::TeleInfoSensor(const char *tag) { this->tag = std::string(tag); } void TeleInfoSensor::publish_val(const std::string &val) { - auto newval = parse_number(val).value_or(0.0f); - publish_state(newval); + auto newval = parse_number(val); + if (!newval.has_value()) { + // Publishing 0 instead would look like a meter reset to total_increasing energy sensors + ESP_LOGW(TAG, "Value '%s' for tag %s is not a number", val.c_str(), this->tag.c_str()); + return; + } + this->publish_state(*newval); } void TeleInfoSensor::dump_config() { LOG_SENSOR(" ", "Teleinfo Sensor", this); } } // namespace esphome::teleinfo diff --git a/tests/components/teleinfo/__init__.py b/tests/components/teleinfo/__init__.py new file mode 100644 index 00000000000..5f1db440600 --- /dev/null +++ b/tests/components/teleinfo/__init__.py @@ -0,0 +1,11 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # teleinfo.h includes uart.h; the hub's own to_code needs a configured UART bus. + async def to_code_testing(config: ConfigType) -> None: + pass + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["uart"] diff --git a/tests/components/teleinfo/sensor/__init__.py b/tests/components/teleinfo/sensor/__init__.py new file mode 100644 index 00000000000..cfaaf8d96bd --- /dev/null +++ b/tests/components/teleinfo/sensor/__init__.py @@ -0,0 +1,10 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; the sensor class is built directly. + async def to_code_testing(config: ConfigType) -> None: + pass + + manifest.to_code = to_code_testing diff --git a/tests/components/teleinfo/teleinfo_sensor_test.cpp b/tests/components/teleinfo/teleinfo_sensor_test.cpp new file mode 100644 index 00000000000..9b86fd61879 --- /dev/null +++ b/tests/components/teleinfo/teleinfo_sensor_test.cpp @@ -0,0 +1,47 @@ +#include + +#include + +#include "esphome/components/teleinfo/sensor/teleinfo_sensor.h" + +namespace esphome::teleinfo::testing { + +namespace { +class TeleInfoSensorTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.add_on_state_callback([this](float state) { + this->publishes_++; + this->last_ = state; + }); + } + + TeleInfoSensor sensor_{"EAST"}; + int publishes_{0}; + float last_{NAN}; +}; +} // namespace + +TEST_F(TeleInfoSensorTest, PublishesZeroPaddedNumber) { + this->sensor_.publish_val("000012345"); + EXPECT_EQ(this->publishes_, 1); + EXPECT_FLOAT_EQ(this->last_, 12345.0f); +} + +TEST_F(TeleInfoSensorTest, PublishesRealZero) { + this->sensor_.publish_val("000000000"); + EXPECT_EQ(this->publishes_, 1); + EXPECT_FLOAT_EQ(this->last_, 0.0f); +} + +// A value that is not a number must be dropped: publishing 0 would look like a meter reset +TEST_F(TeleInfoSensorTest, SkipsValueThatIsNotANumber) { + this->sensor_.publish_val("000012345"); + this->sensor_.publish_val("0000l2345"); + this->sensor_.publish_val("H081225223518"); + EXPECT_EQ(this->publishes_, 1); + EXPECT_FLOAT_EQ(this->last_, 12345.0f); + EXPECT_FLOAT_EQ(this->sensor_.get_state(), 12345.0f); +} + +} // namespace esphome::teleinfo::testing From bb0eabf241bd46d54f70df67c3d65a8baff2678b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 14:56:34 -1000 Subject: [PATCH 478/651] [core] Add options() to C++ enums (#20910) --- esphome/cpp_generator.py | 14 ++++++++++ tests/unit_tests/test_cpp_generator.py | 36 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index d0fc126957f..b17db965212 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1270,6 +1270,9 @@ class MockObj(Expression): return MockObj(op) +_ENUM_NON_ALNUM = re.compile(r"[^A-Za-z0-9]") + + class MockObjEnum(MockObj): def __init__(self, *args, **kwargs): self._enum = kwargs.pop("enum") @@ -1289,6 +1292,17 @@ class MockObjEnum(MockObj): def __repr__(self): return f"MockObj<{str(self.base)}>" + def options(self, *names: str, prefix: str = "") -> dict[str, "MockObj"]: + """Map option names to prefix + NAME enumerators (non-alphanumerics become "_"), for cv.enum.""" + enumerators = { + name: prefix + _ENUM_NON_ALNUM.sub("_", name).upper() for name in names + } + if len(set(enumerators.values())) != len(names): + raise ValueError(f"Option names must map to distinct enumerators: {names}") + return { + name: getattr(self, enumerator) for name, enumerator in enumerators.items() + } + class MockObjClass(MockObj): def __init__(self, *args, **kwargs): diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index ec88c583baa..e78035f1f6c 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -965,3 +965,39 @@ def test_extern_progmem_array_is_an_externally_linked_global() -> None: assert [str(st) for st in CORE.global_statements if "PROGMEM" in str(st)] == [ "const uint8_t esphome::api::API_TABLE[] PROGMEM = {1, 2};" ] + + +class TestEnumOptions: + def test_class_enum_with_prefix(self) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode", is_class=True) + options = mode.options("fast", "slow-ish", "2.5x", prefix="MODE_") + assert list(options) == ["fast", "slow-ish", "2.5x"] + assert {k: str(v) for k, v in options.items()} == { + "fast": "esphome::foo::Mode::MODE_FAST", + "slow-ish": "esphome::foo::Mode::MODE_SLOW_ISH", + "2.5x": "esphome::foo::Mode::MODE_2_5X", + } + + def test_matches_attribute_access(self) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode") + options = mode.options("LOW", "HIGH") + assert str(options["LOW"]) == str(mode.LOW) == "esphome::foo::LOW" + assert str(options["HIGH"]) == str(mode.HIGH) + + def test_result_works_with_cv_enum(self) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode", is_class=True) + options = mode.options("on", "off", prefix="MODE_") + validator = cv.enum(options, lower=True) + assert str(validator("ON").enum_value) == "esphome::foo::Mode::MODE_ON" + assert validator(cv.SCHEMA_EXTRACT) is options + + @pytest.mark.parametrize( + "names", + [("OFF", "OFF"), ("slow-ish", "slow_ish"), ("2.5x", "2_5X")], + ) + def test_names_must_map_to_distinct_enumerators( + self, names: tuple[str, ...] + ) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode") + with pytest.raises(ValueError, match="distinct enumerators"): + mode.options(*names) From 36ba2f6106c9a0648a26c585e699cf45dfabbbe8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 14:56:34 -1000 Subject: [PATCH 479/651] [components] Build enum option dicts with options() (#20911) --- .../components/absolute_humidity/sensor.py | 6 +- esphome/components/ac_dimmer/output.py | 8 +- esphome/components/adc/__init__.py | 6 +- esphome/components/ags10/sensor.py | 8 +- esphome/components/aht10/sensor.py | 5 +- .../components/airthings_wave_plus/sensor.py | 5 +- esphome/components/as5600/sensor/__init__.py | 5 +- esphome/components/as734x/__init__.py | 9 +- esphome/components/audio/__init__.py | 8 +- esphome/components/bedjet/climate/__init__.py | 12 +- esphome/components/bh1745/__init__.py | 6 +- esphome/components/bl0939/sensor.py | 7 +- esphome/components/bme280_base/__init__.py | 21 +--- esphome/components/bme680/sensor.py | 24 +--- esphome/components/bme680_bsec/__init__.py | 10 +- esphome/components/bme68x_bsec2/__init__.py | 22 +--- esphome/components/bmi270/motion.py | 9 +- esphome/components/bmp280_base/__init__.py | 21 +--- esphome/components/canbus/__init__.py | 47 +++---- esphome/components/cc1101/__init__.py | 115 +++++------------- esphome/components/climate/__init__.py | 93 +++++++------- esphome/components/cover/__init__.py | 8 +- esphome/components/cs5460a/sensor.py | 5 +- esphome/components/cse7761/sensor.py | 7 +- esphome/components/deep_sleep/__init__.py | 16 +-- esphome/components/dfplayer/__init__.py | 14 +-- esphome/components/dht/sensor.py | 21 ++-- esphome/components/es8311/audio_dac.py | 21 ++-- esphome/components/es8388/select/__init__.py | 16 +-- .../components/es9038q2m_katana/audio_dac.py | 9 +- esphome/components/esp32_ble/__init__.py | 21 ++-- esphome/components/esp32_camera/__init__.py | 64 +++++----- .../esp32_camera_web_server/__init__.py | 2 +- esphome/components/espectre/__init__.py | 11 +- esphome/components/ethernet/__init__.py | 48 ++++---- .../exponential_moving_average/sensor.py | 8 +- esphome/components/fan/__init__.py | 23 ++-- .../components/fingerprint_grow/__init__.py | 23 +--- esphome/components/friedrich/climate.py | 4 +- esphome/components/gp8403/__init__.py | 10 +- esphome/components/graph/__init__.py | 23 ++-- esphome/components/gree/climate.py | 12 +- esphome/components/growatt_solar/sensor.py | 5 +- esphome/components/haier/climate.py | 29 ++--- esphome/components/haier_tundra/climate.py | 5 +- esphome/components/hbridge/fan/__init__.py | 5 +- esphome/components/hdc302x/sensor.py | 9 +- esphome/components/heatpumpir/climate.py | 22 +--- esphome/components/hlw8012/sensor.py | 8 +- .../components/honeywellabp2_i2c/sensor.py | 5 +- esphome/components/htu21d/sensor.py | 8 +- esphome/components/hub75/display.py | 49 ++++---- .../components/i2s_audio/speaker/__init__.py | 25 ++-- esphome/components/icm20689/motion.py | 9 +- esphome/components/inkplate/display.py | 16 +-- esphome/components/integration/sensor.py | 8 +- esphome/components/jsn_sr04t/sensor.py | 5 +- esphome/components/lc709203f/sensor.py | 7 +- esphome/components/light/types.py | 27 ++-- esphome/components/lis2dw12/__init__.py | 39 +++--- esphome/components/lis3dh/motion.py | 22 ++-- esphome/components/lock/__init__.py | 19 +-- esphome/components/lsm6ds/motion.py | 9 +- esphome/components/max31856/sensor.py | 13 +- esphome/components/max31865/sensor.py | 5 +- esphome/components/max44009/sensor.py | 8 +- esphome/components/max6956/__init__.py | 5 +- esphome/components/max7219digit/display.py | 10 +- esphome/components/mcp23xxx_base/__init__.py | 9 +- esphome/components/mcp2515/canbus.py | 13 +- esphome/components/mcp4728/output/__init__.py | 11 +- esphome/components/mcp9600/sensor.py | 13 +- esphome/components/media_player/__init__.py | 7 +- esphome/components/midea/climate.py | 8 +- esphome/components/mitsubishi/climate.py | 34 +++--- esphome/components/modbus/helpers.py | 60 +++++---- .../modbus_controller/text_sensor/__init__.py | 7 +- .../motion/binary_sensor/__init__.py | 9 +- esphome/components/msa3xx/__init__.py | 7 +- esphome/components/number/__init__.py | 15 +-- esphome/components/pca9685/__init__.py | 5 +- esphome/components/pcm5122/switch/__init__.py | 7 +- esphome/components/pm2005/sensor.py | 5 +- esphome/components/pulse_counter/sensor.py | 8 +- esphome/components/pulse_meter/sensor.py | 5 +- esphome/components/pzem6l24/sensor.py | 10 +- esphome/components/qmi8658/motion.py | 9 +- esphome/components/qmp6988/sensor.py | 11 +- esphome/components/qr_code/__init__.py | 9 +- esphome/components/remote_base/__init__.py | 47 +++---- esphome/components/resistance/sensor.py | 5 +- esphome/components/rotary_encoder/sensor.py | 7 +- .../components/rp2040_pio_led_strip/light.py | 7 +- esphome/components/scd4x/sensor.py | 9 +- esphome/components/select/__init__.py | 9 +- esphome/components/sen5x/sensor.py | 6 +- .../sendspin/text_sensor/__init__.py | 9 +- esphome/components/serial_proxy/__init__.py | 9 +- esphome/components/sht4x/sensor.py | 23 ++-- esphome/components/sm2135/__init__.py | 48 ++++---- esphome/components/spa06_base/__init__.py | 37 +++--- .../speaker/media_player/__init__.py | 5 +- esphome/components/spi_device/__init__.py | 5 +- esphome/components/st7701s/display.py | 5 +- esphome/components/stcc4/sensor.py | 5 +- esphome/components/sun/sensor/__init__.py | 5 +- esphome/components/sx126x/__init__.py | 32 +++-- esphome/components/sx127x/__init__.py | 11 +- esphome/components/sy6970/__init__.py | 9 +- esphome/components/syslog/__init__.py | 5 +- esphome/components/tas58xx/audio_dac.py | 15 +-- esphome/components/tcs34725/sensor.py | 50 ++++---- .../template/alarm_control_panel/__init__.py | 20 +-- .../components/template/climate/__init__.py | 7 +- esphome/components/template/cover/__init__.py | 8 +- esphome/components/template/valve/__init__.py | 8 +- .../template/water_heater/__init__.py | 8 +- esphome/components/thermostat/climate.py | 24 ++-- esphome/components/tmc22xx/__init__.py | 13 +- esphome/components/tmp1075/sensor.py | 5 +- esphome/components/toshiba/climate.py | 17 +-- .../components/total_daily_energy/sensor.py | 8 +- esphome/components/tsl2561/sensor.py | 5 +- esphome/components/tuya/cover/__init__.py | 8 +- esphome/components/tuya/light/__init__.py | 6 +- esphome/components/uart/__init__.py | 24 ++-- esphome/components/uln2003/stepper.py | 8 +- esphome/components/usb_uart/__init__.py | 8 +- esphome/components/valve/__init__.py | 8 +- esphome/components/veml3235/sensor.py | 23 ++-- esphome/components/water_heater/__init__.py | 19 +-- esphome/components/whirlpool/climate.py | 6 +- esphome/components/wifi/__init__.py | 25 ++-- esphome/components/xgzp68xx/sensor.py | 21 ++-- esphome/components/zigbee/const.py | 9 +- esphome/components/zigbee/const_esp32.py | 62 +++++----- 136 files changed, 821 insertions(+), 1339 deletions(-) diff --git a/esphome/components/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py index 84a69dfa236..f11ba5af990 100644 --- a/esphome/components/absolute_humidity/sensor.py +++ b/esphome/components/absolute_humidity/sensor.py @@ -19,11 +19,7 @@ AbsoluteHumidityComponent = absolute_humidity_ns.class_( SaturationVaporPressureEquation = absolute_humidity_ns.enum( "SaturationVaporPressureEquation" ) -EQUATION = { - "BUCK": SaturationVaporPressureEquation.BUCK, - "TETENS": SaturationVaporPressureEquation.TETENS, - "WOBUS": SaturationVaporPressureEquation.WOBUS, -} +EQUATION = SaturationVaporPressureEquation.options("BUCK", "TETENS", "WOBUS") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 498565b0ea8..7d1b66a6905 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -14,11 +14,9 @@ ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer") AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component) DimMethod = ac_dimmer_ns.enum("DimMethod") -DIM_METHODS = { - "LEADING_PULSE": DimMethod.DIM_METHOD_LEADING_PULSE, - "LEADING": DimMethod.DIM_METHOD_LEADING, - "TRAILING": DimMethod.DIM_METHOD_TRAILING, -} +DIM_METHODS = DimMethod.options( + "LEADING_PULSE", "LEADING", "TRAILING", prefix="DIM_METHOD_" +) ZC_INTERRUPT_TYPES = { "RISING": gpio_ns.INTERRUPT_RISING_EDGE, diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 6966a86f6b9..f90c01e833d 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -46,11 +46,7 @@ ATTENUATION_MODES = { sampling_mode = adc_ns.enum("SamplingMode", is_class=True) -SAMPLING_MODES = { - "avg": sampling_mode.AVG, - "min": sampling_mode.MIN, - "max": sampling_mode.MAX, -} +SAMPLING_MODES = sampling_mode.options("avg", "min", "max") adc_unit_t = cg.global_ns.enum("adc_unit_t", is_class=True) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index d20d80b2fc7..8fd18671f81 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -84,11 +84,9 @@ automation.register_apply_action( ) AGS10SetZeroPointActionMode = ags10_ns.enum("AGS10SetZeroPointActionMode") -AGS10_SET_ZERO_POINT_ACTION_MODE = { - "FACTORY_DEFAULT": AGS10SetZeroPointActionMode.FACTORY_DEFAULT, - "CURRENT_VALUE": AGS10SetZeroPointActionMode.CURRENT_VALUE, - "CUSTOM_VALUE": AGS10SetZeroPointActionMode.CUSTOM_VALUE, -} +AGS10_SET_ZERO_POINT_ACTION_MODE = AGS10SetZeroPointActionMode.options( + "FACTORY_DEFAULT", "CURRENT_VALUE", "CUSTOM_VALUE" +) AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( { diff --git a/esphome/components/aht10/sensor.py b/esphome/components/aht10/sensor.py index add86e16a63..974e7d4df4c 100644 --- a/esphome/components/aht10/sensor.py +++ b/esphome/components/aht10/sensor.py @@ -20,10 +20,7 @@ aht10_ns = cg.esphome_ns.namespace("aht10") AHT10Component = aht10_ns.class_("AHT10Component", cg.PollingComponent, i2c.I2CDevice) AHT10Variant = aht10_ns.enum("AHT10Variant") -AHT10_VARIANTS = { - "AHT10": AHT10Variant.AHT10, - "AHT20": AHT10Variant.AHT20, -} +AHT10_VARIANTS = AHT10Variant.options("AHT10", "AHT20") CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index 75c1eca594f..14e862d55b8 100644 --- a/esphome/components/airthings_wave_plus/sensor.py +++ b/esphome/components/airthings_wave_plus/sensor.py @@ -29,10 +29,7 @@ AirthingsWavePlus = airthings_wave_plus_ns.class_( CONF_DEVICE_TYPE = "device_type" WaveDeviceType = airthings_wave_plus_ns.enum("WaveDeviceType") -DEVICE_TYPES = { - "WAVE_PLUS": WaveDeviceType.WAVE_PLUS, - "WAVE_GEN2": WaveDeviceType.WAVE_GEN2, -} +DEVICE_TYPES = WaveDeviceType.options("WAVE_PLUS", "WAVE_GEN2") def validate_wave_gen2_config(config: ConfigType) -> ConfigType: diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index 4cf1e95f695..7a3010b8e35 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -30,10 +30,7 @@ CONF_END_POSITION = "end_position" CONF_OUT_OF_RANGE_MODE = "out_of_range_mode" OutOfRangeMode = as5600_ns.enum("OutRangeMode") -OUT_OF_RANGE_MODES = { - "MIN_MAX": OutOfRangeMode.OUT_RANGE_MODE_MIN_MAX, - "NAN": OutOfRangeMode.OUT_RANGE_MODE_NAN, -} +OUT_OF_RANGE_MODES = OutOfRangeMode.options("MIN_MAX", "NAN", prefix="OUT_RANGE_MODE_") CONF_AS5600_ID = "as5600_id" diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index bd43f78f01e..61eeaa5a35b 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -55,12 +55,9 @@ GAINS_41 = { GAINS_43 = {**GAINS_41, "X1024": Gain.GAIN_1024X, "X2048": Gain.GAIN_2048X} Normalization = as734x_ns.enum("Normalization", True) -NORMALIZATIONS = { - "NONE": Normalization.NORMALIZATION_NONE, - "ALL": Normalization.NORMALIZATION_ALL, - "BANDS": Normalization.NORMALIZATION_BANDS, - "CLEAR": Normalization.NORMALIZATION_CLEAR, -} +NORMALIZATIONS = Normalization.options( + "NONE", "ALL", "BANDS", "CLEAR", prefix="NORMALIZATION_" +) # Per-channel correction for the golden device, measured by the manufacturer. DEFAULT_CHANNEL_CORRECTION_43 = [ diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 36a9161eb45..24b3c1435d2 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -27,13 +27,7 @@ audio_ns = cg.esphome_ns.namespace("audio") AudioFile = audio_ns.struct("AudioFile") AudioFileType = audio_ns.enum("AudioFileType", is_class=True) -AUDIO_FILE_TYPE_ENUM = { - "NONE": AudioFileType.NONE, - "WAV": AudioFileType.WAV, - "MP3": AudioFileType.MP3, - "FLAC": AudioFileType.FLAC, - "OPUS": AudioFileType.OPUS, -} +AUDIO_FILE_TYPE_ENUM = AudioFileType.options("NONE", "WAV", "MP3", "FLAC", "OPUS") MEMORY_PSRAM = "psram" MEMORY_INTERNAL = "internal" diff --git a/esphome/components/bedjet/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index 36650d643cc..417bad44fc4 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -12,14 +12,10 @@ DEPENDENCIES = ["bedjet"] BedJetClimate = bedjet_ns.class_("BedJetClimate", climate.Climate, cg.PollingComponent) BedjetHeatMode = bedjet_ns.enum("BedjetHeatMode") BedjetTemperatureSource = bedjet_ns.enum("BedjetTemperatureSource") -BEDJET_HEAT_MODES = { - "heat": BedjetHeatMode.HEAT_MODE_HEAT, - "extended": BedjetHeatMode.HEAT_MODE_EXTENDED, -} -BEDJET_TEMPERATURE_SOURCES = { - "outlet": BedjetTemperatureSource.TEMPERATURE_SOURCE_OUTLET, - "ambient": BedjetTemperatureSource.TEMPERATURE_SOURCE_AMBIENT, -} +BEDJET_HEAT_MODES = BedjetHeatMode.options("heat", "extended", prefix="HEAT_MODE_") +BEDJET_TEMPERATURE_SOURCES = BedjetTemperatureSource.options( + "outlet", "ambient", prefix="TEMPERATURE_SOURCE_" +) CONFIG_SCHEMA = ( climate.climate_schema(BedJetClimate) diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py index bd63fd4ef2d..a922340df71 100644 --- a/esphome/components/bh1745/__init__.py +++ b/esphome/components/bh1745/__init__.py @@ -25,11 +25,7 @@ BH1745Component = bh1745_ns.class_( ) 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, -} +ADC_GAINS = AdcGain.options("1X", "2X", "16X", prefix="ADC_GAIN_") MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True) MEASUREMENT_TIMES = { diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 248002d185f..5e33759dea4 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -35,10 +35,9 @@ 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, -} +MODE_OPTIONS = BL0939Mode.options( + "direct", "current_transformer", prefix="BL0939_MODE_" +) # Must match the mode_ member initializer in bl0939.h; set_mode() is skipped # when the config already equals it. DEFAULT_MODE = "direct" diff --git a/esphome/components/bme280_base/__init__.py b/esphome/components/bme280_base/__init__.py index 8d54cbed2a0..c55e40f90d1 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -24,23 +24,14 @@ DOMAIN = "bme280_base" bme280_ns = cg.esphome_ns.namespace("bme280_base") BME280Oversampling = bme280_ns.enum("BME280Oversampling") -OVERSAMPLING_OPTIONS = { - "NONE": BME280Oversampling.BME280_OVERSAMPLING_NONE, - "1X": BME280Oversampling.BME280_OVERSAMPLING_1X, - "2X": BME280Oversampling.BME280_OVERSAMPLING_2X, - "4X": BME280Oversampling.BME280_OVERSAMPLING_4X, - "8X": BME280Oversampling.BME280_OVERSAMPLING_8X, - "16X": BME280Oversampling.BME280_OVERSAMPLING_16X, -} +OVERSAMPLING_OPTIONS = BME280Oversampling.options( + "NONE", "1X", "2X", "4X", "8X", "16X", prefix="BME280_OVERSAMPLING_" +) BME280IIRFilter = bme280_ns.enum("BME280IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": BME280IIRFilter.BME280_IIR_FILTER_OFF, - "2X": BME280IIRFilter.BME280_IIR_FILTER_2X, - "4X": BME280IIRFilter.BME280_IIR_FILTER_4X, - "8X": BME280IIRFilter.BME280_IIR_FILTER_8X, - "16X": BME280IIRFilter.BME280_IIR_FILTER_16X, -} +IIR_FILTER_OPTIONS = BME280IIRFilter.options( + "OFF", "2X", "4X", "8X", "16X", prefix="BME280_IIR_FILTER_" +) CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index ffe5c74bbd4..73f48526be6 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -28,26 +28,14 @@ DEPENDENCIES = ["i2c"] bme680_ns = cg.esphome_ns.namespace("bme680") BME680Oversampling = bme680_ns.enum("BME680Oversampling") -OVERSAMPLING_OPTIONS = { - "NONE": BME680Oversampling.BME680_OVERSAMPLING_NONE, - "1X": BME680Oversampling.BME680_OVERSAMPLING_1X, - "2X": BME680Oversampling.BME680_OVERSAMPLING_2X, - "4X": BME680Oversampling.BME680_OVERSAMPLING_4X, - "8X": BME680Oversampling.BME680_OVERSAMPLING_8X, - "16X": BME680Oversampling.BME680_OVERSAMPLING_16X, -} +OVERSAMPLING_OPTIONS = BME680Oversampling.options( + "NONE", "1X", "2X", "4X", "8X", "16X", prefix="BME680_OVERSAMPLING_" +) BME680IIRFilter = bme680_ns.enum("BME680IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": BME680IIRFilter.BME680_IIR_FILTER_OFF, - "1X": BME680IIRFilter.BME680_IIR_FILTER_1X, - "3X": BME680IIRFilter.BME680_IIR_FILTER_3X, - "7X": BME680IIRFilter.BME680_IIR_FILTER_7X, - "15X": BME680IIRFilter.BME680_IIR_FILTER_15X, - "31X": BME680IIRFilter.BME680_IIR_FILTER_31X, - "63X": BME680IIRFilter.BME680_IIR_FILTER_63X, - "127X": BME680IIRFilter.BME680_IIR_FILTER_127X, -} +IIR_FILTER_OPTIONS = BME680IIRFilter.options( + "OFF", "1X", "3X", "7X", "15X", "31X", "63X", "127X", prefix="BME680_IIR_FILTER_" +) BME680Component = bme680_ns.class_( "BME680Component", cg.PollingComponent, i2c.I2CDevice diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 190994b2f8a..55fd1c975f5 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -19,10 +19,7 @@ CONF_SUPPLY_VOLTAGE = "supply_voltage" bme680_bsec_ns = cg.esphome_ns.namespace("bme680_bsec") IAQMode = bme680_bsec_ns.enum("IAQMode") -IAQ_MODE_OPTIONS = { - "STATIC": IAQMode.IAQ_MODE_STATIC, - "MOBILE": IAQMode.IAQ_MODE_MOBILE, -} +IAQ_MODE_OPTIONS = IAQMode.options("STATIC", "MOBILE", prefix="IAQ_MODE_") SupplyVoltage = bme680_bsec_ns.enum("SupplyVoltage") SUPPLY_VOLTAGE_OPTIONS = { @@ -31,10 +28,7 @@ SUPPLY_VOLTAGE_OPTIONS = { } SampleRate = bme680_bsec_ns.enum("SampleRate") -SAMPLE_RATE_OPTIONS = { - "LP": SampleRate.SAMPLE_RATE_LP, - "ULP": SampleRate.SAMPLE_RATE_ULP, -} +SAMPLE_RATE_OPTIONS = SampleRate.options("LP", "ULP", prefix="SAMPLE_RATE_") BME680BSECComponent = bme680_bsec_ns.class_( "BME680BSECComponent", cg.Component, i2c.I2CDevice diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 8208672b6a5..85bb250cd0a 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -36,28 +36,18 @@ BME68xBSEC2Component = bme68x_bsec2_ns.class_("BME68xBSEC2Component", cg.Compone MODEL_OPTIONS = ["bme680", "bme688"] AlgorithmOutput = bme68x_bsec2_ns.enum("AlgorithmOutput") -ALGORITHM_OUTPUT_OPTIONS = { - "classification": AlgorithmOutput.ALGORITHM_OUTPUT_CLASSIFICATION, - "regression": AlgorithmOutput.ALGORITHM_OUTPUT_REGRESSION, -} +ALGORITHM_OUTPUT_OPTIONS = AlgorithmOutput.options( + "classification", "regression", prefix="ALGORITHM_OUTPUT_" +) OperatingAge = bme68x_bsec2_ns.enum("OperatingAge") -OPERATING_AGE_OPTIONS = { - "4d": OperatingAge.OPERATING_AGE_4D, - "28d": OperatingAge.OPERATING_AGE_28D, -} +OPERATING_AGE_OPTIONS = OperatingAge.options("4d", "28d", prefix="OPERATING_AGE_") SampleRate = bme68x_bsec2_ns.enum("SampleRate") -SAMPLE_RATE_OPTIONS = { - "LP": SampleRate.SAMPLE_RATE_LP, - "ULP": SampleRate.SAMPLE_RATE_ULP, -} +SAMPLE_RATE_OPTIONS = SampleRate.options("LP", "ULP", prefix="SAMPLE_RATE_") Voltage = bme68x_bsec2_ns.enum("Voltage") -VOLTAGE_OPTIONS = { - "1.8V": Voltage.VOLTAGE_1_8V, - "3.3V": Voltage.VOLTAGE_3_3V, -} +VOLTAGE_OPTIONS = Voltage.options("1.8V", "3.3V", prefix="VOLTAGE_") ALGORITHM_OUTPUT_FILE_NAME = { "classification": "sel", diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py index 97127fd2dd5..bab24b45d65 100644 --- a/esphome/components/bmi270/motion.py +++ b/esphome/components/bmi270/motion.py @@ -20,12 +20,9 @@ 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, - "4G": BMI270AccelRange.BMI270_ACCEL_RANGE_4G, - "8G": BMI270AccelRange.BMI270_ACCEL_RANGE_8G, - "16G": BMI270AccelRange.BMI270_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = BMI270AccelRange.options( + "2G", "4G", "8G", "16G", prefix="BMI270_ACCEL_RANGE_" +) BMI270GyroRange = bmi270_ns.enum("BMI270GyroRange") GYRO_RANGE_OPTIONS = { diff --git a/esphome/components/bmp280_base/__init__.py b/esphome/components/bmp280_base/__init__.py index 68d9145378b..3d84d6728f5 100644 --- a/esphome/components/bmp280_base/__init__.py +++ b/esphome/components/bmp280_base/__init__.py @@ -21,23 +21,14 @@ DOMAIN = "bmp280_base" bmp280_ns = cg.esphome_ns.namespace("bmp280_base") BMP280Oversampling = bmp280_ns.enum("BMP280Oversampling") -OVERSAMPLING_OPTIONS = { - "NONE": BMP280Oversampling.BMP280_OVERSAMPLING_NONE, - "1X": BMP280Oversampling.BMP280_OVERSAMPLING_1X, - "2X": BMP280Oversampling.BMP280_OVERSAMPLING_2X, - "4X": BMP280Oversampling.BMP280_OVERSAMPLING_4X, - "8X": BMP280Oversampling.BMP280_OVERSAMPLING_8X, - "16X": BMP280Oversampling.BMP280_OVERSAMPLING_16X, -} +OVERSAMPLING_OPTIONS = BMP280Oversampling.options( + "NONE", "1X", "2X", "4X", "8X", "16X", prefix="BMP280_OVERSAMPLING_" +) BMP280IIRFilter = bmp280_ns.enum("BMP280IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": BMP280IIRFilter.BMP280_IIR_FILTER_OFF, - "2X": BMP280IIRFilter.BMP280_IIR_FILTER_2X, - "4X": BMP280IIRFilter.BMP280_IIR_FILTER_4X, - "8X": BMP280IIRFilter.BMP280_IIR_FILTER_8X, - "16X": BMP280IIRFilter.BMP280_IIR_FILTER_16X, -} +IIR_FILTER_OPTIONS = BMP280IIRFilter.options( + "OFF", "2X", "4X", "8X", "16X", prefix="BMP280_IIR_FILTER_" +) CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 56733870dc4..27220051a77 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -56,29 +56,30 @@ CanbusTrigger = canbus_ns.class_( ) CanSpeed = canbus_ns.enum("CAN_SPEED") -CAN_SPEEDS = { - "1KBPS": CanSpeed.CAN_1KBPS, - "5KBPS": CanSpeed.CAN_5KBPS, - "10KBPS": CanSpeed.CAN_10KBPS, - "12K5BPS": CanSpeed.CAN_12K5BPS, - "16KBPS": CanSpeed.CAN_16KBPS, - "20KBPS": CanSpeed.CAN_20KBPS, - "25KBPS": CanSpeed.CAN_25KBPS, - "31K25BPS": CanSpeed.CAN_31K25BPS, - "33KBPS": CanSpeed.CAN_33KBPS, - "40KBPS": CanSpeed.CAN_40KBPS, - "50KBPS": CanSpeed.CAN_50KBPS, - "80KBPS": CanSpeed.CAN_80KBPS, - "83K3BPS": CanSpeed.CAN_83K3BPS, - "95KBPS": CanSpeed.CAN_95KBPS, - "100KBPS": CanSpeed.CAN_100KBPS, - "125KBPS": CanSpeed.CAN_125KBPS, - "200KBPS": CanSpeed.CAN_200KBPS, - "250KBPS": CanSpeed.CAN_250KBPS, - "500KBPS": CanSpeed.CAN_500KBPS, - "800KBPS": CanSpeed.CAN_800KBPS, - "1000KBPS": CanSpeed.CAN_1000KBPS, -} +CAN_SPEEDS = CanSpeed.options( + "1KBPS", + "5KBPS", + "10KBPS", + "12K5BPS", + "16KBPS", + "20KBPS", + "25KBPS", + "31K25BPS", + "33KBPS", + "40KBPS", + "50KBPS", + "80KBPS", + "83K3BPS", + "95KBPS", + "100KBPS", + "125KBPS", + "200KBPS", + "250KBPS", + "500KBPS", + "800KBPS", + "1000KBPS", + prefix="CAN_", +) def get_rate(value: str) -> int: diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 8b9e91f833a..2660d5bbde7 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -67,41 +67,30 @@ CONF_GDO0_PIN = "gdo0_pin" # Enums SyncMode = ns.enum("SyncMode", True) -SYNC_MODE = { - "None": SyncMode.SYNC_MODE_NONE, - "15/16": SyncMode.SYNC_MODE_15_16, - "16/16": SyncMode.SYNC_MODE_16_16, - "30/32": SyncMode.SYNC_MODE_30_32, -} +SYNC_MODE = SyncMode.options("None", "15/16", "16/16", "30/32", prefix="SYNC_MODE_") Modulation = ns.enum("Modulation", True) -MODULATION = { - "2-FSK": Modulation.MODULATION_2_FSK, - "GFSK": Modulation.MODULATION_GFSK, - "ASK/OOK": Modulation.MODULATION_ASK_OOK, - "4-FSK": Modulation.MODULATION_4_FSK, - "MSK": Modulation.MODULATION_MSK, -} +MODULATION = Modulation.options( + "2-FSK", "GFSK", "ASK/OOK", "4-FSK", "MSK", prefix="MODULATION_" +) RxAttenuation = ns.enum("RxAttenuation", True) -RX_ATTENUATION = { - "0dB": RxAttenuation.RX_ATTENUATION_0DB, - "6dB": RxAttenuation.RX_ATTENUATION_6DB, - "12dB": RxAttenuation.RX_ATTENUATION_12DB, - "18dB": RxAttenuation.RX_ATTENUATION_18DB, -} +RX_ATTENUATION = RxAttenuation.options( + "0dB", "6dB", "12dB", "18dB", prefix="RX_ATTENUATION_" +) MagnTarget = ns.enum("MagnTarget", True) -MAGN_TARGET = { - "24dB": MagnTarget.MAGN_TARGET_24DB, - "27dB": MagnTarget.MAGN_TARGET_27DB, - "30dB": MagnTarget.MAGN_TARGET_30DB, - "33dB": MagnTarget.MAGN_TARGET_33DB, - "36dB": MagnTarget.MAGN_TARGET_36DB, - "38dB": MagnTarget.MAGN_TARGET_38DB, - "40dB": MagnTarget.MAGN_TARGET_40DB, - "42dB": MagnTarget.MAGN_TARGET_42DB, -} +MAGN_TARGET = MagnTarget.options( + "24dB", + "27dB", + "30dB", + "33dB", + "36dB", + "38dB", + "40dB", + "42dB", + prefix="MAGN_TARGET_", +) MaxLnaGain = ns.enum("MaxLnaGain", True) MAX_LNA_GAIN = { @@ -140,20 +129,14 @@ FILTER_LENGTH_FSK_MSK = { } FilterLengthAskOok = ns.enum("FilterLengthAskOok", True) -FILTER_LENGTH_ASK_OOK = { - "4dB": FilterLengthAskOok.FILTER_LENGTH_4DB, - "8dB": FilterLengthAskOok.FILTER_LENGTH_8DB, - "12dB": FilterLengthAskOok.FILTER_LENGTH_12DB, - "16dB": FilterLengthAskOok.FILTER_LENGTH_16DB, -} +FILTER_LENGTH_ASK_OOK = FilterLengthAskOok.options( + "4dB", "8dB", "12dB", "16dB", prefix="FILTER_LENGTH_" +) Freeze = ns.enum("Freeze", True) -FREEZE = { - "Default": Freeze.FREEZE_DEFAULT, - "On Sync": Freeze.FREEZE_ON_SYNC, - "Analog Only": Freeze.FREEZE_ANALOG_ONLY, - "Analog And Digital": Freeze.FREEZE_ANALOG_AND_DIGITAL, -} +FREEZE = Freeze.options( + "Default", "On Sync", "Analog Only", "Analog And Digital", prefix="FREEZE_" +) WaitTime = ns.enum("WaitTime", True) WAIT_TIME = { @@ -164,34 +147,16 @@ WAIT_TIME = { } HystLevel = ns.enum("HystLevel", True) -HYST_LEVEL = { - "None": HystLevel.HYST_LEVEL_NONE, - "Low": HystLevel.HYST_LEVEL_LOW, - "Medium": HystLevel.HYST_LEVEL_MEDIUM, - "High": HystLevel.HYST_LEVEL_HIGH, -} +HYST_LEVEL = HystLevel.options("None", "Low", "Medium", "High", prefix="HYST_LEVEL_") FocLimit = ns.enum("FocLimit", True) -FOC_LIMIT = { - "Disabled": FocLimit.FOC_LIMIT_DISABLED, - "BW/8": FocLimit.FOC_LIMIT_BW_8, - "BW/4": FocLimit.FOC_LIMIT_BW_4, - "BW/2": FocLimit.FOC_LIMIT_BW_2, -} +FOC_LIMIT = FocLimit.options("Disabled", "BW/8", "BW/4", "BW/2", prefix="FOC_LIMIT_") FocPreK = ns.enum("FocPreK", True) -FOC_PRE_K = { - "K": FocPreK.FOC_PRE_K_K, - "2K": FocPreK.FOC_PRE_K_2K, - "3K": FocPreK.FOC_PRE_K_3K, - "4K": FocPreK.FOC_PRE_K_4K, -} +FOC_PRE_K = FocPreK.options("K", "2K", "3K", "4K", prefix="FOC_PRE_K_") FocPostK = ns.enum("FocPostK", True) -FOC_POST_K = { - "Same": FocPostK.FOC_POST_K_SAME, - "K/2": FocPostK.FOC_POST_K_K_2, -} +FOC_POST_K = FocPostK.options("Same", "K/2", prefix="FOC_POST_K_") BsLimit = ns.enum("BsLimit", True) BS_LIMIT = { @@ -202,32 +167,16 @@ BS_LIMIT = { } BsPreKi = ns.enum("BsPreKi", True) -BS_PRE_KI = { - "KI": BsPreKi.BS_PRE_KI_KI, - "2KI": BsPreKi.BS_PRE_KI_2KI, - "3KI": BsPreKi.BS_PRE_KI_3KI, - "4KI": BsPreKi.BS_PRE_KI_4KI, -} +BS_PRE_KI = BsPreKi.options("KI", "2KI", "3KI", "4KI", prefix="BS_PRE_KI_") BsPreKp = ns.enum("BsPreKp", True) -BS_PRE_KP = { - "KP": BsPreKp.BS_PRE_KP_KP, - "2KP": BsPreKp.BS_PRE_KP_2KP, - "3KP": BsPreKp.BS_PRE_KP_3KP, - "4KP": BsPreKp.BS_PRE_KP_4KP, -} +BS_PRE_KP = BsPreKp.options("KP", "2KP", "3KP", "4KP", prefix="BS_PRE_KP_") BsPostKi = ns.enum("BsPostKi", True) -BS_POST_KI = { - "Same": BsPostKi.BS_POST_KI_SAME, - "KI/2": BsPostKi.BS_POST_KI_KI_2, -} +BS_POST_KI = BsPostKi.options("Same", "KI/2", prefix="BS_POST_KI_") BsPostKp = ns.enum("BsPostKp", True) -BS_POST_KP = { - "Same": BsPostKp.BS_POST_KP_SAME, - "KP": BsPostKp.BS_POST_KP_KP, -} +BS_POST_KP = BsPostKp.options("Same", "KP", prefix="BS_POST_KP_") # Optional settings to generate setter calls for CONFIG_MAP = { diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index a59c03ed1e5..1b335c27395 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -69,67 +69,68 @@ ClimateCall = climate_ns.class_("ClimateCall") ClimateTraits = climate_ns.class_("ClimateTraits") ClimateMode = climate_ns.enum("ClimateMode") -CLIMATE_MODES = { - "OFF": ClimateMode.CLIMATE_MODE_OFF, - "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, - "COOL": ClimateMode.CLIMATE_MODE_COOL, - "HEAT": ClimateMode.CLIMATE_MODE_HEAT, - "DRY": ClimateMode.CLIMATE_MODE_DRY, - "FAN_ONLY": ClimateMode.CLIMATE_MODE_FAN_ONLY, - "AUTO": ClimateMode.CLIMATE_MODE_AUTO, -} +CLIMATE_MODES = ClimateMode.options( + "OFF", + "HEAT_COOL", + "COOL", + "HEAT", + "DRY", + "FAN_ONLY", + "AUTO", + prefix="CLIMATE_MODE_", +) validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) ClimateFanMode = climate_ns.enum("ClimateFanMode") -CLIMATE_FAN_MODES = { - "ON": ClimateFanMode.CLIMATE_FAN_ON, - "OFF": ClimateFanMode.CLIMATE_FAN_OFF, - "AUTO": ClimateFanMode.CLIMATE_FAN_AUTO, - "LOW": ClimateFanMode.CLIMATE_FAN_LOW, - "MEDIUM": ClimateFanMode.CLIMATE_FAN_MEDIUM, - "HIGH": ClimateFanMode.CLIMATE_FAN_HIGH, - "MIDDLE": ClimateFanMode.CLIMATE_FAN_MIDDLE, - "FOCUS": ClimateFanMode.CLIMATE_FAN_FOCUS, - "DIFFUSE": ClimateFanMode.CLIMATE_FAN_DIFFUSE, - "QUIET": ClimateFanMode.CLIMATE_FAN_QUIET, -} +CLIMATE_FAN_MODES = ClimateFanMode.options( + "ON", + "OFF", + "AUTO", + "LOW", + "MEDIUM", + "HIGH", + "MIDDLE", + "FOCUS", + "DIFFUSE", + "QUIET", + prefix="CLIMATE_FAN_", +) validate_climate_fan_mode = cv.enum(CLIMATE_FAN_MODES, upper=True) ClimatePreset = climate_ns.enum("ClimatePreset") -CLIMATE_PRESETS = { - "NONE": ClimatePreset.CLIMATE_PRESET_NONE, - "ECO": ClimatePreset.CLIMATE_PRESET_ECO, - "AWAY": ClimatePreset.CLIMATE_PRESET_AWAY, - "BOOST": ClimatePreset.CLIMATE_PRESET_BOOST, - "COMFORT": ClimatePreset.CLIMATE_PRESET_COMFORT, - "HOME": ClimatePreset.CLIMATE_PRESET_HOME, - "SLEEP": ClimatePreset.CLIMATE_PRESET_SLEEP, - "ACTIVITY": ClimatePreset.CLIMATE_PRESET_ACTIVITY, -} +CLIMATE_PRESETS = ClimatePreset.options( + "NONE", + "ECO", + "AWAY", + "BOOST", + "COMFORT", + "HOME", + "SLEEP", + "ACTIVITY", + prefix="CLIMATE_PRESET_", +) validate_climate_preset = cv.enum(CLIMATE_PRESETS, upper=True) ClimateSwingMode = climate_ns.enum("ClimateSwingMode") -CLIMATE_SWING_MODES = { - "OFF": ClimateSwingMode.CLIMATE_SWING_OFF, - "BOTH": ClimateSwingMode.CLIMATE_SWING_BOTH, - "VERTICAL": ClimateSwingMode.CLIMATE_SWING_VERTICAL, - "HORIZONTAL": ClimateSwingMode.CLIMATE_SWING_HORIZONTAL, -} +CLIMATE_SWING_MODES = ClimateSwingMode.options( + "OFF", "BOTH", "VERTICAL", "HORIZONTAL", prefix="CLIMATE_SWING_" +) validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True) ClimateAction = climate_ns.enum("ClimateAction") -CLIMATE_ACTIONS = { - "OFF": ClimateAction.CLIMATE_ACTION_OFF, - "COOLING": ClimateAction.CLIMATE_ACTION_COOLING, - "HEATING": ClimateAction.CLIMATE_ACTION_HEATING, - "IDLE": ClimateAction.CLIMATE_ACTION_IDLE, - "DRYING": ClimateAction.CLIMATE_ACTION_DRYING, - "FAN": ClimateAction.CLIMATE_ACTION_FAN, - "DEFROSTING": ClimateAction.CLIMATE_ACTION_DEFROSTING, -} +CLIMATE_ACTIONS = ClimateAction.options( + "OFF", + "COOLING", + "HEATING", + "IDLE", + "DRYING", + "FAN", + "DEFROSTING", + prefix="CLIMATE_ACTION_", +) validate_climate_action = cv.enum(CLIMATE_ACTIONS, upper=True) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index c570f3d4ca2..7bdd9000094 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -80,11 +80,9 @@ COVER_STATES = { validate_cover_state = cv.enum(COVER_STATES, upper=True) CoverOperation = cover_ns.enum("CoverOperation") -COVER_OPERATIONS = { - "IDLE": CoverOperation.COVER_OPERATION_IDLE, - "OPENING": CoverOperation.COVER_OPERATION_OPENING, - "CLOSING": CoverOperation.COVER_OPERATION_CLOSING, -} +COVER_OPERATIONS = CoverOperation.options( + "IDLE", "OPENING", "CLOSING", prefix="COVER_OPERATION_" +) validate_cover_operation = cv.enum(COVER_OPERATIONS, upper=True) # Actions diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index d087c99bf1a..54e452efc88 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -24,10 +24,7 @@ DEPENDENCIES = ["spi"] cs5460a_ns = cg.esphome_ns.namespace("cs5460a") CS5460APGAGain = cs5460a_ns.enum("CS5460APGAGain") -PGA_GAIN_OPTIONS = { - "10X": CS5460APGAGain.CS5460A_PGA_GAIN_10X, - "50X": CS5460APGAGain.CS5460A_PGA_GAIN_50X, -} +PGA_GAIN_OPTIONS = CS5460APGAGain.options("10X", "50X", prefix="CS5460A_PGA_GAIN_") CS5460AComponent = cs5460a_ns.class_("CS5460AComponent", spi.SPIDevice, cg.Component) diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 6f695307de8..1618cb43962 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -39,10 +39,9 @@ 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, -} +CURRENT_INPUTS = CSE7761CurrentInput.options( + "shunt", "current_transformer", prefix="CSE7761_CURRENT_INPUT_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 5f340c60efe..d60a51c3cf7 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -223,19 +223,15 @@ DeepSleepComponent = deep_sleep_ns.class_("DeepSleepComponent", cg.Component) EnterDeepSleepAction = deep_sleep_ns.class_("EnterDeepSleepAction", automation.Action) WakeupPinMode = deep_sleep_ns.enum("WakeupPinMode") -WAKEUP_PIN_MODES = { - "IGNORE": WakeupPinMode.WAKEUP_PIN_MODE_IGNORE, - "KEEP_AWAKE": WakeupPinMode.WAKEUP_PIN_MODE_KEEP_AWAKE, - "INVERT_WAKEUP": WakeupPinMode.WAKEUP_PIN_MODE_INVERT_WAKEUP, -} +WAKEUP_PIN_MODES = WakeupPinMode.options( + "IGNORE", "KEEP_AWAKE", "INVERT_WAKEUP", prefix="WAKEUP_PIN_MODE_" +) esp_sleep_ext1_wakeup_mode_t = cg.global_ns.enum("esp_sleep_ext1_wakeup_mode_t") Ext1Wakeup = deep_sleep_ns.struct("Ext1Wakeup") -EXT1_WAKEUP_MODES = { - "ANY_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_LOW, - "ALL_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ALL_LOW, - "ANY_HIGH": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_HIGH, -} +EXT1_WAKEUP_MODES = esp_sleep_ext1_wakeup_mode_t.options( + "ANY_LOW", "ALL_LOW", "ANY_HIGH", prefix="ESP_EXT1_WAKEUP_" +) WakeupCauseToRunDuration = deep_sleep_ns.struct("WakeupCauseToRunDuration") WakeupCause = deep_sleep_ns.enum("WakeupCause") diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index d4ec9d6590a..69b745df9cf 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -21,19 +21,9 @@ CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" EqPreset = dfplayer_ns.enum("EqPreset") -EQ_PRESET = { - "NORMAL": EqPreset.NORMAL, - "POP": EqPreset.POP, - "ROCK": EqPreset.ROCK, - "JAZZ": EqPreset.JAZZ, - "CLASSIC": EqPreset.CLASSIC, - "BASS": EqPreset.BASS, -} +EQ_PRESET = EqPreset.options("NORMAL", "POP", "ROCK", "JAZZ", "CLASSIC", "BASS") Device = dfplayer_ns.enum("Device") -DEVICE = { - "USB": Device.USB, - "TF_CARD": Device.TF_CARD, -} +DEVICE = Device.options("USB", "TF_CARD") CONFIG_SCHEMA = cv.All( cv.Schema( diff --git a/esphome/components/dht/sensor.py b/esphome/components/dht/sensor.py index c7ae70b8344..da8a5775b15 100644 --- a/esphome/components/dht/sensor.py +++ b/esphome/components/dht/sensor.py @@ -19,16 +19,17 @@ from esphome.types import ConfigType dht_ns = cg.esphome_ns.namespace("dht") DHTModel = dht_ns.enum("DHTModel") -DHT_MODELS = { - "AUTO_DETECT": DHTModel.DHT_MODEL_AUTO_DETECT, - "DHT11": DHTModel.DHT_MODEL_DHT11, - "DHT22": DHTModel.DHT_MODEL_DHT22, - "AM2120": DHTModel.DHT_MODEL_AM2120, - "AM2302": DHTModel.DHT_MODEL_AM2302, - "RHT03": DHTModel.DHT_MODEL_RHT03, - "SI7021": DHTModel.DHT_MODEL_SI7021, - "DHT22_TYPE2": DHTModel.DHT_MODEL_DHT22_TYPE2, -} +DHT_MODELS = DHTModel.options( + "AUTO_DETECT", + "DHT11", + "DHT22", + "AM2120", + "AM2302", + "RHT03", + "SI7021", + "DHT22_TYPE2", + prefix="DHT_MODEL_", +) DHT = dht_ns.class_("DHT", cg.PollingComponent) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index f9cfb822cca..036ee93bd3e 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -22,16 +22,17 @@ ES8311_BITS_PER_SAMPLE_ENUM = { } es8311_mic_gain = es8311_ns.enum("ES8311MicGain") -ES8311_MIC_GAIN_ENUM = { - "0DB": es8311_mic_gain.ES8311_MIC_GAIN_0DB, - "6DB": es8311_mic_gain.ES8311_MIC_GAIN_6DB, - "12DB": es8311_mic_gain.ES8311_MIC_GAIN_12DB, - "18DB": es8311_mic_gain.ES8311_MIC_GAIN_18DB, - "24DB": es8311_mic_gain.ES8311_MIC_GAIN_24DB, - "30DB": es8311_mic_gain.ES8311_MIC_GAIN_30DB, - "36DB": es8311_mic_gain.ES8311_MIC_GAIN_36DB, - "42DB": es8311_mic_gain.ES8311_MIC_GAIN_42DB, -} +ES8311_MIC_GAIN_ENUM = es8311_mic_gain.options( + "0DB", + "6DB", + "12DB", + "18DB", + "24DB", + "30DB", + "36DB", + "42DB", + prefix="ES8311_MIC_GAIN_", +) _validate_bits = cv.float_with_unit("bits", "bit") diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index f0e3f5b7c89..41866877384 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -14,18 +14,14 @@ CONF_DAC_OUTPUT = "dac_output" CONF_ADC_INPUT_MIC = "adc_input_mic" DacOutputLine = es8388_ns.enum("DacOutputLine") -DAC_OUTPUT_OPTIONS = { - "LINE1": DacOutputLine.DAC_OUTPUT_LINE1, - "LINE2": DacOutputLine.DAC_OUTPUT_LINE2, - "BOTH": DacOutputLine.DAC_OUTPUT_BOTH, -} +DAC_OUTPUT_OPTIONS = DacOutputLine.options( + "LINE1", "LINE2", "BOTH", prefix="DAC_OUTPUT_" +) DAC_OUTPUT_OPTION_NAMES = list(DAC_OUTPUT_OPTIONS) AdcInputMicLine = es8388_ns.enum("AdcInputMicLine") -ADC_INPUT_MIC_OPTIONS = { - "LINE1": AdcInputMicLine.ADC_INPUT_MIC_LINE1, - "LINE2": AdcInputMicLine.ADC_INPUT_MIC_LINE2, - "DIFFERENCE": AdcInputMicLine.ADC_INPUT_MIC_DIFFERENCE, -} +ADC_INPUT_MIC_OPTIONS = AdcInputMicLine.options( + "LINE1", "LINE2", "DIFFERENCE", prefix="ADC_INPUT_MIC_" +) ADC_INPUT_MIC_OPTION_NAMES = list(ADC_INPUT_MIC_OPTIONS) DacOutputSelect = es8388_ns.class_("DacOutputSelect", select.Select) diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py index f5c14d234c6..0568e5e202a 100644 --- a/esphome/components/es9038q2m_katana/audio_dac.py +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -42,12 +42,9 @@ FILTER_SHAPES = { } DeemphasisMode = es9038q2m_katana_ns.enum("DeemphasisMode") -DEEMPHASIS_MODES = { - "bypass": DeemphasisMode.DEEMPHASIS_BYPASS, - "32khz": DeemphasisMode.DEEMPHASIS_32KHZ, - "44_1khz": DeemphasisMode.DEEMPHASIS_44_1KHZ, - "48khz": DeemphasisMode.DEEMPHASIS_48KHZ, -} +DEEMPHASIS_MODES = DeemphasisMode.options( + "bypass", "32khz", "44_1khz", "48khz", prefix="DEEMPHASIS_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 48f2e3c9460..95a83edf76f 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -310,16 +310,17 @@ IO_CAPABILITY = { } AuthReqMode = esp32_ble_ns.enum("AuthReqMode") -AUTH_REQ_MODE = { - "no_bond": AuthReqMode.AUTH_REQ_NO_BOND, - "bond": AuthReqMode.AUTH_REQ_BOND, - "mitm": AuthReqMode.AUTH_REQ_MITM, - "bond_mitm": AuthReqMode.AUTH_REQ_BOND_MITM, - "sc_only": AuthReqMode.AUTH_REQ_SC_ONLY, - "sc_bond": AuthReqMode.AUTH_REQ_SC_BOND, - "sc_mitm": AuthReqMode.AUTH_REQ_SC_MITM, - "sc_mitm_bond": AuthReqMode.AUTH_REQ_SC_MITM_BOND, -} +AUTH_REQ_MODE = AuthReqMode.options( + "no_bond", + "bond", + "mitm", + "bond_mitm", + "sc_only", + "sc_bond", + "sc_mitm", + "sc_mitm_bond", + prefix="AUTH_REQ_", +) esp_power_level_t = cg.global_ns.enum("esp_power_level_t") diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 2d4e1ed7e99..0014d38a5c4 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -90,50 +90,42 @@ FRAME_SIZES = { "QSXGA": ESP32CameraFrameSize.ESP32_CAMERA_SIZE_2560X1920, } ESP32CameraPixelFormat = esp32_camera_ns.enum("ESP32CameraPixelFormat") -PIXEL_FORMATS = { - "RGB565": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB565, - "YUV422": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_YUV422, - "YUV420": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_YUV420, - "GRAYSCALE": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_GRAYSCALE, - "JPEG": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_JPEG, - "RGB888": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB888, - "RAW": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RAW, - "RGB444": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB444, - "RGB555": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB555, -} +PIXEL_FORMATS = ESP32CameraPixelFormat.options( + "RGB565", + "YUV422", + "YUV420", + "GRAYSCALE", + "JPEG", + "RGB888", + "RAW", + "RGB444", + "RGB555", + prefix="ESP32_PIXEL_FORMAT_", +) ESP32GainControlMode = esp32_camera_ns.enum("ESP32GainControlMode") ENUM_GAIN_CONTROL_MODE = { "MANUAL": ESP32GainControlMode.ESP32_GC_MODE_MANU, "AUTO": ESP32GainControlMode.ESP32_GC_MODE_AUTO, } ESP32AgcGainCeiling = esp32_camera_ns.enum("ESP32AgcGainCeiling") -ENUM_GAIN_CEILING = { - "2X": ESP32AgcGainCeiling.ESP32_GAINCEILING_2X, - "4X": ESP32AgcGainCeiling.ESP32_GAINCEILING_4X, - "8X": ESP32AgcGainCeiling.ESP32_GAINCEILING_8X, - "16X": ESP32AgcGainCeiling.ESP32_GAINCEILING_16X, - "32X": ESP32AgcGainCeiling.ESP32_GAINCEILING_32X, - "64X": ESP32AgcGainCeiling.ESP32_GAINCEILING_64X, - "128X": ESP32AgcGainCeiling.ESP32_GAINCEILING_128X, -} +ENUM_GAIN_CEILING = ESP32AgcGainCeiling.options( + "2X", "4X", "8X", "16X", "32X", "64X", "128X", prefix="ESP32_GAINCEILING_" +) ESP32WhiteBalanceMode = esp32_camera_ns.enum("ESP32WhiteBalanceMode") -ENUM_WB_MODE = { - "AUTO": ESP32WhiteBalanceMode.ESP32_WB_MODE_AUTO, - "SUNNY": ESP32WhiteBalanceMode.ESP32_WB_MODE_SUNNY, - "CLOUDY": ESP32WhiteBalanceMode.ESP32_WB_MODE_CLOUDY, - "OFFICE": ESP32WhiteBalanceMode.ESP32_WB_MODE_OFFICE, - "HOME": ESP32WhiteBalanceMode.ESP32_WB_MODE_HOME, -} +ENUM_WB_MODE = ESP32WhiteBalanceMode.options( + "AUTO", "SUNNY", "CLOUDY", "OFFICE", "HOME", prefix="ESP32_WB_MODE_" +) ESP32SpecialEffect = esp32_camera_ns.enum("ESP32SpecialEffect") -ENUM_SPECIAL_EFFECT = { - "NONE": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_NONE, - "NEGATIVE": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_NEGATIVE, - "GRAYSCALE": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_GRAYSCALE, - "RED_TINT": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_RED_TINT, - "GREEN_TINT": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_GREEN_TINT, - "BLUE_TINT": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_BLUE_TINT, - "SEPIA": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_SEPIA, -} +ENUM_SPECIAL_EFFECT = ESP32SpecialEffect.options( + "NONE", + "NEGATIVE", + "GRAYSCALE", + "RED_TINT", + "GREEN_TINT", + "BLUE_TINT", + "SEPIA", + prefix="ESP32_SPECIAL_EFFECT_", +) camera_fb_location_t = cg.global_ns.enum("camera_fb_location_t") ENUM_FB_LOCATION = { diff --git a/esphome/components/esp32_camera_web_server/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index c063eb88be5..60465cf8f91 100644 --- a/esphome/components/esp32_camera_web_server/__init__.py +++ b/esphome/components/esp32_camera_web_server/__init__.py @@ -14,7 +14,7 @@ esp32_camera_web_server_ns = cg.esphome_ns.namespace("esp32_camera_web_server") CameraWebServer = esp32_camera_web_server_ns.class_("CameraWebServer", cg.Component) Mode = esp32_camera_web_server_ns.enum("Mode") -MODES = {"STREAM": Mode.STREAM, "SNAPSHOT": Mode.SNAPSHOT} +MODES = Mode.options("STREAM", "SNAPSHOT") def _consume_camera_web_server_sockets(config: ConfigType) -> ConfigType: diff --git a/esphome/components/espectre/__init__.py b/esphome/components/espectre/__init__.py index 4f10db996c9..3d9154c5d62 100644 --- a/esphome/components/espectre/__init__.py +++ b/esphome/components/espectre/__init__.py @@ -38,15 +38,8 @@ CsiCapturePolicy = sdk_ns.enum("CsiCapturePolicy", is_class=True) TrafficGeneratorMode = sdk_ns.enum("TrafficGeneratorMode", is_class=True) WifiBandPolicy = sdk_ns.enum("WifiBandPolicy", is_class=True) -DETECTION_ALGORITHMS = { - "lightweight": DetectionAlgorithm.LIGHTWEIGHT, - "high_accuracy": DetectionAlgorithm.HIGH_ACCURACY, -} -CSI_CAPTURE_PROFILES = { - "auto": CsiCapturePolicy.AUTO, - "lltf": CsiCapturePolicy.LLTF, - "ht_vht": CsiCapturePolicy.HT_VHT, -} +DETECTION_ALGORITHMS = DetectionAlgorithm.options("lightweight", "high_accuracy") +CSI_CAPTURE_PROFILES = CsiCapturePolicy.options("auto", "lltf", "ht_vht") TRAFFIC_GENERATOR_MODES = { "ping": TrafficGeneratorMode.PING, "dns": TrafficGeneratorMode.DNS, diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 31e4952d8e0..3fa71632f02 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -117,27 +117,28 @@ CONF_INTERFACE = "interface" CONF_CLOCK_SPEED = "clock_speed" EthernetType = ethernet_ns.enum("EthernetType") -ETHERNET_TYPES = { - "LAN8720": EthernetType.ETHERNET_TYPE_LAN8720, - "RTL8201": EthernetType.ETHERNET_TYPE_RTL8201, - "DP83848": EthernetType.ETHERNET_TYPE_DP83848, - "IP101": EthernetType.ETHERNET_TYPE_IP101, - "JL1101": EthernetType.ETHERNET_TYPE_JL1101, - "KSZ8081": EthernetType.ETHERNET_TYPE_KSZ8081, - "KSZ8081RNA": EthernetType.ETHERNET_TYPE_KSZ8081RNA, - "W5100": EthernetType.ETHERNET_TYPE_W5100, - "W5500": EthernetType.ETHERNET_TYPE_W5500, - "OPENETH": EthernetType.ETHERNET_TYPE_OPENETH, - "DM9051": EthernetType.ETHERNET_TYPE_DM9051, - "LAN8670": EthernetType.ETHERNET_TYPE_LAN8670, - "ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60, - "W6100": EthernetType.ETHERNET_TYPE_W6100, - "W6300": EthernetType.ETHERNET_TYPE_W6300, - "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, - "YT8531": EthernetType.ETHERNET_TYPE_YT8531, - "CH390": EthernetType.ETHERNET_TYPE_CH390, - "KSZ8851SNL": EthernetType.ETHERNET_TYPE_KSZ8851SNL, -} +ETHERNET_TYPES = EthernetType.options( + "LAN8720", + "RTL8201", + "DP83848", + "IP101", + "JL1101", + "KSZ8081", + "KSZ8081RNA", + "W5100", + "W5500", + "OPENETH", + "DM9051", + "LAN8670", + "ENC28J60", + "W6100", + "W6300", + "GENERIC", + "YT8531", + "CH390", + "KSZ8851SNL", + prefix="ETHERNET_TYPE_", +) # PHY types that need compile-time defines for conditional compilation # Each RMII PHY type gets a define so unused PHY drivers are excluded by the linker @@ -207,10 +208,7 @@ SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) emac_rmii_clock_mode_t = cg.global_ns.enum("emac_rmii_clock_mode_t") -CLK_MODES = { - "CLK_EXT_IN": emac_rmii_clock_mode_t.EMAC_CLK_EXT_IN, - "CLK_OUT": emac_rmii_clock_mode_t.EMAC_CLK_OUT, -} +CLK_MODES = emac_rmii_clock_mode_t.options("CLK_EXT_IN", "CLK_OUT", prefix="EMAC_") spi_host_device_t = cg.global_ns.enum("spi_host_device_t") diff --git a/esphome/components/exponential_moving_average/sensor.py b/esphome/components/exponential_moving_average/sensor.py index 20c8135636d..d8f6a949969 100644 --- a/esphome/components/exponential_moving_average/sensor.py +++ b/esphome/components/exponential_moving_average/sensor.py @@ -23,11 +23,9 @@ ExponentialMovingAverageSensor = exponential_moving_average_ns.class_( ) TimeWeighting = exponential_moving_average_ns.enum("TimeWeighting") -TIME_WEIGHTINGS: dict[str, cg.MockObj] = { - "new": TimeWeighting.TIME_WEIGHTING_NEW, - "previous": TimeWeighting.TIME_WEIGHTING_PREVIOUS, - "linear": TimeWeighting.TIME_WEIGHTING_LINEAR, -} +TIME_WEIGHTINGS: dict[str, cg.MockObj] = TimeWeighting.options( + "new", "previous", "linear", prefix="TIME_WEIGHTING_" +) CONF_TIME_WEIGHTING: str = "time_weighting" diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index cb54e89c3ca..2f0003a4def 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -45,21 +45,18 @@ fan_ns = cg.esphome_ns.namespace("fan") Fan = fan_ns.class_("Fan", cg.EntityBase) FanDirection = fan_ns.enum("FanDirection", is_class=True) -FAN_DIRECTION_ENUM = { - "FORWARD": FanDirection.FORWARD, - "REVERSE": FanDirection.REVERSE, -} +FAN_DIRECTION_ENUM = FanDirection.options("FORWARD", "REVERSE") FanRestoreMode = fan_ns.enum("FanRestoreMode", is_class=True) -RESTORE_MODES = { - "NO_RESTORE": FanRestoreMode.NO_RESTORE, - "ALWAYS_OFF": FanRestoreMode.ALWAYS_OFF, - "ALWAYS_ON": FanRestoreMode.ALWAYS_ON, - "RESTORE_DEFAULT_OFF": FanRestoreMode.RESTORE_DEFAULT_OFF, - "RESTORE_DEFAULT_ON": FanRestoreMode.RESTORE_DEFAULT_ON, - "RESTORE_INVERTED_DEFAULT_OFF": FanRestoreMode.RESTORE_INVERTED_DEFAULT_OFF, - "RESTORE_INVERTED_DEFAULT_ON": FanRestoreMode.RESTORE_INVERTED_DEFAULT_ON, -} +RESTORE_MODES = FanRestoreMode.options( + "NO_RESTORE", + "ALWAYS_OFF", + "ALWAYS_ON", + "RESTORE_DEFAULT_OFF", + "RESTORE_DEFAULT_ON", + "RESTORE_INVERTED_DEFAULT_OFF", + "RESTORE_INVERTED_DEFAULT_ON", +) # Actions CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index e496e9c635e..2ef654afc55 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -40,25 +40,14 @@ FingerprintGrowComponent = fingerprint_grow_ns.class_( AuraLEDState = fingerprint_grow_ns.enum("GrowAuraLEDState", True) -AURA_LED_STATES = { - "BREATHING": AuraLEDState.BREATHING, - "FLASHING": AuraLEDState.FLASHING, - "ALWAYS_ON": AuraLEDState.ALWAYS_ON, - "ALWAYS_OFF": AuraLEDState.ALWAYS_OFF, - "GRADUAL_ON": AuraLEDState.GRADUAL_ON, - "GRADUAL_OFF": AuraLEDState.GRADUAL_OFF, -} +AURA_LED_STATES = AuraLEDState.options( + "BREATHING", "FLASHING", "ALWAYS_ON", "ALWAYS_OFF", "GRADUAL_ON", "GRADUAL_OFF" +) validate_aura_led_states = cv.enum(AURA_LED_STATES, upper=True) AuraLEDColor = fingerprint_grow_ns.enum("GrowAuraLEDColor", True) -AURA_LED_COLORS = { - "RED": AuraLEDColor.RED, - "BLUE": AuraLEDColor.BLUE, - "PURPLE": AuraLEDColor.PURPLE, - "GREEN": AuraLEDColor.GREEN, - "YELLOW": AuraLEDColor.YELLOW, - "CYAN": AuraLEDColor.CYAN, - "WHITE": AuraLEDColor.WHITE, -} +AURA_LED_COLORS = AuraLEDColor.options( + "RED", "BLUE", "PURPLE", "GREEN", "YELLOW", "CYAN", "WHITE" +) validate_aura_led_colors = cv.enum(AURA_LED_COLORS, upper=True) diff --git a/esphome/components/friedrich/climate.py b/esphome/components/friedrich/climate.py index 52f1f35708d..b2c65843676 100644 --- a/esphome/components/friedrich/climate.py +++ b/esphome/components/friedrich/climate.py @@ -11,9 +11,7 @@ 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, -} +MODELS = Model.options("MW12Y3H", prefix="MODEL_") CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend( { diff --git a/esphome/components/gp8403/__init__.py b/esphome/components/gp8403/__init__.py index 583b1d15689..59bab37d255 100644 --- a/esphome/components/gp8403/__init__.py +++ b/esphome/components/gp8403/__init__.py @@ -17,15 +17,9 @@ GP8403Model = gp8403_ns.enum("GP8403Model") CONF_GP8403_ID = "gp8403_id" -MODELS = { - "GP8403": GP8403Model.GP8403, - "GP8413": GP8403Model.GP8413, -} +MODELS = GP8403Model.options("GP8403", "GP8413") -VOLTAGES = { - "5V": GP8403Voltage.GP8403_VOLTAGE_5V, - "10V": GP8403Voltage.GP8403_VOLTAGE_10V, -} +VOLTAGES = GP8403Voltage.options("5V", "10V", prefix="GP8403_VOLTAGE_") CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 0aee5973347..ce9035655c3 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -43,26 +43,17 @@ GraphTrace = graph_ns.class_("GraphTrace") GraphLegend = graph_ns.class_("GraphLegend") LineType = graph_ns.enum("LineType") -LINE_TYPE = { - "SOLID": LineType.LINE_TYPE_SOLID, - "DOTTED": LineType.LINE_TYPE_DOTTED, - "DASHED": LineType.LINE_TYPE_DASHED, -} +LINE_TYPE = LineType.options("SOLID", "DOTTED", "DASHED", prefix="LINE_TYPE_") DirectionType = graph_ns.enum("DirectionType") -DIRECTION_TYPE = { - "AUTO": DirectionType.DIRECTION_TYPE_AUTO, - "HORIZONTAL": DirectionType.DIRECTION_TYPE_HORIZONTAL, - "VERTICAL": DirectionType.DIRECTION_TYPE_VERTICAL, -} +DIRECTION_TYPE = DirectionType.options( + "AUTO", "HORIZONTAL", "VERTICAL", prefix="DIRECTION_TYPE_" +) ValuePositionType = graph_ns.enum("ValuePositionType") -VALUE_POSITION_TYPE = { - "NONE": ValuePositionType.VALUE_POSITION_TYPE_NONE, - "AUTO": ValuePositionType.VALUE_POSITION_TYPE_AUTO, - "BESIDE": ValuePositionType.VALUE_POSITION_TYPE_BESIDE, - "BELOW": ValuePositionType.VALUE_POSITION_TYPE_BELOW, -} +VALUE_POSITION_TYPE = ValuePositionType.options( + "NONE", "AUTO", "BESIDE", "BELOW", prefix="VALUE_POSITION_TYPE_" +) GRAPH_TRACE_SCHEMA = cv.Schema( { diff --git a/esphome/components/gree/climate.py b/esphome/components/gree/climate.py index 356845a7a6f..ebdc5f27744 100644 --- a/esphome/components/gree/climate.py +++ b/esphome/components/gree/climate.py @@ -12,15 +12,9 @@ AUTO_LOAD = ["climate_ir"] GreeClimate = gree_ns.class_("GreeClimate", climate_ir.ClimateIR) Model = gree_ns.enum("Model") -MODELS = { - "generic": Model.GREE_GENERIC, - "yan": Model.GREE_YAN, - "yaa": Model.GREE_YAA, - "yac": Model.GREE_YAC, - "yac1fb9": Model.GREE_YAC1FB9, - "yx1ff": Model.GREE_YX1FF, - "yag": Model.GREE_YAG, -} +MODELS = Model.options( + "generic", "yan", "yaa", "yac", "yac1fb9", "yx1ff", "yag", prefix="GREE_" +) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend( { diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index af323e6b07d..0f49a30e6da 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -100,10 +100,7 @@ PV_SCHEMA = cv.Schema( ) GrowattProtocolVersion = growatt_solar_ns.enum("GrowattProtocolVersion") -PROTOCOL_VERSIONS = { - "RTU": GrowattProtocolVersion.RTU, - "RTU2": GrowattProtocolVersion.RTU2, -} +PROTOCOL_VERSIONS = GrowattProtocolVersion.options("RTU", "RTU2") CONFIG_SCHEMA = ( diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index db915e9c7f4..52171d1855f 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -67,23 +67,14 @@ Smartair2Climate = haier_ns.class_("Smartair2Climate", HaierClimateBase) CONF_HAIER_ID = "haier_id" AirflowVerticalDirection = hon_protocol_ns.enum("VerticalSwingMode", True) -AIRFLOW_VERTICAL_DIRECTION_OPTIONS = { - "HEALTH_UP": AirflowVerticalDirection.HEALTH_UP, - "MAX_UP": AirflowVerticalDirection.MAX_UP, - "UP": AirflowVerticalDirection.UP, - "CENTER": AirflowVerticalDirection.CENTER, - "DOWN": AirflowVerticalDirection.DOWN, - "HEALTH_DOWN": AirflowVerticalDirection.HEALTH_DOWN, -} +AIRFLOW_VERTICAL_DIRECTION_OPTIONS = AirflowVerticalDirection.options( + "HEALTH_UP", "MAX_UP", "UP", "CENTER", "DOWN", "HEALTH_DOWN" +) AirflowHorizontalDirection = hon_protocol_ns.enum("HorizontalSwingMode", True) -AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = { - "MAX_LEFT": AirflowHorizontalDirection.MAX_LEFT, - "LEFT": AirflowHorizontalDirection.LEFT, - "CENTER": AirflowHorizontalDirection.CENTER, - "RIGHT": AirflowHorizontalDirection.RIGHT, - "MAX_RIGHT": AirflowHorizontalDirection.MAX_RIGHT, -} +AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = AirflowHorizontalDirection.options( + "MAX_LEFT", "LEFT", "CENTER", "RIGHT", "MAX_RIGHT" +) SUPPORTED_SWING_MODES_OPTIONS = { "OFF": ClimateSwingMode.CLIMATE_SWING_OFF, @@ -114,11 +105,9 @@ SUPPORTED_CLIMATE_PRESETS_HON_OPTIONS = { } HonControlMethod = haier_ns.enum("HonControlMethod", True) -SUPPORTED_HON_CONTROL_METHODS = { - "MONITOR_ONLY": HonControlMethod.MONITOR_ONLY, - "SET_GROUP_PARAMETERS": HonControlMethod.SET_GROUP_PARAMETERS, - "SET_SINGLE_PARAMETER": HonControlMethod.SET_SINGLE_PARAMETER, -} +SUPPORTED_HON_CONTROL_METHODS = HonControlMethod.options( + "MONITOR_ONLY", "SET_GROUP_PARAMETERS", "SET_SINGLE_PARAMETER" +) def validate_visual(config): diff --git a/esphome/components/haier_tundra/climate.py b/esphome/components/haier_tundra/climate.py index c992d14bfed..3ef6ff48598 100644 --- a/esphome/components/haier_tundra/climate.py +++ b/esphome/components/haier_tundra/climate.py @@ -12,10 +12,7 @@ HaierTundra = haier_tundra_ns.class_("HaierTundra", climate_ir.ClimateIR) CONF_UNIT = "unit" Unit = haier_tundra_ns.enum("Unit") -UNITS = { - "a": Unit.HAIER_HEADER_UNIT_A, - "b": Unit.HAIER_HEADER_UNIT_B, -} +UNITS = Unit.options("a", "b", prefix="HAIER_HEADER_UNIT_") CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend( { diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index e862760e6ae..f6d935c300d 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -22,10 +22,7 @@ CODEOWNERS = ["@WeekendWarrior"] HBridgeFan = hbridge_ns.class_("HBridgeFan", cg.Component, fan.Fan) DecayMode = hbridge_ns.enum("DecayMode") -DECAY_MODE_OPTIONS = { - "SLOW": DecayMode.DECAY_MODE_SLOW, - "FAST": DecayMode.DECAY_MODE_FAST, -} +DECAY_MODE_OPTIONS = DecayMode.options("SLOW", "FAST", prefix="DECAY_MODE_") CONFIG_SCHEMA = ( fan.fan_schema(HBridgeFan) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 46e1a95cb12..604730f971a 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -28,12 +28,9 @@ HDC302XComponent = hdc302x_ns.class_( ) HDC302XPowerMode = hdc302x_ns.enum("HDC302XPowerMode") -POWER_MODE_OPTIONS = { - "HIGH_ACCURACY": HDC302XPowerMode.HIGH_ACCURACY, - "BALANCED": HDC302XPowerMode.BALANCED, - "LOW_POWER": HDC302XPowerMode.LOW_POWER, - "ULTRA_LOW_POWER": HDC302XPowerMode.ULTRA_LOW_POWER, -} +POWER_MODE_OPTIONS = HDC302XPowerMode.options( + "HIGH_ACCURACY", "BALANCED", "LOW_POWER", "ULTRA_LOW_POWER" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 42ef79d31d2..03148194fe6 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -78,25 +78,15 @@ PROTOCOLS = { CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = heatpumpir_ns.enum("HorizontalDirections") -HORIZONTAL_DIRECTIONS = { - "auto": HorizontalDirections.HORIZONTAL_DIRECTION_AUTO, - "middle": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE, - "left": HorizontalDirections.HORIZONTAL_DIRECTION_LEFT, - "mleft": HorizontalDirections.HORIZONTAL_DIRECTION_MLEFT, - "mright": HorizontalDirections.HORIZONTAL_DIRECTION_MRIGHT, - "right": HorizontalDirections.HORIZONTAL_DIRECTION_RIGHT, -} +HORIZONTAL_DIRECTIONS = HorizontalDirections.options( + "auto", "middle", "left", "mleft", "mright", "right", prefix="HORIZONTAL_DIRECTION_" +) CONF_VERTICAL_DEFAULT = "vertical_default" VerticalDirections = heatpumpir_ns.enum("VerticalDirections") -VERTICAL_DIRECTIONS = { - "auto": VerticalDirections.VERTICAL_DIRECTION_AUTO, - "up": VerticalDirections.VERTICAL_DIRECTION_UP, - "mup": VerticalDirections.VERTICAL_DIRECTION_MUP, - "middle": VerticalDirections.VERTICAL_DIRECTION_MIDDLE, - "mdown": VerticalDirections.VERTICAL_DIRECTION_MDOWN, - "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, -} +VERTICAL_DIRECTIONS = VerticalDirections.options( + "auto", "up", "mup", "middle", "mdown", "down", prefix="VERTICAL_DIRECTION_" +) def _default_visual(config: ConfigType) -> ConfigType: diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 5099dcc94ca..007f7c53030 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -41,11 +41,9 @@ INITIAL_MODES = { CONF_VOLTAGE: HLW8012InitialMode.HLW8012_INITIAL_MODE_VOLTAGE, } -MODELS = { - "HLW8012": HLW8012SensorModels.HLW8012_SENSOR_MODEL_HLW8012, - "CSE7759": HLW8012SensorModels.HLW8012_SENSOR_MODEL_CSE7759, - "BL0937": HLW8012SensorModels.HLW8012_SENSOR_MODEL_BL0937, -} +MODELS = HLW8012SensorModels.options( + "HLW8012", "CSE7759", "BL0937", prefix="HLW8012_SENSOR_MODEL_" +) CONF_CF1_PIN = "cf1_pin" CONF_CF_PIN = "cf_pin" diff --git a/esphome/components/honeywellabp2_i2c/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index d4c4e44a3b6..7f41fe21939 100644 --- a/esphome/components/honeywellabp2_i2c/sensor.py +++ b/esphome/components/honeywellabp2_i2c/sensor.py @@ -20,10 +20,7 @@ CONF_MIN_PRESSURE = "min_pressure" CONF_MAX_PRESSURE = "max_pressure" TRANSFER_FUNCTION = "transfer_function" ABP2TRANFERFUNCTION = honeywellabp2_ns.enum("ABP2TRANFERFUNCTION") -TRANS_FUNC_OPTIONS = { - "A": ABP2TRANFERFUNCTION.ABP2_TRANS_FUNC_A, - "B": ABP2TRANFERFUNCTION.ABP2_TRANS_FUNC_B, -} +TRANS_FUNC_OPTIONS = ABP2TRANFERFUNCTION.options("A", "B", prefix="ABP2_TRANS_FUNC_") HONEYWELLABP2Sensor = honeywellabp2_ns.class_( "HONEYWELLABP2Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index d78267964b0..bde4b244b4a 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -27,11 +27,9 @@ HTU21DComponent = htu21d_ns.class_( ) HTU21DSensorModels = htu21d_ns.enum("HTU21DSensorModels") -MODELS = { - "HTU21D": HTU21DSensorModels.HTU21D_SENSOR_MODEL_HTU21D, - "SI7021": HTU21DSensorModels.HTU21D_SENSOR_MODEL_SI7021, - "SHT21": HTU21DSensorModels.HTU21D_SENSOR_MODEL_SHT21, -} +MODELS = HTU21DSensorModels.options( + "HTU21D", "SI7021", "SHT21", prefix="HTU21D_SENSOR_MODEL_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 823928176a1..408126e1ad1 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -97,37 +97,32 @@ CONF_MIN_REFRESH_RATE = "min_refresh_rate" # Map to hub75 library enums (in global namespace) Hub75ShiftDriver = cg.global_ns.enum("Hub75ShiftDriver", is_class=True) -SHIFT_DRIVERS = { - "GENERIC": Hub75ShiftDriver.GENERIC, - "FM6126A": Hub75ShiftDriver.FM6126A, - "ICN2038S": Hub75ShiftDriver.ICN2038S, - "FM6124": Hub75ShiftDriver.FM6124, - "MBI5124": Hub75ShiftDriver.MBI5124, - "DP3246": Hub75ShiftDriver.DP3246, -} +SHIFT_DRIVERS = Hub75ShiftDriver.options( + "GENERIC", "FM6126A", "ICN2038S", "FM6124", "MBI5124", "DP3246" +) Hub75PanelLayout = cg.global_ns.enum("Hub75PanelLayout", is_class=True) -PANEL_LAYOUTS = { - "HORIZONTAL": Hub75PanelLayout.HORIZONTAL, - "TOP_LEFT_DOWN": Hub75PanelLayout.TOP_LEFT_DOWN, - "TOP_RIGHT_DOWN": Hub75PanelLayout.TOP_RIGHT_DOWN, - "BOTTOM_LEFT_UP": Hub75PanelLayout.BOTTOM_LEFT_UP, - "BOTTOM_RIGHT_UP": Hub75PanelLayout.BOTTOM_RIGHT_UP, - "TOP_LEFT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_LEFT_DOWN_ZIGZAG, - "TOP_RIGHT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_RIGHT_DOWN_ZIGZAG, - "BOTTOM_LEFT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_LEFT_UP_ZIGZAG, - "BOTTOM_RIGHT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG, -} +PANEL_LAYOUTS = Hub75PanelLayout.options( + "HORIZONTAL", + "TOP_LEFT_DOWN", + "TOP_RIGHT_DOWN", + "BOTTOM_LEFT_UP", + "BOTTOM_RIGHT_UP", + "TOP_LEFT_DOWN_ZIGZAG", + "TOP_RIGHT_DOWN_ZIGZAG", + "BOTTOM_LEFT_UP_ZIGZAG", + "BOTTOM_RIGHT_UP_ZIGZAG", +) Hub75ScanWiring = cg.global_ns.enum("Hub75ScanWiring", is_class=True) -SCAN_WIRINGS = { - "STANDARD_TWO_SCAN": Hub75ScanWiring.STANDARD_TWO_SCAN, - "SCAN_1_4_16PX_HIGH": Hub75ScanWiring.SCAN_1_4_16PX_HIGH, - "SCAN_1_8_32PX_HIGH": Hub75ScanWiring.SCAN_1_8_32PX_HIGH, - "SCAN_1_8_32PX_FULL": Hub75ScanWiring.SCAN_1_8_32PX_FULL, - "SCAN_1_8_40PX_HIGH": Hub75ScanWiring.SCAN_1_8_40PX_HIGH, - "SCAN_1_8_64PX_HIGH": Hub75ScanWiring.SCAN_1_8_64PX_HIGH, -} +SCAN_WIRINGS = Hub75ScanWiring.options( + "STANDARD_TWO_SCAN", + "SCAN_1_4_16PX_HIGH", + "SCAN_1_8_32PX_HIGH", + "SCAN_1_8_32PX_FULL", + "SCAN_1_8_40PX_HIGH", + "SCAN_1_8_64PX_HIGH", +) def _validate_scan_wiring(value: Any) -> str: diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index ddbe08922fe..3c7922421bf 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -68,18 +68,19 @@ INTERNAL_DAC_OPTIONS = { } i2s_comm_format_t = cg.global_ns.enum("i2s_comm_format_t") -I2C_COMM_FMT_OPTIONS = { - "stand_i2s": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_I2S, - "stand_msb": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MSB, - "stand_pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_SHORT, - "stand_pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_LONG, - "stand_max": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MAX, - "i2s_msb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_MSB, - "i2s_lsb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_LSB, - "pcm": i2s_comm_format_t.I2S_COMM_FORMAT_PCM, - "pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_SHORT, - "pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_LONG, -} +I2C_COMM_FMT_OPTIONS = i2s_comm_format_t.options( + "stand_i2s", + "stand_msb", + "stand_pcm_short", + "stand_pcm_long", + "stand_max", + "i2s_msb", + "i2s_lsb", + "pcm", + "pcm_short", + "pcm_long", + prefix="I2S_COMM_FORMAT_", +) INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] diff --git a/esphome/components/icm20689/motion.py b/esphome/components/icm20689/motion.py index 7673dcf4cf4..b2677596c4d 100644 --- a/esphome/components/icm20689/motion.py +++ b/esphome/components/icm20689/motion.py @@ -11,12 +11,9 @@ DEPENDENCIES = ["spi"] # Enum proxies (must match the C++ enum values exactly) ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G, - "4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G, - "8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G, - "16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = ICM20689AccelRange.options( + "2G", "4G", "8G", "16G", prefix="ICM20689_ACCEL_RANGE_" +) ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange") GYRO_RANGE_OPTIONS = { diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 350a0c16527..93eecbbe919 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -57,14 +57,14 @@ Inkplate = inkplate_ns.class_( InkplateModel = inkplate_ns.enum("InkplateModel") -MODELS = { - "inkplate_6": InkplateModel.INKPLATE_6, - "inkplate_10": InkplateModel.INKPLATE_10, - "inkplate_6_plus": InkplateModel.INKPLATE_6_PLUS, - "inkplate_6_v2": InkplateModel.INKPLATE_6_V2, - "inkplate_5": InkplateModel.INKPLATE_5, - "inkplate_5_v2": InkplateModel.INKPLATE_5_V2, -} +MODELS = InkplateModel.options( + "inkplate_6", + "inkplate_10", + "inkplate_6_plus", + "inkplate_6_v2", + "inkplate_5", + "inkplate_5_v2", +) CONF_CUSTOM_WAVEFORM = "custom_waveform" diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index a68cb575192..d8e2c82cf6f 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -28,11 +28,9 @@ INTEGRATION_TIMES = { "d": IntegrationSensorTime.INTEGRATION_SENSOR_TIME_DAY, } IntegrationMethod = integration_ns.enum("IntegrationMethod") -INTEGRATION_METHODS = { - "trapezoid": IntegrationMethod.INTEGRATION_METHOD_TRAPEZOID, - "left": IntegrationMethod.INTEGRATION_METHOD_LEFT, - "right": IntegrationMethod.INTEGRATION_METHOD_RIGHT, -} +INTEGRATION_METHODS = IntegrationMethod.options( + "trapezoid", "left", "right", prefix="INTEGRATION_METHOD_" +) CONF_TIME_UNIT = "time_unit" CONF_INTEGRATION_METHOD = "integration_method" diff --git a/esphome/components/jsn_sr04t/sensor.py b/esphome/components/jsn_sr04t/sensor.py index 0c4187b823f..695c8b97d8d 100644 --- a/esphome/components/jsn_sr04t/sensor.py +++ b/esphome/components/jsn_sr04t/sensor.py @@ -17,10 +17,7 @@ Jsnsr04tComponent = jsn_sr04t_ns.class_( "Jsnsr04tComponent", sensor.Sensor, cg.PollingComponent, uart.UARTDevice ) Model = jsn_sr04t_ns.enum("Model") -MODEL = { - "jsn_sr04t": Model.JSN_SR04T, - "aj_sr04m": Model.AJ_SR04M, -} +MODEL = Model.options("jsn_sr04t", "aj_sr04m") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 0281609edfa..67e5942a43b 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -25,10 +25,9 @@ DEPENDENCIES = ["i2c"] lc709203f_ns = cg.esphome_ns.namespace("lc709203f") LC709203FBatteryVoltage = lc709203f_ns.enum("LC709203FBatteryVoltage") -BATTERY_VOLTAGE_OPTIONS = { - "3.7": LC709203FBatteryVoltage.LC709203F_BATTERY_VOLTAGE_3_7, - "3.8": LC709203FBatteryVoltage.LC709203F_BATTERY_VOLTAGE_3_8, -} +BATTERY_VOLTAGE_OPTIONS = LC709203FBatteryVoltage.options( + "3.7", "3.8", prefix="LC709203F_BATTERY_VOLTAGE_" +) lc709203f = lc709203f_ns.class_("Lc709203f", cg.PollingComponent, i2c.I2CDevice) diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 7a95920a740..23d1d7e00ea 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -21,24 +21,21 @@ ChannelColors = light_ns.struct("ChannelColors") # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) -COLOR_MODES = { - "ON_OFF": ColorMode.ON_OFF, - "BRIGHTNESS": ColorMode.BRIGHTNESS, - "WHITE": ColorMode.WHITE, - "COLOR_TEMPERATURE": ColorMode.COLOR_TEMPERATURE, - "COLD_WARM_WHITE": ColorMode.COLD_WARM_WHITE, - "RGB": ColorMode.RGB, - "RGB_WHITE": ColorMode.RGB_WHITE, - "RGB_COLOR_TEMPERATURE": ColorMode.RGB_COLOR_TEMPERATURE, - "RGB_COLD_WARM_WHITE": ColorMode.RGB_COLD_WARM_WHITE, -} +COLOR_MODES = ColorMode.options( + "ON_OFF", + "BRIGHTNESS", + "WHITE", + "COLOR_TEMPERATURE", + "COLD_WARM_WHITE", + "RGB", + "RGB_WHITE", + "RGB_COLOR_TEMPERATURE", + "RGB_COLD_WARM_WHITE", +) # Limit modes LimitMode = light_ns.enum("LimitMode", is_class=True) -LIMIT_MODES = { - "CLAMP": LimitMode.CLAMP, - "DO_NOTHING": LimitMode.DO_NOTHING, -} +LIMIT_MODES = LimitMode.options("CLAMP", "DO_NOTHING") # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) diff --git a/esphome/components/lis2dw12/__init__.py b/esphome/components/lis2dw12/__init__.py index fcd885e5db4..76a69433910 100644 --- a/esphome/components/lis2dw12/__init__.py +++ b/esphome/components/lis2dw12/__init__.py @@ -37,12 +37,7 @@ 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, -} +RANGES = LIS2DW12Range.options("2G", "4G", "8G", "16G", prefix="RANGE_") LIS2DW12PowerMode = lis2dw12_ns.enum("PowerMode", True) POWER_MODES = { @@ -54,25 +49,23 @@ POWER_MODES = { } 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, -} +DATA_RATES = LIS2DW12DataRate.options( + "1.6Hz", + "12.5Hz", + "25Hz", + "50Hz", + "100Hz", + "200Hz", + "400Hz", + "800Hz", + "1600Hz", + prefix="RATE_", +) 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, -} +FILTER_BANDWIDTHS = LIS2DW12FilterBandwidth.options( + "odr_div_2", "odr_div_4", "odr_div_10", "odr_div_20", prefix="BW_" +) _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_TAP, "add_on_tap_callback"), diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py index ea4b4d3b81a..317d4b48e90 100644 --- a/esphome/components/lis3dh/motion.py +++ b/esphome/components/lis3dh/motion.py @@ -21,12 +21,7 @@ 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, -} +RANGE_OPTIONS = LIS3DHRange.options("2G", "4G", "8G", "16G", prefix="LIS3DH_RANGE_") LIS3DHDataRate = lis3dh_ns.enum("LIS3DHDataRate") DATA_RATE_OPTIONS = { @@ -58,17 +53,14 @@ ODR_HZ = { 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, -} +OPERATING_MODE_OPTIONS = LIS3DHOperatingMode.options( + "LOW_POWER", "NORMAL", "HIGH_RESOLUTION", prefix="LIS3DH_MODE_" +) LIS3DHInterruptPin = lis3dh_ns.enum("LIS3DHInterruptPin") -INTERRUPT_PIN_OPTIONS = { - "INT1": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT1, - "INT2": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT2, -} +INTERRUPT_PIN_OPTIONS = LIS3DHInterruptPin.options( + "INT1", "INT2", prefix="LIS3DH_INT_PIN_" +) # INT1_CFG high-event bit per axis. INT_CFG_HIGH_EVENT = {"x": 0x02, "y": 0x08, "z": 0x20} diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index cf723df9b93..fb9a79f81fe 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -36,15 +36,16 @@ LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") -LOCK_STATES = { - "OPEN": LockState.LOCK_STATE_OPEN, - "LOCKED": LockState.LOCK_STATE_LOCKED, - "UNLOCKED": LockState.LOCK_STATE_UNLOCKED, - "JAMMED": LockState.LOCK_STATE_JAMMED, - "OPENING": LockState.LOCK_STATE_OPENING, - "LOCKING": LockState.LOCK_STATE_LOCKING, - "UNLOCKING": LockState.LOCK_STATE_UNLOCKING, -} +LOCK_STATES = LockState.options( + "OPEN", + "LOCKED", + "UNLOCKED", + "JAMMED", + "OPENING", + "LOCKING", + "UNLOCKING", + prefix="LOCK_STATE_", +) validate_lock_state = cv.enum(LOCK_STATES, upper=True) diff --git a/esphome/components/lsm6ds/motion.py b/esphome/components/lsm6ds/motion.py index 064cd312a92..0eaff38db3e 100644 --- a/esphome/components/lsm6ds/motion.py +++ b/esphome/components/lsm6ds/motion.py @@ -19,12 +19,9 @@ DOMAIN = "lsm6ds" # ── C++ namespace / class ──────────────────────────────────────────────────── # ── Enum proxies ───────────────────────────────────────────────────────────── LSM6DSAccelRange = lsm6ds_ns.enum("LSM6DSAccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_2G, - "4G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_4G, - "8G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_8G, - "16G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = LSM6DSAccelRange.options( + "2G", "4G", "8G", "16G", prefix="LSM6DS_ACCEL_RANGE_" +) LSM6DSAccelODR = lsm6ds_ns.enum("LSM6DSAccelODR") ACCEL_ODR_OPTIONS = { diff --git a/esphome/components/max31856/sensor.py b/esphome/components/max31856/sensor.py index 43a2e18db87..4a12c1bb482 100644 --- a/esphome/components/max31856/sensor.py +++ b/esphome/components/max31856/sensor.py @@ -21,16 +21,9 @@ FILTER = { 60: MAX31865ConfigFilter.FILTER_60HZ, } MAX31856ThermocoupleType = max31856_ns.enum("MAX31856ThermocoupleType") -THERMOCOUPLE_TYPE = { - "B": MAX31856ThermocoupleType.MAX31856_TCTYPE_B, - "E": MAX31856ThermocoupleType.MAX31856_TCTYPE_E, - "J": MAX31856ThermocoupleType.MAX31856_TCTYPE_J, - "K": MAX31856ThermocoupleType.MAX31856_TCTYPE_K, - "N": MAX31856ThermocoupleType.MAX31856_TCTYPE_N, - "R": MAX31856ThermocoupleType.MAX31856_TCTYPE_R, - "S": MAX31856ThermocoupleType.MAX31856_TCTYPE_S, - "T": MAX31856ThermocoupleType.MAX31856_TCTYPE_T, -} +THERMOCOUPLE_TYPE = MAX31856ThermocoupleType.options( + "B", "E", "J", "K", "N", "R", "S", "T", prefix="MAX31856_TCTYPE_" +) CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/max31865/sensor.py b/esphome/components/max31865/sensor.py index 167a0997e45..e249dc5855c 100644 --- a/esphome/components/max31865/sensor.py +++ b/esphome/components/max31865/sensor.py @@ -21,10 +21,7 @@ MAX31865Sensor = max31865_ns.class_( ) MAX31865ConfigFilter = max31865_ns.enum("MAX31865ConfigFilter") -FILTER = { - "50HZ": MAX31865ConfigFilter.FILTER_50HZ, - "60HZ": MAX31865ConfigFilter.FILTER_60HZ, -} +FILTER = MAX31865ConfigFilter.options("50HZ", "60HZ", prefix="FILTER_") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/max44009/sensor.py b/esphome/components/max44009/sensor.py index 88673c5d00a..09b480335ec 100644 --- a/esphome/components/max44009/sensor.py +++ b/esphome/components/max44009/sensor.py @@ -19,11 +19,9 @@ MAX44009Sensor = max44009_ns.class_( ) MAX44009Mode = max44009_ns.enum("MAX44009Mode") -MODE_OPTIONS = { - "auto": MAX44009Mode.MAX44009_MODE_AUTO, - "low_power": MAX44009Mode.MAX44009_MODE_LOW_POWER, - "continuous": MAX44009Mode.MAX44009_MODE_CONTINUOUS, -} +MODE_OPTIONS = MAX44009Mode.options( + "auto", "low_power", "continuous", prefix="MAX44009_MODE_" +) CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 77e82fdec71..b5d5041c968 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -30,10 +30,7 @@ MAX6956 = max6956_ns.class_("MAX6956", cg.Component, i2c.I2CDevice) MAX6956GPIOPin = max6956_ns.class_("MAX6956GPIOPin", cg.GPIOPin) MAX6956_CURRENTMODE = max6956_ns.enum("MAX6956CURRENTMODE") -CURRENT_MODES = { - "global": MAX6956_CURRENTMODE.GLOBAL, - "segment": MAX6956_CURRENTMODE.SEGMENT, -} +CURRENT_MODES = MAX6956_CURRENTMODE.options("global", "segment") CONFIG_SCHEMA = ( diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index 7974ef20202..2271bc09f48 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -28,16 +28,10 @@ CONF_CHIP_LINES_STYLE = "chip_lines_style" integration_ns = cg.esphome_ns.namespace("max7219digit") ChipLinesStyle = integration_ns.enum("ChipLinesStyle") -CHIP_LINES_STYLE = { - "ZIGZAG": ChipLinesStyle.ZIGZAG, - "SNAKE": ChipLinesStyle.SNAKE, -} +CHIP_LINES_STYLE = ChipLinesStyle.options("ZIGZAG", "SNAKE") ScrollMode = integration_ns.enum("ScrollMode") -SCROLL_MODES = { - "CONTINUOUS": ScrollMode.CONTINUOUS, - "STOP": ScrollMode.STOP, -} +SCROLL_MODES = ScrollMode.options("CONTINUOUS", "STOP") CHIP_MODES = { "0": 0, diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index f7449b0ab9f..942a975813a 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -27,12 +27,9 @@ MCP23XXXBase = mcp23xxx_base_ns.class_("MCP23XXXBase", cg.Component) MCP23XXXGPIOPin = mcp23xxx_base_ns.class_("MCP23XXXGPIOPin", cg.GPIOPin) MCP23XXXInterruptMode = mcp23xxx_base_ns.enum("MCP23XXXInterruptMode") -MCP23XXX_INTERRUPT_MODES = { - "NO_INTERRUPT": MCP23XXXInterruptMode.MCP23XXX_NO_INTERRUPT, - "CHANGE": MCP23XXXInterruptMode.MCP23XXX_CHANGE, - "RISING": MCP23XXXInterruptMode.MCP23XXX_RISING, - "FALLING": MCP23XXXInterruptMode.MCP23XXX_FALLING, -} +MCP23XXX_INTERRUPT_MODES = MCP23XXXInterruptMode.options( + "NO_INTERRUPT", "CHANGE", "RISING", "FALLING", prefix="MCP23XXX_" +) MCP23XXX_CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/mcp2515/canbus.py b/esphome/components/mcp2515/canbus.py index eab9be0f700..62b80b172c1 100644 --- a/esphome/components/mcp2515/canbus.py +++ b/esphome/components/mcp2515/canbus.py @@ -15,18 +15,9 @@ mcp2515 = mcp2515_ns.class_("MCP2515", CanbusComponent, spi.SPIDevice) CanClock = mcp2515_ns.enum("CAN_CLOCK") McpMode = mcp2515_ns.enum("CANCTRL_REQOP_MODE") -CAN_CLOCK = { - "8MHZ": CanClock.MCP_8MHZ, - "12MHZ": CanClock.MCP_12MHZ, - "16MHZ": CanClock.MCP_16MHZ, - "20MHZ": CanClock.MCP_20MHZ, -} +CAN_CLOCK = CanClock.options("8MHZ", "12MHZ", "16MHZ", "20MHZ", prefix="MCP_") -MCP_MODE = { - "NORMAL": McpMode.CANCTRL_REQOP_NORMAL, - "LOOPBACK": McpMode.CANCTRL_REQOP_LOOPBACK, - "LISTENONLY": McpMode.CANCTRL_REQOP_LISTENONLY, -} +MCP_MODE = McpMode.options("NORMAL", "LOOPBACK", "LISTENONLY", prefix="CANCTRL_REQOP_") CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( { diff --git a/esphome/components/mcp4728/output/__init__.py b/esphome/components/mcp4728/output/__init__.py index e8cb4c47d69..659a3731a5a 100644 --- a/esphome/components/mcp4728/output/__init__.py +++ b/esphome/components/mcp4728/output/__init__.py @@ -19,7 +19,7 @@ VREF_OPTIONS = { } MCP4728Gain = mcp4728_ns.enum("MCP4728Gain") -GAIN_OPTIONS = {"X1": MCP4728Gain.MCP4728_GAIN_X1, "X2": MCP4728Gain.MCP4728_GAIN_X2} +GAIN_OPTIONS = MCP4728Gain.options("X1", "X2", prefix="MCP4728_GAIN_") MCP4728PwrDown = mcp4728_ns.enum("MCP4728PwrDown") PWRDOWN_OPTIONS = { @@ -30,12 +30,9 @@ PWRDOWN_OPTIONS = { } MCP4728ChannelIdx = mcp4728_ns.enum("MCP4728ChannelIdx") -CHANNEL_OPTIONS = { - "A": MCP4728ChannelIdx.MCP4728_CHANNEL_A, - "B": MCP4728ChannelIdx.MCP4728_CHANNEL_B, - "C": MCP4728ChannelIdx.MCP4728_CHANNEL_C, - "D": MCP4728ChannelIdx.MCP4728_CHANNEL_D, -} +CHANNEL_OPTIONS = MCP4728ChannelIdx.options( + "A", "B", "C", "D", prefix="MCP4728_CHANNEL_" +) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { diff --git a/esphome/components/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 75f2e025c76..9a5afe64c28 100644 --- a/esphome/components/mcp9600/sensor.py +++ b/esphome/components/mcp9600/sensor.py @@ -22,16 +22,9 @@ MCP9600Component = mcp9600_ns.class_( ) MCP9600ThermocoupleType = mcp9600_ns.enum("MCP9600ThermocoupleType") -THERMOCOUPLE_TYPE = { - "K": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_K, - "J": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_J, - "T": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_T, - "N": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_N, - "S": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_S, - "E": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_E, - "B": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_B, - "R": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_R, -} +THERMOCOUPLE_TYPE = MCP9600ThermocoupleType.options( + "K", "J", "T", "N", "S", "E", "B", "R", prefix="MCP9600_THERMOCOUPLE_TYPE_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d012773d8a6..88b000f5fcb 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -42,10 +42,9 @@ MediaPlayerSupportedFormat = media_player_ns.struct("MediaPlayerSupportedFormat" MediaPlayerFormatPurpose = media_player_ns.enum( "MediaPlayerFormatPurpose", is_class=True ) -MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = { - "default": MediaPlayerFormatPurpose.PURPOSE_DEFAULT, - "announcement": MediaPlayerFormatPurpose.PURPOSE_ANNOUNCEMENT, -} +MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = MediaPlayerFormatPurpose.options( + "default", "announcement", prefix="PURPOSE_" +) # Public API for external components. Do not remove. FORMAT_MAPPING = { diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 20cd194d2ed..cecbf9836ef 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -86,11 +86,9 @@ CUSTOM_PRESETS = { } # How midea_ac.display_toggle reaches the appliance; see do_display_toggle() for the AUTO rules -DISPLAY_CONTROLS = { - "UART": DisplayControl.DISPLAY_CONTROL_UART, - "AUTO": DisplayControl.DISPLAY_CONTROL_AUTO, - "IR": DisplayControl.DISPLAY_CONTROL_IR, -} +DISPLAY_CONTROLS = DisplayControl.options( + "UART", "AUTO", "IR", prefix="DISPLAY_CONTROL_" +) validate_modes = cv.enum(ALLOWED_CLIMATE_MODES, upper=True) validate_presets = cv.enum(ALLOWED_CLIMATE_PRESETS, upper=True) diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 1fbe1d67cf3..0085d062d77 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -24,25 +24,27 @@ CONF_FAHRENHEIT_COMPATIBILITY = "fahrenheit_compatibility" CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = mitsubishi_ns.enum("HorizontalDirections") -HORIZONTAL_DIRECTIONS = { - "left": HorizontalDirections.HORIZONTAL_DIRECTION_LEFT, - "middle-left": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE_LEFT, - "middle": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE, - "middle-right": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE_RIGHT, - "right": HorizontalDirections.HORIZONTAL_DIRECTION_RIGHT, - "split": HorizontalDirections.HORIZONTAL_DIRECTION_SPLIT, -} +HORIZONTAL_DIRECTIONS = HorizontalDirections.options( + "left", + "middle-left", + "middle", + "middle-right", + "right", + "split", + prefix="HORIZONTAL_DIRECTION_", +) CONF_VERTICAL_DEFAULT = "vertical_default" VerticalDirections = mitsubishi_ns.enum("VerticalDirections") -VERTICAL_DIRECTIONS = { - "auto": VerticalDirections.VERTICAL_DIRECTION_AUTO, - "up": VerticalDirections.VERTICAL_DIRECTION_UP, - "middle-up": VerticalDirections.VERTICAL_DIRECTION_MIDDLE_UP, - "middle": VerticalDirections.VERTICAL_DIRECTION_MIDDLE, - "middle-down": VerticalDirections.VERTICAL_DIRECTION_MIDDLE_DOWN, - "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, -} +VERTICAL_DIRECTIONS = VerticalDirections.options( + "auto", + "up", + "middle-up", + "middle", + "middle-down", + "down", + prefix="VERTICAL_DIRECTION_", +) CONF_VERTICAL_VANES = "vertical_vanes" diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index ec95b820455..2d996dc376a 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -9,25 +9,21 @@ PduBuffer = modbus_helpers_ns.class_("PduBuffer") FunctionCode_ns = modbus_ns.namespace("FunctionCode") FunctionCode = FunctionCode_ns.enum("FunctionCode") -MODBUS_FUNCTION_CODE = { - "read_coils": FunctionCode.READ_COILS, - "read_discrete_inputs": FunctionCode.READ_DISCRETE_INPUTS, - "read_holding_registers": FunctionCode.READ_HOLDING_REGISTERS, - "read_input_registers": FunctionCode.READ_INPUT_REGISTERS, - "write_single_coil": FunctionCode.WRITE_SINGLE_COIL, - "write_single_register": FunctionCode.WRITE_SINGLE_REGISTER, - "write_multiple_coils": FunctionCode.WRITE_MULTIPLE_COILS, - "write_multiple_registers": FunctionCode.WRITE_MULTIPLE_REGISTERS, -} +MODBUS_FUNCTION_CODE = FunctionCode.options( + "read_coils", + "read_discrete_inputs", + "read_holding_registers", + "read_input_registers", + "write_single_coil", + "write_single_register", + "write_multiple_coils", + "write_multiple_registers", +) EntityType_ns = modbus_ns.namespace("EntityType") EntityType = EntityType_ns.enum("EntityType") -MODBUS_WRITE_REGISTER_TYPE = { - "custom": EntityType.CUSTOM, - "coil": EntityType.COIL, - "holding": EntityType.HOLDING, -} +MODBUS_WRITE_REGISTER_TYPE = EntityType.options("custom", "coil", "holding") MODBUS_REGISTER_TYPE = { **MODBUS_WRITE_REGISTER_TYPE, @@ -38,23 +34,23 @@ MODBUS_REGISTER_TYPE = { SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") SensorValueType = SensorValueType_ns.enum("SensorValueType") -SENSOR_VALUE_TYPE = { - "RAW": SensorValueType.RAW, - "U_WORD": SensorValueType.U_WORD, - "U_WORD_S": SensorValueType.U_WORD_S, - "S_WORD": SensorValueType.S_WORD, - "S_WORD_S": SensorValueType.S_WORD_S, - "U_DWORD": SensorValueType.U_DWORD, - "U_DWORD_R": SensorValueType.U_DWORD_R, - "S_DWORD": SensorValueType.S_DWORD, - "S_DWORD_R": SensorValueType.S_DWORD_R, - "U_QWORD": SensorValueType.U_QWORD, - "U_QWORD_R": SensorValueType.U_QWORD_R, - "S_QWORD": SensorValueType.S_QWORD, - "S_QWORD_R": SensorValueType.S_QWORD_R, - "FP32": SensorValueType.FP32, - "FP32_R": SensorValueType.FP32_R, -} +SENSOR_VALUE_TYPE = SensorValueType.options( + "RAW", + "U_WORD", + "U_WORD_S", + "S_WORD", + "S_WORD_S", + "U_DWORD", + "U_DWORD_R", + "S_DWORD", + "S_DWORD_R", + "U_QWORD", + "U_QWORD_R", + "S_QWORD", + "S_QWORD_R", + "FP32", + "FP32_R", +) TYPE_REGISTER_MAP = { "RAW": 1, diff --git a/esphome/components/modbus_controller/text_sensor/__init__.py b/esphome/components/modbus_controller/text_sensor/__init__.py index 7ab77700ca5..00955464a3d 100644 --- a/esphome/components/modbus_controller/text_sensor/__init__.py +++ b/esphome/components/modbus_controller/text_sensor/__init__.py @@ -33,12 +33,7 @@ ModbusTextSensor = modbus_controller_ns.class_( RawEncoding_ns = modbus_controller_ns.namespace("RawEncoding") RawEncoding = RawEncoding_ns.enum("RawEncoding") -RAW_ENCODING = { - "NONE": RawEncoding.NONE, - "HEXBYTES": RawEncoding.HEXBYTES, - "COMMA": RawEncoding.COMMA, - "ANSI": RawEncoding.ANSI, -} +RAW_ENCODING = RawEncoding.options("NONE", "HEXBYTES", "COMMA", "ANSI") CONFIG_SCHEMA = cv.All( text_sensor.text_sensor_schema() diff --git a/esphome/components/motion/binary_sensor/__init__.py b/esphome/components/motion/binary_sensor/__init__.py index 0a3876c8ab2..175f859e02e 100644 --- a/esphome/components/motion/binary_sensor/__init__.py +++ b/esphome/components/motion/binary_sensor/__init__.py @@ -24,12 +24,9 @@ MotionBinarySensor = motion_ns.class_( MotionBinarySensorType = motion_ns.enum("MotionBinarySensorType") -SENSOR_TYPES = { - "face_up": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_UP, - "face_down": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_DOWN, - "free_fall": MotionBinarySensorType.MOTION_BINARY_SENSOR_FREE_FALL, - "moving": MotionBinarySensorType.MOTION_BINARY_SENSOR_MOVING, -} +SENSOR_TYPES = MotionBinarySensorType.options( + "face_up", "face_down", "free_fall", "moving", prefix="MOTION_BINARY_SENSOR_" +) # face_up / face_down configure their threshold as a maximum tilt angle in degrees; # the C++ side compares against the cosine of that angle. diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 75260a1b0af..3c491ffc898 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -48,12 +48,7 @@ MSA_MODELS = { } MSARange = msa3xx_ns.enum("Range", True) -MSA_RANGES = { - "2G": MSARange.RANGE_2G, - "4G": MSARange.RANGE_4G, - "8G": MSARange.RANGE_8G, - "16G": MSARange.RANGE_16G, -} +MSA_RANGES = MSARange.options("2G", "4G", "8G", "16G", prefix="RANGE_") MSAResolution = msa3xx_ns.enum("Resolution", True) RESOLUTIONS_MSA301 = { diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 5822c95c7ef..96352a8f1a6 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -177,20 +177,13 @@ NumberMode = number_ns.enum("NumberMode") # skips the setter when the config equals it. DEFAULT_MODE = "AUTO" -NUMBER_MODES = { - "AUTO": NumberMode.NUMBER_MODE_AUTO, - "BOX": NumberMode.NUMBER_MODE_BOX, - "SLIDER": NumberMode.NUMBER_MODE_SLIDER, -} +NUMBER_MODES = NumberMode.options("AUTO", "BOX", "SLIDER", prefix="NUMBER_MODE_") NumberOperation = number_ns.enum("NumberOperation") -NUMBER_OPERATION_OPTIONS = { - "INCREMENT": NumberOperation.NUMBER_OP_INCREMENT, - "DECREMENT": NumberOperation.NUMBER_OP_DECREMENT, - "TO_MIN": NumberOperation.NUMBER_OP_TO_MIN, - "TO_MAX": NumberOperation.NUMBER_OP_TO_MAX, -} +NUMBER_OPERATION_OPTIONS = NumberOperation.options( + "INCREMENT", "DECREMENT", "TO_MIN", "TO_MAX", prefix="NUMBER_OP_" +) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") validate_unit_of_measurement = cv.All( diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 16103aee443..699643f044c 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -17,10 +17,7 @@ pca9685_ns = cg.esphome_ns.namespace("pca9685") PCA9685Output = pca9685_ns.class_("PCA9685Output", cg.Component, i2c.I2CDevice) phase_balancer = pca9685_ns.enum("PhaseBalancer", is_class=True) -PHASE_BALANCERS = { - "none": phase_balancer.NONE, - "linear": phase_balancer.LINEAR, -} +PHASE_BALANCERS = phase_balancer.options("none", "linear") def validate_frequency(config: ConfigType) -> ConfigType: diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py index 829adeccb7a..638e51a0bc3 100644 --- a/esphome/components/pcm5122/switch/__init__.py +++ b/esphome/components/pcm5122/switch/__init__.py @@ -9,10 +9,9 @@ from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch) pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode") -PCM5122_POWER_SWITCH_MODE_ENUM = { - "standby": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_STANDBY, - "powerdown": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_POWERDOWN, -} +PCM5122_POWER_SWITCH_MODE_ENUM = pcm5122_power_switch_mode.options( + "standby", "powerdown", prefix="PCM5122_POWER_SWITCH_MODE_" +) CONFIG_SCHEMA = switch.switch_schema( PCM5122PowerSwitch, diff --git a/esphome/components/pm2005/sensor.py b/esphome/components/pm2005/sensor.py index a0c133eba10..0e36992141b 100644 --- a/esphome/components/pm2005/sensor.py +++ b/esphome/components/pm2005/sensor.py @@ -27,10 +27,7 @@ PM2005Component = pm2005_ns.class_( ) SensorType = pm2005_ns.enum("SensorType") -SENSOR_TYPE = { - "PM2005": SensorType.PM2005, - "PM2105": SensorType.PM2105, -} +SENSOR_TYPE = SensorType.options("PM2005", "PM2105") CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 4cf88bafe9f..46aa87a8e11 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -28,11 +28,9 @@ CONF_USE_PCNT = "use_pcnt" pulse_counter_ns = cg.esphome_ns.namespace("pulse_counter") PulseCounterCountMode = pulse_counter_ns.enum("PulseCounterCountMode") -COUNT_MODES = { - "DISABLE": PulseCounterCountMode.PULSE_COUNTER_DISABLE, - "INCREMENT": PulseCounterCountMode.PULSE_COUNTER_INCREMENT, - "DECREMENT": PulseCounterCountMode.PULSE_COUNTER_DECREMENT, -} +COUNT_MODES = PulseCounterCountMode.options( + "DISABLE", "INCREMENT", "DECREMENT", prefix="PULSE_COUNTER_" +) COUNT_MODE_SCHEMA = cv.enum(COUNT_MODES, upper=True) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index e523ebe7569..05708c65c13 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -32,10 +32,7 @@ PulseMeterSensor = pulse_meter_ns.class_( ) PulseMeterInternalFilterMode = PulseMeterSensor.enum("InternalFilterMode") -FILTER_MODES = { - "EDGE": PulseMeterInternalFilterMode.FILTER_EDGE, - "PULSE": PulseMeterInternalFilterMode.FILTER_PULSE, -} +FILTER_MODES = PulseMeterInternalFilterMode.options("EDGE", "PULSE", prefix="FILTER_") def validate_internal_filter(value: Any) -> TimePeriodMicroseconds: diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index 5e883acd7e1..7788caca442 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -40,13 +40,9 @@ PZEM6L24 = pzem6l24_ns.class_( ) ResetPhase = pzem6l24_ns.enum("ResetPhase") -RESET_PHASE_OPTIONS = { - "all": ResetPhase.RESET_PHASE_ALL, - "a": ResetPhase.RESET_PHASE_A, - "b": ResetPhase.RESET_PHASE_B, - "c": ResetPhase.RESET_PHASE_C, - "combined": ResetPhase.RESET_PHASE_COMBINED, -} +RESET_PHASE_OPTIONS = ResetPhase.options( + "all", "a", "b", "c", "combined", prefix="RESET_PHASE_" +) # Combined config keys CONF_TOTAL_ACTIVE_POWER = "total_active_power" diff --git a/esphome/components/qmi8658/motion.py b/esphome/components/qmi8658/motion.py index 2fd12dc9216..2d5a83a1f1b 100644 --- a/esphome/components/qmi8658/motion.py +++ b/esphome/components/qmi8658/motion.py @@ -14,12 +14,9 @@ from . import QMI8658Component, qmi8658_ns # Enum proxies (must match the C++ enum values exactly) QMI8658AccelRange = qmi8658_ns.enum("QMI8658AccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_2G, - "4G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_4G, - "8G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_8G, - "16G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = QMI8658AccelRange.options( + "2G", "4G", "8G", "16G", prefix="QMI8658_ACCEL_RANGE_" +) QMI8658GyroRange = qmi8658_ns.enum("QMI8658GyroRange") GYRO_RANGE_OPTIONS = { diff --git a/esphome/components/qmp6988/sensor.py b/esphome/components/qmp6988/sensor.py index abc39176c14..955e7026062 100644 --- a/esphome/components/qmp6988/sensor.py +++ b/esphome/components/qmp6988/sensor.py @@ -35,14 +35,9 @@ OVERSAMPLING_OPTIONS = { } QMP6988IIRFilter = qmp6988_ns.enum("QMP6988IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": QMP6988IIRFilter.QMP6988_IIR_FILTER_OFF, - "2X": QMP6988IIRFilter.QMP6988_IIR_FILTER_2X, - "4X": QMP6988IIRFilter.QMP6988_IIR_FILTER_4X, - "8X": QMP6988IIRFilter.QMP6988_IIR_FILTER_8X, - "16X": QMP6988IIRFilter.QMP6988_IIR_FILTER_16X, - "32X": QMP6988IIRFilter.QMP6988_IIR_FILTER_32X, -} +IIR_FILTER_OPTIONS = QMP6988IIRFilter.options( + "OFF", "2X", "4X", "8X", "16X", "32X", prefix="QMP6988_IIR_FILTER_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 356d78469fe..460e097b437 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -16,12 +16,9 @@ qr_code_ns = cg.esphome_ns.namespace("qr_code") QRCode = qr_code_ns.class_("QrCode", cg.Component) qrcodegen_Ecc = cg.esphome_ns.enum("qrcodegen_Ecc") -ECC = { - "LOW": qrcodegen_Ecc.qrcodegen_Ecc_LOW, - "MEDIUM": qrcodegen_Ecc.qrcodegen_Ecc_MEDIUM, - "QUARTILE": qrcodegen_Ecc.qrcodegen_Ecc_QUARTILE, - "HIGH": qrcodegen_Ecc.qrcodegen_Ecc_HIGH, -} +ECC = qrcodegen_Ecc.options( + "LOW", "MEDIUM", "QUARTILE", "HIGH", prefix="qrcodegen_Ecc_" +) CONFIG_SCHEMA = cv.ensure_list( cv.Schema( diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 286d209e980..0e0a63e9d94 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2565,15 +2565,16 @@ register_protocol_action( ) = declare_protocol("Hob2Hood") Hob2HoodCommand = remote_base_ns.enum("Hob2HoodCommand") -HOB2HOOD_COMMAND_OPTIONS = { - "light_off": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_OFF, - "light_on": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_ON, - "fan_off": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_OFF, - "fan_low": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_LOW, - "fan_medium": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MEDIUM, - "fan_high": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_HIGH, - "fan_max": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MAX, -} +HOB2HOOD_COMMAND_OPTIONS = Hob2HoodCommand.options( + "light_off", + "light_on", + "fan_off", + "fan_low", + "fan_medium", + "fan_high", + "fan_max", + prefix="HOB2HOOD_COMMAND_", +) HOB2HOOD_SCHEMA = cv.Schema( {cv.Required(CONF_COMMAND): cv.enum(HOB2HOOD_COMMAND_OPTIONS, lower=True)} @@ -2841,29 +2842,19 @@ register_protocol_action( ) = 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, -} +IRA211_COMMANDS = IRA211CommandEnum.options( + "TEMP_UP", "TEMP_DOWN", "MODE", "FAN", "POWER", "SYNC", prefix="IRA211_COMMAND_" +) IRA211ModeEnum = ns.enum("IRA211Mode", is_class=True) -IRA211_MODES = { - "PROTECTION": IRA211ModeEnum.IRA211_MODE_PROTECTION, - "TIMER": IRA211ModeEnum.IRA211_MODE_TIMER, - "COMFORT": IRA211ModeEnum.IRA211_MODE_COMFORT, -} +IRA211_MODES = IRA211ModeEnum.options( + "PROTECTION", "TIMER", "COMFORT", prefix="IRA211_MODE_" +) 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, -} +IRA211_FAN_SPEEDS = IRA211FanEnum.options( + "AUTO", "LOW", "MEDIUM", "HIGH", prefix="IRA211_FAN_" +) def _half_degree_step(value: float) -> float: diff --git a/esphome/components/resistance/sensor.py b/esphome/components/resistance/sensor.py index 1333375717e..cbf329713df 100644 --- a/esphome/components/resistance/sensor.py +++ b/esphome/components/resistance/sensor.py @@ -17,10 +17,7 @@ CONF_CONFIGURATION = "configuration" CONF_RESISTOR = "resistor" ResistanceConfiguration = resistance_ns.enum("ResistanceConfiguration") -CONFIGURATIONS = { - "DOWNSTREAM": ResistanceConfiguration.DOWNSTREAM, - "UPSTREAM": ResistanceConfiguration.UPSTREAM, -} +CONFIGURATIONS = ResistanceConfiguration.options("DOWNSTREAM", "UPSTREAM") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index ab5442e6764..31391d9c6ac 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -20,10 +20,9 @@ from esphome.types import ConfigType rotary_encoder_ns = cg.esphome_ns.namespace("rotary_encoder") RotaryEncoderRestoreMode = rotary_encoder_ns.enum("RotaryEncoderRestoreMode") -RESTORE_MODES = { - "RESTORE_DEFAULT_ZERO": RotaryEncoderRestoreMode.ROTARY_ENCODER_RESTORE_DEFAULT_ZERO, - "ALWAYS_ZERO": RotaryEncoderRestoreMode.ROTARY_ENCODER_ALWAYS_ZERO, -} +RESTORE_MODES = RotaryEncoderRestoreMode.options( + "RESTORE_DEFAULT_ZERO", "ALWAYS_ZERO", prefix="ROTARY_ENCODER_" +) RotaryEncoderResolution = rotary_encoder_ns.enum("RotaryEncoderResolution") RESOLUTIONS = { diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 5b7259f9e51..53a9d438292 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -143,12 +143,7 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( Chipset = rp2040_pio_led_strip_ns.enum("Chipset") -CHIPSETS = { - "WS2812": Chipset.CHIPSET_WS2812, - "WS2812B": Chipset.CHIPSET_WS2812B, - "SK6812": Chipset.CHIPSET_SK6812, - "SM16703": Chipset.CHIPSET_SM16703, -} +CHIPSETS = Chipset.options("WS2812", "WS2812B", "SK6812", "SM16703", prefix="CHIPSET_") @dataclass diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 71be6e43b16..e78ae2a6b22 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -37,12 +37,9 @@ SCD4XComponent = scd4x_ns.class_( "SCD4XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) MeasurementMode = scd4x_ns.enum("MEASUREMENT_MODE") -MEASUREMENT_MODE_OPTIONS = { - "periodic": MeasurementMode.PERIODIC, - "low_power_periodic": MeasurementMode.LOW_POWER_PERIODIC, - "single_shot": MeasurementMode.SINGLE_SHOT, - "single_shot_rht_only": MeasurementMode.SINGLE_SHOT_RHT_ONLY, -} +MEASUREMENT_MODE_OPTIONS = MeasurementMode.options( + "periodic", "low_power_periodic", "single_shot", "single_shot_rht_only" +) CONFIG_SCHEMA = ( diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 7b9f616460b..68fe39b6bcb 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -41,12 +41,9 @@ SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) # Enums SelectOperation = select_ns.enum("SelectOperation") -SELECT_OPERATION_OPTIONS = { - "NEXT": SelectOperation.SELECT_OP_NEXT, - "PREVIOUS": SelectOperation.SELECT_OP_PREVIOUS, - "FIRST": SelectOperation.SELECT_OP_FIRST, - "LAST": SelectOperation.SELECT_OP_LAST, -} +SELECT_OPERATION_OPTIONS = SelectOperation.options( + "NEXT", "PREVIOUS", "FIRST", "LAST", prefix="SELECT_OP_" +) _SELECT_SCHEMA = ( diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 7bfce0f26a5..d45bb2c7916 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -66,11 +66,7 @@ ACCELERATION_MODES = { "high": RhtAccelerationMode.HIGH_ACCELERATION, } -MODELS = { - "SEN50": Sen5xType.SEN50, - "SEN54": Sen5xType.SEN54, - "SEN55": Sen5xType.SEN55, -} +MODELS = Sen5xType.options("SEN50", "SEN54", "SEN55") def _gas_sensor( diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py index 24aa2d57346..bd492b0d0b6 100644 --- a/esphome/components/sendspin/text_sensor/__init__.py +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -29,12 +29,9 @@ SendspinPairingCodeTextSensor = sendspin_ns.class_( ) SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) -SENDSPIN_TEXT_METADATA_TYPES = { - "title": SendspinTextMetadataTypes.TITLE, - "artist": SendspinTextMetadataTypes.ARTIST, - "album": SendspinTextMetadataTypes.ALBUM, - "album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST, -} +SENDSPIN_TEXT_METADATA_TYPES = SendspinTextMetadataTypes.options( + "title", "artist", "album", "album_artist" +) def _request_roles(config: ConfigType) -> ConfigType: diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index d5baac3c9df..6c3327e556f 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -36,12 +36,9 @@ SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") -SERIAL_PROXY_PORT_TYPES = { - "TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL, - "RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232, - "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, - "USB_SERIAL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL, -} +SERIAL_PROXY_PORT_TYPES = SerialProxyPortType.options( + "TTL", "RS232", "RS485", "USB_SERIAL", prefix="SERIAL_PROXY_PORT_TYPE_" +) CONF_DTR_PIN = "dtr_pin" CONF_PORT_TYPE = "port_type" diff --git a/esphome/components/sht4x/sensor.py b/esphome/components/sht4x/sensor.py index 7fb52e37d4b..d491d94ba14 100644 --- a/esphome/components/sht4x/sensor.py +++ b/esphome/components/sht4x/sensor.py @@ -27,26 +27,21 @@ SHT4XComponent = sht4x_ns.class_( CONF_PRECISION = "precision" SHT4XPRECISION = sht4x_ns.enum("SHT4XPRECISION") -PRECISION_OPTIONS = { - "High": SHT4XPRECISION.SHT4X_PRECISION_HIGH, - "Med": SHT4XPRECISION.SHT4X_PRECISION_MED, - "Low": SHT4XPRECISION.SHT4X_PRECISION_LOW, -} +PRECISION_OPTIONS = SHT4XPRECISION.options( + "High", "Med", "Low", prefix="SHT4X_PRECISION_" +) CONF_HEATER_POWER = "heater_power" SHT4XHEATERPOWER = sht4x_ns.enum("SHT4XHEATERPOWER") -HEATER_POWER_OPTIONS = { - "High": SHT4XHEATERPOWER.SHT4X_HEATERPOWER_HIGH, - "Med": SHT4XHEATERPOWER.SHT4X_HEATERPOWER_MED, - "Low": SHT4XHEATERPOWER.SHT4X_HEATERPOWER_LOW, -} +HEATER_POWER_OPTIONS = SHT4XHEATERPOWER.options( + "High", "Med", "Low", prefix="SHT4X_HEATERPOWER_" +) CONF_HEATER_TIME = "heater_time" SHT4XHEATERTIME = sht4x_ns.enum("SHT4XHEATERTIME") -HEATER_TIME_OPTIONS = { - "Long": SHT4XHEATERTIME.SHT4X_HEATERTIME_LONG, - "Short": SHT4XHEATERTIME.SHT4X_HEATERTIME_SHORT, -} +HEATER_TIME_OPTIONS = SHT4XHEATERTIME.options( + "Long", "Short", prefix="SHT4X_HEATERTIME_" +) CONF_HEATER_MAX_DUTY = "heater_max_duty" diff --git a/esphome/components/sm2135/__init__.py b/esphome/components/sm2135/__init__.py index c42ce4c8b3f..7c5622c041b 100644 --- a/esphome/components/sm2135/__init__.py +++ b/esphome/components/sm2135/__init__.py @@ -17,29 +17,31 @@ CONF_SEPARATE_MODES = "separate_modes" SM2135Current = sm2135_ns.enum("SM2135Current") -DRIVE_STRENGTHS_CW = { - "10mA": SM2135Current.SM2135_CURRENT_10MA, - "15mA": SM2135Current.SM2135_CURRENT_15MA, - "20mA": SM2135Current.SM2135_CURRENT_20MA, - "25mA": SM2135Current.SM2135_CURRENT_25MA, - "30mA": SM2135Current.SM2135_CURRENT_30MA, - "35mA": SM2135Current.SM2135_CURRENT_35MA, - "40mA": SM2135Current.SM2135_CURRENT_40MA, - "45mA": SM2135Current.SM2135_CURRENT_45MA, - "50mA": SM2135Current.SM2135_CURRENT_50MA, - "55mA": SM2135Current.SM2135_CURRENT_55MA, - "60mA": SM2135Current.SM2135_CURRENT_60MA, -} -DRIVE_STRENGTHS_RGB = { - "10mA": SM2135Current.SM2135_CURRENT_10MA, - "15mA": SM2135Current.SM2135_CURRENT_15MA, - "20mA": SM2135Current.SM2135_CURRENT_20MA, - "25mA": SM2135Current.SM2135_CURRENT_25MA, - "30mA": SM2135Current.SM2135_CURRENT_30MA, - "35mA": SM2135Current.SM2135_CURRENT_35MA, - "40mA": SM2135Current.SM2135_CURRENT_40MA, - "45mA": SM2135Current.SM2135_CURRENT_45MA, -} +DRIVE_STRENGTHS_CW = SM2135Current.options( + "10mA", + "15mA", + "20mA", + "25mA", + "30mA", + "35mA", + "40mA", + "45mA", + "50mA", + "55mA", + "60mA", + prefix="SM2135_CURRENT_", +) +DRIVE_STRENGTHS_RGB = SM2135Current.options( + "10mA", + "15mA", + "20mA", + "25mA", + "30mA", + "35mA", + "40mA", + "45mA", + prefix="SM2135_CURRENT_", +) MULTI_CONF = True diff --git a/esphome/components/spa06_base/__init__.py b/esphome/components/spa06_base/__init__.py index c08d92f9d8b..d12f443b8ce 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -24,24 +24,25 @@ DOMAIN = "spa06_base" spa06_ns = cg.esphome_ns.namespace("spa06_base") SampleRate = spa06_ns.enum("SampleRate") -SAMPLE_RATE_OPTIONS = { - "1": SampleRate.SAMPLE_RATE_1, - "2": SampleRate.SAMPLE_RATE_2, - "4": SampleRate.SAMPLE_RATE_4, - "8": SampleRate.SAMPLE_RATE_8, - "16": SampleRate.SAMPLE_RATE_16, - "32": SampleRate.SAMPLE_RATE_32, - "64": SampleRate.SAMPLE_RATE_64, - "128": SampleRate.SAMPLE_RATE_128, - "25p16": SampleRate.SAMPLE_RATE_25P16, - "25p8": SampleRate.SAMPLE_RATE_25P8, - "25p4": SampleRate.SAMPLE_RATE_25P4, - "25p2": SampleRate.SAMPLE_RATE_25P2, - "25": SampleRate.SAMPLE_RATE_25, - "50": SampleRate.SAMPLE_RATE_50, - "100": SampleRate.SAMPLE_RATE_100, - "200": SampleRate.SAMPLE_RATE_200, -} +SAMPLE_RATE_OPTIONS = SampleRate.options( + "1", + "2", + "4", + "8", + "16", + "32", + "64", + "128", + "25p16", + "25p8", + "25p4", + "25p2", + "25", + "50", + "100", + "200", + prefix="SAMPLE_RATE_", +) Oversampling = spa06_ns.enum("Oversampling") OVERSAMPLING_OPTIONS = { diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 159af158086..b37a288d874 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -57,10 +57,7 @@ SpeakerMediaPlayer = speaker_ns.class_( AudioPipeline = speaker_ns.class_("AudioPipeline") AudioPipelineType = speaker_ns.enum("AudioPipelineType", is_class=True) -AUDIO_PIPELINE_TYPE_ENUM = { - "MEDIA": AudioPipelineType.MEDIA, - "ANNOUNCEMENT": AudioPipelineType.ANNOUNCEMENT, -} +AUDIO_PIPELINE_TYPE_ENUM = AudioPipelineType.options("MEDIA", "ANNOUNCEMENT") StopStreamAction = speaker_ns.class_( "StopStreamAction", automation.Action, cg.Parented.template(SpeakerMediaPlayer) diff --git a/esphome/components/spi_device/__init__.py b/esphome/components/spi_device/__init__.py index 3f64dfc3d16..d841987fdc3 100644 --- a/esphome/components/spi_device/__init__.py +++ b/esphome/components/spi_device/__init__.py @@ -14,10 +14,7 @@ spi_device_ns = cg.esphome_ns.namespace("spi_device") spi_device = spi_device_ns.class_("SPIDeviceComponent", cg.Component, spi.SPIDevice) BitOrder = spi.spi_ns.enum("SPIBitOrder") -ORDERS = { - "msb_first": BitOrder.BIT_ORDER_MSB_FIRST, - "lsb_first": BitOrder.BIT_ORDER_LSB_FIRST, -} +ORDERS = BitOrder.options("msb_first", "lsb_first", prefix="BIT_ORDER_") CONF_BIT_ORDER = "bit_order" CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 16d7ef8e862..2805837ee01 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -54,10 +54,7 @@ ST7701S = st7701s_ns.class_("ST7701S", display.Display, cg.Component, spi.SPIDev ColorOrder = display.display_ns.enum("ColorMode") ST7701S_DELAY_FLAG = 0xFF -COLOR_ORDERS = { - "RGB": ColorOrder.COLOR_ORDER_RGB, - "BGR": ColorOrder.COLOR_ORDER_BGR, -} +COLOR_ORDERS = ColorOrder.options("RGB", "BGR", prefix="COLOR_ORDER_") DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index 212fe6bb489..4385164384a 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -36,10 +36,7 @@ STCC4Component = stcc4_ns.class_( MeasurementMode = stcc4_ns.enum("MeasurementMode", is_class=True) -MEASUREMENT_MODE_OPTIONS = { - "continuous": MeasurementMode.CONTINUOUS, - "single_shot": MeasurementMode.SINGLE_SHOT, -} +MEASUREMENT_MODE_OPTIONS = MeasurementMode.options("continuous", "single_shot") def validate_config(config: ConfigType) -> ConfigType: diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index d2e9fa750dc..ef4e51d5e5e 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -15,10 +15,7 @@ DEPENDENCIES = ["sun"] SunSensor = sun_ns.class_("SunSensor", sensor.Sensor, cg.PollingComponent) SensorType = sun_ns.enum("SensorType") -TYPES = { - "elevation": SensorType.SUN_SENSOR_ELEVATION, - "azimuth": SensorType.SUN_SENSOR_AZIMUTH, -} +TYPES = SensorType.options("elevation", "azimuth", prefix="SUN_SENSOR_") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index c8b920d6cb7..075aad534e7 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -89,29 +89,27 @@ BW = { "500_0kHz": SX126xBw.SX126X_BW_500000, } -CODING_RATE = { - "CR_4_5": SX126xLoraCr.LORA_CR_4_5, - "CR_4_6": SX126xLoraCr.LORA_CR_4_6, - "CR_4_7": SX126xLoraCr.LORA_CR_4_7, - "CR_4_8": SX126xLoraCr.LORA_CR_4_8, -} +CODING_RATE = SX126xLoraCr.options( + "CR_4_5", "CR_4_6", "CR_4_7", "CR_4_8", prefix="LORA_" +) MOD = { "LORA": SX126xPacketType.PACKET_TYPE_LORA, "FSK": SX126xPacketType.PACKET_TYPE_GFSK, } -TCXO_VOLTAGE = { - "1_6V": SX126xTcxoCtrl.TCXO_CTRL_1_6V, - "1_7V": SX126xTcxoCtrl.TCXO_CTRL_1_7V, - "1_8V": SX126xTcxoCtrl.TCXO_CTRL_1_8V, - "2_2V": SX126xTcxoCtrl.TCXO_CTRL_2_2V, - "2_4V": SX126xTcxoCtrl.TCXO_CTRL_2_4V, - "2_7V": SX126xTcxoCtrl.TCXO_CTRL_2_7V, - "3_0V": SX126xTcxoCtrl.TCXO_CTRL_3_0V, - "3_3V": SX126xTcxoCtrl.TCXO_CTRL_3_3V, - "NONE": SX126xTcxoCtrl.TCXO_CTRL_NONE, -} +TCXO_VOLTAGE = SX126xTcxoCtrl.options( + "1_6V", + "1_7V", + "1_8V", + "2_2V", + "2_4V", + "2_7V", + "3_0V", + "3_3V", + "NONE", + prefix="TCXO_CTRL_", +) RAMP = { "10us": SX126xRampTime.PA_RAMP_10, diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index ea7fe38ba3c..4b54c646338 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -82,16 +82,9 @@ CODING_RATE = { "CR_4_8": SX127xModemCfg1.CODING_RATE_4_8, } -MOD = { - "LORA": SX127xOpMode.MOD_LORA, - "FSK": SX127xOpMode.MOD_FSK, - "OOK": SX127xOpMode.MOD_OOK, -} +MOD = SX127xOpMode.options("LORA", "FSK", "OOK", prefix="MOD_") -PA_PIN = { - "RFO": SX127xPaConfig.PA_PIN_RFO, - "BOOST": SX127xPaConfig.PA_PIN_BOOST, -} +PA_PIN = SX127xPaConfig.options("RFO", "BOOST", prefix="PA_PIN_") RAMP = { "10us": SX127xPaRamp.PA_RAMP_10, diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index c1c770aa591..7ed2b4fe869 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -27,12 +27,9 @@ SY6970Listener = sy6970_ns.class_("SY6970Listener") I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") # REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on -I2C_WATCHDOG_TIMEOUTS = { - "DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED, - "40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S, - "80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S, - "160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S, -} +I2C_WATCHDOG_TIMEOUTS = I2CWatchdogTimeout.options( + "DISABLED", "40S", "80S", "160S", prefix="I2C_WATCHDOG_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/syslog/__init__.py b/esphome/components/syslog/__init__.py index b48192ddd29..f1393e9fbd7 100644 --- a/esphome/components/syslog/__init__.py +++ b/esphome/components/syslog/__init__.py @@ -17,10 +17,7 @@ syslog_ns = cg.esphome_ns.namespace("syslog") Syslog = syslog_ns.class_("Syslog", Component, Parented.template(udp.UDPComponent)) SyslogFormat = syslog_ns.enum("SyslogFormat") -SYSLOG_FORMATS = { - "RFC3164": SyslogFormat.SYSLOG_FORMAT_RFC3164, - "RFC5424": SyslogFormat.SYSLOG_FORMAT_RFC5424, -} +SYSLOG_FORMATS = SyslogFormat.options("RFC3164", "RFC5424", prefix="SYSLOG_FORMAT_") CONF_STRIP = "strip" CONF_FACILITY = "facility" diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 7b1e3e239e8..94d37832eb4 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -32,19 +32,12 @@ tas58xx_ns = cg.esphome_ns.namespace("tas58xx") TAS58xx = tas58xx_ns.class_("TAS58xx", AudioDac, cg.PollingComponent, i2c.I2CDevice) DacMode = tas58xx_ns.enum("DacMode") -DAC_MODES = { - "btl": DacMode.DAC_MODE_BTL, - "pbtl": DacMode.DAC_MODE_PBTL, -} +DAC_MODES = DacMode.options("btl", "pbtl", prefix="DAC_MODE_") MixerMode = tas58xx_ns.enum("MixerMode") -MIXER_MODES = { - "stereo": MixerMode.MIXER_MODE_STEREO, - "stereo_inverse": MixerMode.MIXER_MODE_STEREO_INVERSE, - "mono": MixerMode.MIXER_MODE_MONO, - "left": MixerMode.MIXER_MODE_LEFT, - "right": MixerMode.MIXER_MODE_RIGHT, -} +MIXER_MODES = MixerMode.options( + "stereo", "stereo_inverse", "mono", "left", "right", prefix="MIXER_MODE_" +) @dataclass(frozen=True) diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index e19d76a64ae..de327f0eba7 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -32,35 +32,31 @@ TCS34725Component = tcs34725_ns.class_( ) TCS34725IntegrationTime = tcs34725_ns.enum("TCS34725IntegrationTime") -TCS34725_INTEGRATION_TIMES = { - "auto": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_AUTO, - "2.4ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_2_4MS, - "24ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_24MS, - "50ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_50MS, - "101ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_101MS, - "120ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_120MS, - "154ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_154MS, - "180ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_180MS, - "199ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_199MS, - "240ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_240MS, - "300ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_300MS, - "360ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_360MS, - "401ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_401MS, - "420ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_420MS, - "480ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_480MS, - "499ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_499MS, - "540ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_540MS, - "600ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_600MS, - "614ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_614MS, -} +TCS34725_INTEGRATION_TIMES = TCS34725IntegrationTime.options( + "auto", + "2.4ms", + "24ms", + "50ms", + "101ms", + "120ms", + "154ms", + "180ms", + "199ms", + "240ms", + "300ms", + "360ms", + "401ms", + "420ms", + "480ms", + "499ms", + "540ms", + "600ms", + "614ms", + prefix="TCS34725_INTEGRATION_TIME_", +) TCS34725Gain = tcs34725_ns.enum("TCS34725Gain") -TCS34725_GAINS = { - "1X": TCS34725Gain.TCS34725_GAIN_1X, - "4X": TCS34725Gain.TCS34725_GAIN_4X, - "16X": TCS34725Gain.TCS34725_GAIN_16X, - "60X": TCS34725Gain.TCS34725_GAIN_60X, -} +TCS34725_GAINS = TCS34725Gain.options("1X", "4X", "16X", "60X", prefix="TCS34725_GAIN_") color_channel_schema = sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, diff --git a/esphome/components/template/alarm_control_panel/__init__.py b/esphome/components/template/alarm_control_panel/__init__.py index a708c83d09f..ec59facb2cf 100644 --- a/esphome/components/template/alarm_control_panel/__init__.py +++ b/esphome/components/template/alarm_control_panel/__init__.py @@ -43,19 +43,19 @@ TemplateAlarmControlPanel = template_ns.class_( TemplateAlarmControlPanelRestoreMode = template_ns.enum( "TemplateAlarmControlPanelRestoreMode" ) -RESTORE_MODES = { - "ALWAYS_DISARMED": TemplateAlarmControlPanelRestoreMode.ALARM_CONTROL_PANEL_ALWAYS_DISARMED, - "RESTORE_DEFAULT_DISARMED": TemplateAlarmControlPanelRestoreMode.ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED, -} +RESTORE_MODES = TemplateAlarmControlPanelRestoreMode.options( + "ALWAYS_DISARMED", "RESTORE_DEFAULT_DISARMED", prefix="ALARM_CONTROL_PANEL_" +) AlarmSensorType = template_ns.enum("AlarmSensorType") -ALARM_SENSOR_TYPES = { - "DELAYED": AlarmSensorType.ALARM_SENSOR_TYPE_DELAYED, - "INSTANT": AlarmSensorType.ALARM_SENSOR_TYPE_INSTANT, - "DELAYED_FOLLOWER": AlarmSensorType.ALARM_SENSOR_TYPE_DELAYED_FOLLOWER, - "INSTANT_ALWAYS": AlarmSensorType.ALARM_SENSOR_TYPE_INSTANT_ALWAYS, -} +ALARM_SENSOR_TYPES = AlarmSensorType.options( + "DELAYED", + "INSTANT", + "DELAYED_FOLLOWER", + "INSTANT_ALWAYS", + prefix="ALARM_SENSOR_TYPE_", +) def validate_config(config): diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 5f7627c5c38..30155de28aa 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -55,10 +55,9 @@ TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Comp TemplateClimateRestoreMode = template_ns.enum( "TemplateClimateRestoreMode", is_class=True ) -CLIMATE_RESTORE_MODES = { - "NO_RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE, - "RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE, -} +CLIMATE_RESTORE_MODES = TemplateClimateRestoreMode.options( + "NO_RESTORE", "RESTORE", prefix="TEMPLATE_CLIMATE_RESTORE_MODE_" +) # Per-field actions that forward a requested value on. The third item is the type of `x`. SET_ACTIONS = ( diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 39df5affcf2..e47fdd9a56d 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -26,11 +26,9 @@ from .. import template_ns TemplateCover = template_ns.class_("TemplateCover", cover.Cover, cg.Component) TemplateCoverRestoreMode = template_ns.enum("TemplateCoverRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TemplateCoverRestoreMode.COVER_NO_RESTORE, - "RESTORE": TemplateCoverRestoreMode.COVER_RESTORE, - "RESTORE_AND_CALL": TemplateCoverRestoreMode.COVER_RESTORE_AND_CALL, -} +RESTORE_MODES = TemplateCoverRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="COVER_" +) CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index a7fd6f7632c..a0b8e9c7c51 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -22,11 +22,9 @@ from .. import template_ns TemplateValve = template_ns.class_("TemplateValve", valve.Valve, cg.Component) TemplateValveRestoreMode = template_ns.enum("TemplateValveRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TemplateValveRestoreMode.VALVE_NO_RESTORE, - "RESTORE": TemplateValveRestoreMode.VALVE_RESTORE, - "RESTORE_AND_CALL": TemplateValveRestoreMode.VALVE_RESTORE_AND_CALL, -} +RESTORE_MODES = TemplateValveRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="VALVE_" +) CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 7f8a82c916d..39e3c56da5f 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -32,11 +32,9 @@ TemplateWaterHeaterPublishAction = template_ns.class_( ) TemplateWaterHeaterRestoreMode = template_ns.enum("TemplateWaterHeaterRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TemplateWaterHeaterRestoreMode.WATER_HEATER_NO_RESTORE, - "RESTORE": TemplateWaterHeaterRestoreMode.WATER_HEATER_RESTORE, - "RESTORE_AND_CALL": TemplateWaterHeaterRestoreMode.WATER_HEATER_RESTORE_AND_CALL, -} +RESTORE_MODES = TemplateWaterHeaterRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="WATER_HEATER_" +) CONFIG_SCHEMA = ( water_heater.water_heater_schema(TemplateWaterHeater) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 6d8c24918d3..bebdd7d0660 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -96,22 +96,20 @@ ThermostatClimateTargetTempConfig = thermostat_ns.struct( ThermostatPresetEntry = thermostat_ns.struct("ThermostatPresetEntry") ThermostatCustomPresetEntry = thermostat_ns.struct("ThermostatCustomPresetEntry") OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom") -ON_BOOT_RESTORE_FROM = { - "MEMORY": OnBootRestoreFrom.MEMORY, - "DEFAULT_PRESET": OnBootRestoreFrom.DEFAULT_PRESET, -} +ON_BOOT_RESTORE_FROM = OnBootRestoreFrom.options("MEMORY", "DEFAULT_PRESET") validate_on_boot_restore_from = cv.enum(ON_BOOT_RESTORE_FROM, upper=True) ClimateMode = climate_ns.enum("ClimateMode") -CLIMATE_MODES = { - "OFF": ClimateMode.CLIMATE_MODE_OFF, - "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, - "COOL": ClimateMode.CLIMATE_MODE_COOL, - "HEAT": ClimateMode.CLIMATE_MODE_HEAT, - "DRY": ClimateMode.CLIMATE_MODE_DRY, - "FAN_ONLY": ClimateMode.CLIMATE_MODE_FAN_ONLY, - "AUTO": ClimateMode.CLIMATE_MODE_AUTO, -} +CLIMATE_MODES = ClimateMode.options( + "OFF", + "HEAT_COOL", + "COOL", + "HEAT", + "DRY", + "FAN_ONLY", + "AUTO", + prefix="CLIMATE_MODE_", +) validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) ClimatePreset = climate_ns.enum("ClimatePreset") diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index 82ac29c7d5b..ce06cb82e3c 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -59,12 +59,13 @@ TMC22XXStepper = tmc22xx_ns.class_( StandstillMode = tmc22xx_ns.enum("StandstillMode") DriverEvent = tmc22xx_ns.enum("DriverEvent") -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, -} +STANDSTILL_MODES = StandstillMode.options( + "normal", + "freewheeling", + "coil_short_ls", + "coil_short_hs", + prefix="STANDSTILL_MODE_", +) 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) diff --git a/esphome/components/tmp1075/sensor.py b/esphome/components/tmp1075/sensor.py index ab510db0c53..09ac80aa4f9 100644 --- a/esphome/components/tmp1075/sensor.py +++ b/esphome/components/tmp1075/sensor.py @@ -32,10 +32,7 @@ POLARITY = { } EAlertFunction = tmp1075_ns.enum("EAlertFunction") -ALERT_FUNCTION = { - "COMPARATOR": EAlertFunction.ALERT_COMPARATOR, - "INTERRUPT": EAlertFunction.ALERT_INTERRUPT, -} +ALERT_FUNCTION = EAlertFunction.options("COMPARATOR", "INTERRUPT", prefix="ALERT_") CONF_ALERT = "alert" CONF_LIMIT_LOW = "limit_low" diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 69fbb210305..1a17045a410 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -11,14 +11,15 @@ toshiba_ns = cg.esphome_ns.namespace("toshiba") ToshibaClimate = toshiba_ns.class_("ToshibaClimate", climate_ir.ClimateIR) Model = toshiba_ns.enum("Model") -MODELS = { - "GENERIC": Model.MODEL_GENERIC, - "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, -} +MODELS = Model.options( + "GENERIC", + "RAC-PT1411HWRU-C", + "RAC-PT1411HWRU-F", + "RAS-2819T", + "RAS-B10N3KV2", + "SEIYA", + prefix="MODEL_", +) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend( { diff --git a/esphome/components/total_daily_energy/sensor.py b/esphome/components/total_daily_energy/sensor.py index 343fe917de9..af2f5ff561c 100644 --- a/esphome/components/total_daily_energy/sensor.py +++ b/esphome/components/total_daily_energy/sensor.py @@ -20,11 +20,9 @@ DEPENDENCIES = ["time"] CONF_POWER_ID = "power_id" total_daily_energy_ns = cg.esphome_ns.namespace("total_daily_energy") TotalDailyEnergyMethod = total_daily_energy_ns.enum("TotalDailyEnergyMethod") -TOTAL_DAILY_ENERGY_METHODS = { - "trapezoid": TotalDailyEnergyMethod.TOTAL_DAILY_ENERGY_METHOD_TRAPEZOID, - "left": TotalDailyEnergyMethod.TOTAL_DAILY_ENERGY_METHOD_LEFT, - "right": TotalDailyEnergyMethod.TOTAL_DAILY_ENERGY_METHOD_RIGHT, -} +TOTAL_DAILY_ENERGY_METHODS = TotalDailyEnergyMethod.options( + "trapezoid", "left", "right", prefix="TOTAL_DAILY_ENERGY_METHOD_" +) TotalDailyEnergy = total_daily_energy_ns.class_( "TotalDailyEnergy", sensor.Sensor, cg.Component ) diff --git a/esphome/components/tsl2561/sensor.py b/esphome/components/tsl2561/sensor.py index 79ee2587d33..3771851acc6 100644 --- a/esphome/components/tsl2561/sensor.py +++ b/esphome/components/tsl2561/sensor.py @@ -24,10 +24,7 @@ INTEGRATION_TIMES = { } TSL2561Gain = tsl2561_ns.enum("TSL2561Gain") -GAINS = { - "1X": TSL2561Gain.TSL2561_GAIN_1X, - "16X": TSL2561Gain.TSL2561_GAIN_16X, -} +GAINS = TSL2561Gain.options("1X", "16X", prefix="TSL2561_GAIN_") CONF_IS_CS_PACKAGE = "is_cs_package" diff --git a/esphome/components/tuya/cover/__init__.py b/esphome/components/tuya/cover/__init__.py index 8c610c02720..6e1daef884a 100644 --- a/esphome/components/tuya/cover/__init__.py +++ b/esphome/components/tuya/cover/__init__.py @@ -17,11 +17,9 @@ CONF_INVERT_POSITION_REPORT = "invert_position_report" TuyaCover = tuya_ns.class_("TuyaCover", cover.Cover, cg.Component) TuyaCoverRestoreMode = tuya_ns.enum("TuyaCoverRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TuyaCoverRestoreMode.COVER_NO_RESTORE, - "RESTORE": TuyaCoverRestoreMode.COVER_RESTORE, - "RESTORE_AND_CALL": TuyaCoverRestoreMode.COVER_RESTORE_AND_CALL, -} +RESTORE_MODES = TuyaCoverRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="COVER_" +) def validate_range(config): diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index bf2d3daf981..5ba55b6ccbb 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -30,11 +30,7 @@ CONF_COLOR_TYPE_LOWERCASE = "color_type_lowercase" TuyaColorType = tuya_ns.enum("TuyaColorType") -COLOR_TYPES = { - "RGB": TuyaColorType.RGB, - "HSV": TuyaColorType.HSV, - "RGBHSV": TuyaColorType.RGBHSV, -} +COLOR_TYPES = TuyaColorType.options("RGB", "HSV", "RGBHSV") TuyaLight = tuya_ns.class_("TuyaLight", light.LightOutput, cg.Component) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 1cf01f4b486..fc811e385f4 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -199,11 +199,9 @@ def _uart_declare_type(value): UARTParityOptions = uart_ns.enum("UARTParityOptions") -UART_PARITY_OPTIONS = { - "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, - "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, - "ODD": UARTParityOptions.UART_CONFIG_PARITY_ODD, -} +UART_PARITY_OPTIONS = UARTParityOptions.options( + "NONE", "EVEN", "ODD", prefix="UART_CONFIG_PARITY_" +) CONF_FLUSH_TIMEOUT = "flush_timeout" CONF_RX_FULL_THRESHOLD = "rx_full_threshold" @@ -211,13 +209,9 @@ CONF_RX_TIMEOUT = "rx_timeout" CONF_CLOCK_SOURCE = "clock_source" UARTClockSource = cg.global_ns.enum("uart_sclk_t") -UART_CLOCK_SOURCES = { - "DEFAULT": UARTClockSource.UART_SCLK_DEFAULT, - "APB": UARTClockSource.UART_SCLK_APB, - "XTAL": UARTClockSource.UART_SCLK_XTAL, - "RTC": UARTClockSource.UART_SCLK_RTC, - "REF_TICK": UARTClockSource.UART_SCLK_REF_TICK, -} +UART_CLOCK_SOURCES = UARTClockSource.options( + "DEFAULT", "APB", "XTAL", "RTC", "REF_TICK", prefix="UART_SCLK_" +) # Keep in sync with SOC_UART_SUPPORT_* in ESP-IDF's per-variant soc_caps.h. UART_CLOCK_SOURCES_BY_VARIANT = { @@ -237,11 +231,7 @@ UART_CLOCK_SOURCES_BY_VARIANT = { } UARTDirection = uart_ns.enum("UARTDirection") -UART_DIRECTIONS = { - "RX": UARTDirection.UART_DIRECTION_RX, - "TX": UARTDirection.UART_DIRECTION_TX, - "BOTH": UARTDirection.UART_DIRECTION_BOTH, -} +UART_DIRECTIONS = UARTDirection.options("RX", "TX", "BOTH", prefix="UART_DIRECTION_") # The reason for having CONF_BYTES at 150 by default: # diff --git a/esphome/components/uln2003/stepper.py b/esphome/components/uln2003/stepper.py index 03fd254d175..f09b2702c71 100644 --- a/esphome/components/uln2003/stepper.py +++ b/esphome/components/uln2003/stepper.py @@ -16,11 +16,9 @@ from esphome.types import ConfigType uln2003_ns = cg.esphome_ns.namespace("uln2003") ULN2003StepMode = uln2003_ns.enum("ULN2003StepMode") -STEP_MODES = { - "FULL_STEP": ULN2003StepMode.ULN2003_STEP_MODE_FULL_STEP, - "HALF_STEP": ULN2003StepMode.ULN2003_STEP_MODE_HALF_STEP, - "WAVE_DRIVE": ULN2003StepMode.ULN2003_STEP_MODE_WAVE_DRIVE, -} +STEP_MODES = ULN2003StepMode.options( + "FULL_STEP", "HALF_STEP", "WAVE_DRIVE", prefix="ULN2003_STEP_MODE_" +) ULN2003 = uln2003_ns.class_("ULN2003", stepper.Stepper, cg.Component) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 0875966071d..213ff54afb1 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -54,11 +54,9 @@ UART_PARITY_OPTIONS = { } UARTStopBitsOptions = usb_uart_ns.enum("UARTStopBitsOptions") -UART_STOP_BITS_OPTIONS = { - "1": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_1, - "1.5": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_1_5, - "2": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_2, -} +UART_STOP_BITS_OPTIONS = UARTStopBitsOptions.options( + "1", "1.5", "2", prefix="UART_CONFIG_STOP_BITS_" +) DEFAULT_BAUD_RATE = 9600 CONF_CLAIM_COMM_INTERFACE = "claim_comm_interface" diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 41e25d63b15..932d677889a 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -54,11 +54,9 @@ VALVE_STATES = { validate_valve_state = cv.enum(VALVE_STATES, upper=True) ValveOperation = valve_ns.enum("ValveOperation") -VALVE_OPERATIONS = { - "IDLE": ValveOperation.VALVE_OPERATION_IDLE, - "OPENING": ValveOperation.VALVE_OPERATION_OPENING, - "CLOSING": ValveOperation.VALVE_OPERATION_CLOSING, -} +VALVE_OPERATIONS = ValveOperation.options( + "IDLE", "OPENING", "CLOSING", prefix="VALVE_OPERATION_" +) validate_valve_operation = cv.enum(VALVE_OPERATIONS, upper=True) CONF_ON_CLOSED = "on_closed" diff --git a/esphome/components/veml3235/sensor.py b/esphome/components/veml3235/sensor.py index afff5988698..e48e654a7a7 100644 --- a/esphome/components/veml3235/sensor.py +++ b/esphome/components/veml3235/sensor.py @@ -24,24 +24,15 @@ VEML3235Sensor = veml3235_ns.class_( "VEML3235Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) VEML3235ComponentIntegrationTime = veml3235_ns.enum("VEML3235ComponentIntegrationTime") -VEML3235_INTEGRATION_TIMES = { - "50ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_50MS, - "100ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_100MS, - "200ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_200MS, - "400ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_400MS, - "800ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_800MS, -} +VEML3235_INTEGRATION_TIMES = VEML3235ComponentIntegrationTime.options( + "50ms", "100ms", "200ms", "400ms", "800ms", prefix="VEML3235_INTEGRATION_TIME_" +) VEML3235ComponentDigitalGain = veml3235_ns.enum("VEML3235ComponentDigitalGain") -DIGITAL_GAINS = { - "1X": VEML3235ComponentDigitalGain.VEML3235_DIGITAL_GAIN_1X, - "2X": VEML3235ComponentDigitalGain.VEML3235_DIGITAL_GAIN_2X, -} +DIGITAL_GAINS = VEML3235ComponentDigitalGain.options( + "1X", "2X", prefix="VEML3235_DIGITAL_GAIN_" +) VEML3235ComponentGain = veml3235_ns.enum("VEML3235ComponentGain") -GAINS = { - "1X": VEML3235ComponentGain.VEML3235_GAIN_1X, - "2X": VEML3235ComponentGain.VEML3235_GAIN_2X, - "4X": VEML3235ComponentGain.VEML3235_GAIN_4X, -} +GAINS = VEML3235ComponentGain.options("1X", "2X", "4X", prefix="VEML3235_GAIN_") def _validate_auto_gain_thresholds(config: ConfigType) -> ConfigType: diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index b5821e55af5..a3a7f047069 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -30,15 +30,16 @@ WaterHeaterTraits = water_heater_ns.class_("WaterHeaterTraits") CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step" WaterHeaterMode = water_heater_ns.enum("WaterHeaterMode") -WATER_HEATER_MODES = { - "OFF": WaterHeaterMode.WATER_HEATER_MODE_OFF, - "ECO": WaterHeaterMode.WATER_HEATER_MODE_ECO, - "ELECTRIC": WaterHeaterMode.WATER_HEATER_MODE_ELECTRIC, - "PERFORMANCE": WaterHeaterMode.WATER_HEATER_MODE_PERFORMANCE, - "HIGH_DEMAND": WaterHeaterMode.WATER_HEATER_MODE_HIGH_DEMAND, - "HEAT_PUMP": WaterHeaterMode.WATER_HEATER_MODE_HEAT_PUMP, - "GAS": WaterHeaterMode.WATER_HEATER_MODE_GAS, -} +WATER_HEATER_MODES = WaterHeaterMode.options( + "OFF", + "ECO", + "ELECTRIC", + "PERFORMANCE", + "HIGH_DEMAND", + "HEAT_PUMP", + "GAS", + prefix="WATER_HEATER_MODE_", +) validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True) _WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( diff --git a/esphome/components/whirlpool/climate.py b/esphome/components/whirlpool/climate.py index efc7e6ca570..4a3a750f503 100644 --- a/esphome/components/whirlpool/climate.py +++ b/esphome/components/whirlpool/climate.py @@ -11,11 +11,7 @@ whirlpool_ns = cg.esphome_ns.namespace("whirlpool") WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR) Model = whirlpool_ns.enum("Model") -MODELS = { - "DG11J1-3A": Model.MODEL_DG11J1_3A, - "DG11J1-91": Model.MODEL_DG11J1_91, - "HISENSE_ARG": Model.MODEL_HISENSE_ARG, -} +MODELS = Model.options("DG11J1-3A", "DG11J1-91", "HISENSE_ARG", prefix="MODEL_") CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend( { diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5879729a22b..5fceb891573 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -194,11 +194,9 @@ WiFiComponent = wifi_ns.class_("WiFiComponent", cg.Component) WiFiAP = wifi_ns.struct("WiFiAP") WiFiPowerSaveMode = wifi_ns.enum("WiFiPowerSaveMode") -WIFI_POWER_SAVE_MODES = { - "NONE": WiFiPowerSaveMode.WIFI_POWER_SAVE_NONE, - "LIGHT": WiFiPowerSaveMode.WIFI_POWER_SAVE_LIGHT, - "HIGH": WiFiPowerSaveMode.WIFI_POWER_SAVE_HIGH, -} +WIFI_POWER_SAVE_MODES = WiFiPowerSaveMode.options( + "NONE", "LIGHT", "HIGH", prefix="WIFI_POWER_SAVE_" +) WiFiBandMode = cg.global_ns.enum("wifi_band_mode_t") WIFI_BAND_MODES = { @@ -208,20 +206,15 @@ WIFI_BAND_MODES = { } WifiMinAuthMode = wifi_ns.enum("WifiMinAuthMode") -WIFI_MIN_AUTH_MODES = { - "WPA": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA, - "WPA2": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA2, - "WPA3": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA3, -} +WIFI_MIN_AUTH_MODES = WifiMinAuthMode.options( + "WPA", "WPA2", "WPA3", prefix="WIFI_MIN_AUTH_MODE_" +) VALIDATE_WIFI_MIN_AUTH_MODE = cv.enum(WIFI_MIN_AUTH_MODES, upper=True) WiFi8266PhyMode = wifi_ns.enum("WiFi8266PhyMode") -WIFI_8266_PHY_MODES = { - "AUTO": WiFi8266PhyMode.WIFI_8266_PHY_MODE_AUTO, - "11B": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11B, - "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, - "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, -} +WIFI_8266_PHY_MODES = WiFi8266PhyMode.options( + "AUTO", "11B", "11G", "11N", prefix="WIFI_8266_PHY_MODE_" +) WIFI_AP_MODE_FALLBACK = "FALLBACK" WIFI_AP_MODE_WISP = "WISP" diff --git a/esphome/components/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index eb3b042cb7a..b67b2d8a9b4 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -21,16 +21,17 @@ CONF_K_VALUE = "k_value" xgzp68xx_ns = cg.esphome_ns.namespace("xgzp68xx") XGZP68XXOversampling = xgzp68xx_ns.enum("XGZP68XXOversampling") -OVERSAMPLING_OPTIONS = { - "256X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_256X, - "512X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_512X, - "1024X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_1024X, - "2048X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_2048X, - "4096X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_4096X, - "8192X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_8192X, - "16384X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_16384X, - "32768X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_32768X, -} +OVERSAMPLING_OPTIONS = XGZP68XXOversampling.options( + "256X", + "512X", + "1024X", + "2048X", + "4096X", + "8192X", + "16384X", + "32768X", + prefix="XGZP68XX_OVERSAMPLING_", +) XGZP68XXComponent = xgzp68xx_ns.class_( "XGZP68XXComponent", cg.PollingComponent, i2c.I2CDevice ) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 109b953de5c..ebbfa13af6a 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -57,12 +57,9 @@ AnalogAttrs = zigbee_ns.struct("AnalogAttrs") AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") report = zigbee_ns.enum("ZigbeeReportT") -REPORT = { - "coordinator": report.ZIGBEE_REPORT_COORDINATOR, - "enable": report.ZIGBEE_REPORT_ENABLE, - "force": report.ZIGBEE_REPORT_FORCE, - "default": report.ZIGBEE_REPORT_DEFAULT, -} +REPORT = report.options( + "coordinator", "enable", "force", "default", prefix="ZIGBEE_REPORT_" +) CONF_CLUSTER = "cluster" CONF_ENDPOINT = "endpoint" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index bb6c153752b..178cb4b6fc8 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -26,38 +26,40 @@ DEVICE_ID = { "CUSTOM_ATTR": 0xFFF2, } cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") -CLUSTER_ID = { - "BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC, - "ON_OFF": cluster_id.EZB_ZCL_CLUSTER_ID_ON_OFF, - "TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME, - "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, - "BINARY_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, - "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, - "ANALOG_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, - "ILLUMINANCE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT, - "TEMPERATURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT, - "PRESSURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT, - "FLOW_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT, - "REL_HUMIDITY_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT, - "OCCUPANCY_SENSING": cluster_id.EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING, - "CARBON_DIOXIDE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT, - "PM2_5_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT, -} +CLUSTER_ID = cluster_id.options( + "BASIC", + "ON_OFF", + "TIME", + "BINARY_INPUT", + "BINARY_OUTPUT", + "ANALOG_INPUT", + "ANALOG_OUTPUT", + "ILLUMINANCE_MEASUREMENT", + "TEMPERATURE_MEASUREMENT", + "PRESSURE_MEASUREMENT", + "FLOW_MEASUREMENT", + "REL_HUMIDITY_MEASUREMENT", + "OCCUPANCY_SENSING", + "CARBON_DIOXIDE_MEASUREMENT", + "PM2_5_MEASUREMENT", + prefix="EZB_ZCL_CLUSTER_ID_", +) CLUSTER_ROLE = { "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), "CLIENT": cg.RawExpression("EZB_ZCL_CLUSTER_CLIENT"), } attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") -ATTR_TYPE = { - "BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL, - "MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8, - "UINT8": attr_type.EZB_ZCL_ATTR_TYPE_UINT8, - "UINT16": attr_type.EZB_ZCL_ATTR_TYPE_UINT16, - "INT8": attr_type.EZB_ZCL_ATTR_TYPE_INT8, - "INT16": attr_type.EZB_ZCL_ATTR_TYPE_INT16, - "ENUM8": attr_type.EZB_ZCL_ATTR_TYPE_ENUM8, - "ENUM16": attr_type.EZB_ZCL_ATTR_TYPE_ENUM16, - "STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING, - "SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE, - "DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE, -} +ATTR_TYPE = attr_type.options( + "BOOL", + "MAP8", + "UINT8", + "UINT16", + "INT8", + "INT16", + "ENUM8", + "ENUM16", + "STRING", + "SINGLE", + "DOUBLE", + prefix="EZB_ZCL_ATTR_TYPE_", +) From 1147b024d6b8b3150fc5846456988864c8aa80aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:17:59 -1000 Subject: [PATCH 480/651] [core] Add sign_extend helper (#20933) --- esphome/core/helpers.h | 6 ++++++ tests/components/core/helpers_test.cpp | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c51ef4a652d..94921e1d088 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -990,6 +990,12 @@ constexpr uint32_t encode_uint32(uint8_t byte1, uint8_t byte2, uint8_t byte3, ui (static_cast(byte3) << 8) | (static_cast(byte4)); } +/// Sign-extend the low \p BITS bits of \p value, e.g. sign_extend<24>(0xFFFFFE) == -2. Higher bits are ignored. +template constexpr int32_t sign_extend(uint32_t value) { + static_assert(BITS > 0 && BITS <= 32, "BITS must be 1 to 32"); + return static_cast(value << (32 - BITS)) >> (32 - BITS); +} + /// Encode a value from its constituent bytes (from most to least significant) in an array with length sizeof(T). template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { T val = 0; diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index d0058a634b2..92a7f841830 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -55,6 +55,21 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { } } +TEST(HelpersTest, SignExtend) { + EXPECT_EQ(sign_extend<24>(0x000000), 0); + EXPECT_EQ(sign_extend<24>(0x7FFFFF), 8388607); + EXPECT_EQ(sign_extend<24>(0x800000), -8388608); + EXPECT_EQ(sign_extend<24>(0xFFFFFE), -2); + EXPECT_EQ(sign_extend<24>(0xAB000001), 1); // bits above BITS are ignored + EXPECT_EQ(sign_extend<12>(0x7FF), 2047); + EXPECT_EQ(sign_extend<12>(0x800), -2048); + EXPECT_EQ(sign_extend<16>(0xFF38), -200); + EXPECT_EQ(sign_extend<1>(1), -1); + EXPECT_EQ(sign_extend<32>(0xFFFFFFFF), -1); + EXPECT_EQ(sign_extend<32>(0x7FFFFFFF), INT32_MAX); + static_assert(sign_extend<12>(0xFFF) == -1, "usable in constant expressions"); +} + 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 From 107bebebad04bdfbf53d7150beb01d152b2be6d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:24:45 -1000 Subject: [PATCH 481/651] [components] Use sign_extend for raw sensor readings (#20934) --- esphome/components/ade7880/ade7880_i2c.cpp | 8 +------- esphome/components/bme280_base/bme280_base.cpp | 10 +++++----- esphome/components/cs5460a/cs5460a.cpp | 3 ++- esphome/components/hx711/hx711.cpp | 4 +--- esphome/components/max31856/max31856.cpp | 9 +++------ esphome/components/max9611/max9611.cpp | 11 ++--------- esphome/components/xdb401/xdb401.cpp | 3 +-- esphome/components/xgzp68xx/xgzp68xx.cpp | 4 +--- 8 files changed, 16 insertions(+), 36 deletions(-) diff --git a/esphome/components/ade7880/ade7880_i2c.cpp b/esphome/components/ade7880/ade7880_i2c.cpp index 294fd430d3e..dfc7a58300f 100644 --- a/esphome/components/ade7880/ade7880_i2c.cpp +++ b/esphome/components/ade7880/ade7880_i2c.cpp @@ -11,12 +11,6 @@ namespace esphome::ade7880 { -// adapted from https://stackoverflow.com/a/55912127/1886371 -template inline T sign_extend(const T &v) noexcept { - using S = struct { signed Val : Bits; }; - return reinterpret_cast(&v)->Val; -} - // Register types // unsigned 8-bit (uint8_t) // signed 10-bit - 16-bit ZP on wire (int16_t, needs sign extension) @@ -49,7 +43,7 @@ uint16_t ADE7880::read_u16_register16_(uint16_t a_register) { int32_t ADE7880::read_s24zp_register16_(uint16_t a_register) { // s24zp means 24 bit signed value in the lower 24 bits of a 32-bit register - int32_t in; + uint32_t in; this->read_register16(a_register, reinterpret_cast(&in), sizeof(in)); return sign_extend<24>(convert_big_endian(in)); } diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 4b74a15c0a6..cf61160f77e 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -3,6 +3,7 @@ #include "bme280_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include @@ -144,11 +145,10 @@ void BME280Component::setup() { this->calibration_.h1 = read_u8_(BME280_REGISTER_DIG_H1); this->calibration_.h2 = read_s16_le_(BME280_REGISTER_DIG_H2); this->calibration_.h3 = read_u8_(BME280_REGISTER_DIG_H3); - // h4 and h5 are signed 12-bit values; shift left then arithmetic right shift to sign-extend - int16_t h4_raw = read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F); - this->calibration_.h4 = static_cast(h4_raw << 4) >> 4; - int16_t h5_raw = read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4); - this->calibration_.h5 = static_cast(h5_raw << 4) >> 4; + this->calibration_.h4 = + sign_extend<12>(read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F)); + this->calibration_.h5 = + sign_extend<12>(read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4)); this->calibration_.h6 = read_u8_(BME280_REGISTER_DIG_H6); uint8_t humid_control_val = 0; diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index 7c4e4847911..4631e5ce3cd 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -1,4 +1,5 @@ #include "cs5460a.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::cs5460a { @@ -304,7 +305,7 @@ bool CS5460AComponent::check_status_() { voltage_sensor_->publish_state(raw_voltage * voltage_multiplier_); if (power_sensor_ != nullptr && raw_energy != prev_raw_energy_) { - int32_t raw = (int32_t) (raw_energy << 8) >> 8; /* Sign-extend */ + int32_t raw = sign_extend<24>(raw_energy); power_sensor_->publish_state(raw * power_multiplier_); prev_raw_energy_ = raw_energy; } diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index 48afc0192de..9a81befcc45 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -67,9 +67,7 @@ bool HX711Sensor::read_sensor_(uint32_t *result) { this->status_clear_warning(); - if (data & 0x800000ULL) { - data |= 0xFF000000ULL; - } + data = static_cast(sign_extend<24>(data)); if (result != nullptr) *result = data; diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index bdb47216daa..b6bfb6127be 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -1,5 +1,6 @@ #include "max31856.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -53,12 +54,8 @@ void MAX31856Sensor::read_thermocouple_temperature_() { // Faults have been logged, clear it for next loop this->clear_fault_(); } else { - int32_t temp24 = this->read_register24_(MAX31856_LTCBH_REG); - if (temp24 & 0x800000) { - temp24 |= 0xFF000000; // fix sign - } - - temp24 >>= 5; // bottom 5 bits are unused + // bottom 5 bits are unused + const int32_t temp24 = sign_extend<24>(this->read_register24_(MAX31856_LTCBH_REG)) >> 5; float temp_c = temp24; temp_c *= 0.0078125; diff --git a/esphome/components/max9611/max9611.cpp b/esphome/components/max9611/max9611.cpp index 420eb7da7cb..486ceaee148 100644 --- a/esphome/components/max9611/max9611.cpp +++ b/esphome/components/max9611/max9611.cpp @@ -1,17 +1,10 @@ #include "max9611.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/components/i2c/i2c_bus.h" namespace esphome::max9611 { using namespace esphome::i2c; -// Sign extend -// http://graphics.stanford.edu/~seander/bithacks.html#FixedSignExtend -template inline T signextend(const T x) { - struct { - T x : B; - } s; - return s.x = x; -} // Map the gain register to in uV/LSB float gain_to_lsb(MAX9611Multiplexer gain) { float lsb = 0.0; @@ -72,7 +65,7 @@ void MAX9611Component::update() { uint16_t t_register = ((register_map_[TEMP_DATA_BYTE_MSB_ADRR] << 8) | (register_map_[TEMP_DATA_BYTE_LSB_ADRR])) >> 7; float voltage = rs_register * VOUT_LSB; float shunt_voltage = (csa_register * gain_to_lsb(gain_)) / MICRO_VOLTS_PER_VOLT; - float temp = signextend(t_register) * TEMP_LSB; + float temp = sign_extend<9>(t_register) * TEMP_LSB; float amps = shunt_voltage / current_resistor_; float watts = amps * voltage; diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp index 5539a1318fd..11a1a569f3f 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -127,8 +127,7 @@ i2c::ErrorCode XDB401Component::read_pressure_(float &pressure) { format_hex_pretty_to(pressure_buf, sizeof(pressure_buf), p_data, 3); ESP_LOGV(TAG, "Got pressure data: %s", pressure_buf); - // Sign-extend 24-bit big-endian pressure value to int32_t. - int32_t raw_pressure = static_cast(encode_uint24(p_data[0], p_data[1], p_data[2]) << 8) >> 8; + int32_t raw_pressure = sign_extend<24>(encode_uint24(p_data[0], p_data[1], p_data[2])); ESP_LOGD(TAG, "Pressure data raw %" PRId32, raw_pressure); pressure = (static_cast(raw_pressure) / CONVERT_PRESSURE) * diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index 795db448f0b..b184a855f6f 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -76,9 +76,7 @@ void XGZP68XXComponent::update() { ESP_LOGV(TAG, "Got raw pressure=%" PRIu32 ", raw temperature=%u, K value=%u", pressure_raw, temperature_raw, this->k_value_); - // Sign extend the pressure - float pressure_in_pa = (float) (((int32_t) pressure_raw << 8) >> 8); - pressure_in_pa /= (float) (this->k_value_); + const float pressure_in_pa = static_cast(sign_extend<24>(pressure_raw)) / this->k_value_; float temperature = ((float) (int16_t) temperature_raw) / 256.0f; From 9031df757bf4351bdc088f787630a8536ecefa95 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:33:29 +0200 Subject: [PATCH 482/651] [core][scheduler] add missing process_to_add call (#20961) Co-authored-by: J. Nick Koston --- esphome/core/scheduler.cpp | 5 ++- .../test_scheduler_cancelled_callback.cpp | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/components/core/test_scheduler_cancelled_callback.cpp diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5d0b96c38f2..1dc95c448b6 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -442,10 +442,10 @@ uint32_t HOT Scheduler::call(uint32_t now) { SchedulerNameLog name_log; bool is_cancelled = is_item_removed_(item); - ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s", + ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRId64 "ms at %" PRIu64 "%s", LOG_STR_ARG(item->get_type_str()), LOG_STR_ARG(item->get_source()), name_log.format(item->get_name_type(), item->get_name(), item->get_name_hash_or_id()), item->interval, - item->get_next_execution() - now_64, item->get_next_execution(), + (int64_t) (item->get_next_execution() - now_64), item->get_next_execution(), is_cancelled ? LOG_STR_LITERAL(" [CANCELLED]") : LOG_STR_LITERAL("")); old_items.push_back(item); @@ -539,6 +539,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(executed_item); + has_added_items |= !this->to_add_.empty(); continue; } diff --git a/tests/components/core/test_scheduler_cancelled_callback.cpp b/tests/components/core/test_scheduler_cancelled_callback.cpp new file mode 100644 index 00000000000..b4cfd21cc5c --- /dev/null +++ b/tests/components/core/test_scheduler_cancelled_callback.cpp @@ -0,0 +1,44 @@ +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/scheduler.h" + +#include + +#include +#include + +namespace esphome::core::testing { + +class SchedulerTestComponent : public Component {}; + +// On a multi-threaded build next_schedule_in() only sees items_, so a timeout a callback leaves in +// to_add_ would let a loop that sleeps on next_schedule_in() oversleep. The case that used to skip +// the merge is a heap item (a zero timeout would run from the defer queue instead) whose callback +// cancels its own item. +TEST(Scheduler, TimeoutAddedByACancelledCallbackIsVisibleAfterCall) { + Scheduler scheduler; + SchedulerTestComponent component; + bool fired = false; + + scheduler.set_timeout(&component, "first", 1, [&]() { + scheduler.cancel_timeout(&component, "first"); + scheduler.set_timeout(&component, "second", 100, [&fired]() { fired = true; }); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + scheduler.call(millis()); + + auto next = scheduler.next_schedule_in(millis()); + ASSERT_TRUE(next.has_value()); + const uint32_t next_ms = next.has_value() ? *next : 0; + EXPECT_GT(next_ms, 50u); + EXPECT_LE(next_ms, 100u); + EXPECT_FALSE(fired); + + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + scheduler.call(millis()); + EXPECT_TRUE(fired); + EXPECT_FALSE(scheduler.next_schedule_in(millis()).has_value()); + scheduler.trim_freelist(); +} + +} // namespace esphome::core::testing From b8afcdfb3c34b385e317272c62ea830279e41969 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:54:53 -1000 Subject: [PATCH 483/651] [i2c] Write 16-bit registers without a heap allocation (#20907) --- esphome/components/i2c/i2c.cpp | 22 ++++++--- esphome/components/i2c/i2c.h | 2 +- tests/components/i2c/write_bytes_16.cpp | 59 +++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 tests/components/i2c/write_bytes_16.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 8fc0a503276..ba23217486e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -3,7 +3,6 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#include namespace esphome::i2c { @@ -59,11 +58,22 @@ bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { } bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const { - // we have to copy in order to be able to change byte order - std::unique_ptr temp{new uint16_t[len]}; - for (size_t i = 0; i < len; i++) - temp[i] = htoi2cs(data[i]); - return write_register(a_register, reinterpret_cast(temp.get()), len * 2) == ERROR_OK; + const size_t size = 1 + len * 2; + SmallBufferWithHeapFallback<17> buffer_alloc(size); // Register plus up to 8 words + uint8_t *buffer = buffer_alloc.get(); + + buffer[0] = a_register; + for (size_t i = 0; i < len; i++) { + const uint16_t word = data[i]; + buffer[1 + i * 2] = word >> 8; + buffer[2 + i * 2] = word; + } + return this->bus_->write_readv(this->address_, buffer, size, nullptr, 0) == ERROR_OK; +} + +bool I2CDevice::write_byte_16(uint8_t a_register, uint16_t data) const { + const uint8_t buffer[3] = {a_register, static_cast(data >> 8), static_cast(data)}; + return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; } I2CRegister &I2CRegister::operator=(uint8_t value) { diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 00929db6207..0d60f519e32 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -264,7 +264,7 @@ class I2CDevice { bool write_byte(uint8_t a_register, uint8_t data) const { return write_bytes(a_register, &data, 1); } - bool write_byte_16(uint8_t a_register, uint16_t data) const { return write_bytes_16(a_register, &data, 1); } + bool write_byte_16(uint8_t a_register, uint16_t data) const; protected: uint8_t address_{0x00}; ///< store the address of the device on the bus diff --git a/tests/components/i2c/write_bytes_16.cpp b/tests/components/i2c/write_bytes_16.cpp new file mode 100644 index 00000000000..1f9670c62ba --- /dev/null +++ b/tests/components/i2c/write_bytes_16.cpp @@ -0,0 +1,59 @@ +#include + +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +class RecordingBus final : public I2CBus { + public: + std::vector written; + ErrorCode result{ERROR_OK}; + + ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, size_t out_size) override { + this->written.assign(data, data + size); + return this->result; + } +}; + +class WriteBytes16Test : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + + RecordingBus bus_; + I2CDevice device_; +}; + +TEST_F(WriteBytes16Test, WritesRegisterThenWordBigEndian) { + EXPECT_TRUE(this->device_.write_byte_16(0x12, 0xABCD)); + EXPECT_EQ(this->bus_.written, (std::vector{0x12, 0xAB, 0xCD})); +} + +TEST_F(WriteBytes16Test, WritesEveryWordBigEndian) { + const uint16_t words[3] = {0x0102, 0x0304, 0xFF00}; + EXPECT_TRUE(this->device_.write_bytes_16(0x7E, words, 3)); + EXPECT_EQ(this->bus_.written, (std::vector{0x7E, 0x01, 0x02, 0x03, 0x04, 0xFF, 0x00})); +} + +TEST_F(WriteBytes16Test, MoreWordsThanTheStackBufferHolds) { + uint16_t words[12]; + std::vector expected{0x01}; + for (uint16_t i = 0; i < 12; i++) { + words[i] = 0x1000 + i; + expected.push_back(0x10); + expected.push_back(i); + } + EXPECT_TRUE(this->device_.write_bytes_16(0x01, words, 12)); + EXPECT_EQ(this->bus_.written, expected); +} + +TEST_F(WriteBytes16Test, ReportsBusFailure) { + this->bus_.result = ERROR_NOT_ACKNOWLEDGED; + EXPECT_FALSE(this->device_.write_byte_16(0x12, 0x0001)); +} + +} // namespace esphome::i2c::testing From 298bd5ec7219f339f53808eed2f5dac5a1a5a2f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:55:28 -1000 Subject: [PATCH 484/651] [core] Add ESP_LOGV_HEX, ESP_LOGVV_HEX and ESP_LOGD_HEX (#20931) --- esphome/core/log.h | 32 ++++++++++++ tests/components/core/test_log_hex.cpp | 67 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/components/core/test_log_hex.cpp diff --git a/esphome/core/log.h b/esphome/core/log.h index 840da292aee..d7a0ff6b679 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -160,6 +160,38 @@ int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #define ESP_LOGV(tag, ...) esph_log_v(tag, __VA_ARGS__) #define ESP_LOGVV(tag, ...) esph_log_vv(tag, __VA_ARGS__) +// Log \p format with up to \p max_bytes of \p data as colon separated hex appended as its last %s argument. +// The stack buffer exists only when the level is compiled in. Needs esphome/core/helpers.h. +#define ESPHOME_LOG_HEX_(log_macro, tag, data, len, max_bytes, format, ...) \ + do { \ + char esph_log_hex_buf_[::esphome::format_hex_pretty_size(max_bytes)]; \ + log_macro(tag, format, __VA_ARGS__ __VA_OPT__(, )::esphome::format_hex_pretty_to(esph_log_hex_buf_, data, len)); \ + } while (0) +#ifdef ESPHOME_LOG_HAS_DEBUG +#define ESP_LOGD_HEX(tag, data, len, max_bytes, format, ...) \ + ESPHOME_LOG_HEX_(ESP_LOGD, tag, data, len, max_bytes, format, __VA_ARGS__) +#else +#define ESP_LOGD_HEX(tag, data, len, max_bytes, format, ...) \ + do { \ + } while (0) +#endif +#ifdef ESPHOME_LOG_HAS_VERBOSE +#define ESP_LOGV_HEX(tag, data, len, max_bytes, format, ...) \ + ESPHOME_LOG_HEX_(ESP_LOGV, tag, data, len, max_bytes, format, __VA_ARGS__) +#else +#define ESP_LOGV_HEX(tag, data, len, max_bytes, format, ...) \ + do { \ + } while (0) +#endif +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE +#define ESP_LOGVV_HEX(tag, data, len, max_bytes, format, ...) \ + ESPHOME_LOG_HEX_(ESP_LOGVV, tag, data, len, max_bytes, format, __VA_ARGS__) +#else +#define ESP_LOGVV_HEX(tag, data, len, max_bytes, format, ...) \ + do { \ + } while (0) +#endif + #define BYTE_TO_BINARY_PATTERN "%c%c%c%c%c%c%c%c" #define BYTE_TO_BINARY(byte) \ ((byte) &0x80 ? '1' : '0'), ((byte) &0x40 ? '1' : '0'), ((byte) &0x20 ? '1' : '0'), ((byte) &0x10 ? '1' : '0'), \ diff --git a/tests/components/core/test_log_hex.cpp b/tests/components/core/test_log_hex.cpp new file mode 100644 index 00000000000..70de9af0828 --- /dev/null +++ b/tests/components/core/test_log_hex.cpp @@ -0,0 +1,67 @@ +#include + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::testing { + +static const char *const TAG = "log_hex_test"; + +TEST(LogHexTest, EvaluatesArgumentsOnlyWhenTheLevelIsCompiledIn) { + static constexpr uint8_t DATA[] = {0x01, 0xAB, 0xFF}; + int debug_calls = 0; + int verbose_calls = 0; + int very_verbose_calls = 0; + auto count = [](int &calls) { + calls++; + return sizeof(DATA); + }; + ESP_LOGD_HEX(TAG, DATA, count(debug_calls), sizeof(DATA), "Data: %s"); + ESP_LOGV_HEX(TAG, DATA, count(verbose_calls), sizeof(DATA), "Frame %u: %s", 7u); + ESP_LOGVV_HEX(TAG, DATA, count(very_verbose_calls), 2, "Type %02X %s: %s", 0x10, "truncated"); +#ifdef ESPHOME_LOG_HAS_DEBUG + EXPECT_EQ(debug_calls, 1); +#else + EXPECT_EQ(debug_calls, 0); +#endif +#ifdef ESPHOME_LOG_HAS_VERBOSE + EXPECT_EQ(verbose_calls, 1); +#else + EXPECT_EQ(verbose_calls, 0); +#endif +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE + EXPECT_EQ(very_verbose_calls, 1); +#else + EXPECT_EQ(very_verbose_calls, 0); +#endif +} + +TEST(LogHexTest, IsASingleStatement) { + static constexpr uint8_t DATA[] = {0x12, 0x34}; + bool other_branch = false; + if (DATA[0] == 0x12) // NOLINT(readability-braces-around-statements) + ESP_LOGV_HEX(TAG, DATA, sizeof(DATA), sizeof(DATA), "Data: %s"); + else // NOLINT(readability-braces-around-statements) + other_branch = true; + EXPECT_FALSE(other_branch); +} + +// Stands in for a log macro so the expansion can be checked without the logger backend +static char captured[64]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#define CAPTURE_LOG(tag, ...) snprintf(captured, sizeof(captured), __VA_ARGS__) + +TEST(LogHexTest, HexTextIsTheLastArgument) { + static constexpr uint8_t DATA[] = {0x01, 0xAB, 0xFF}; + ESPHOME_LOG_HEX_(CAPTURE_LOG, TAG, DATA, sizeof(DATA), sizeof(DATA), "Data: %s"); + EXPECT_STREQ(captured, "Data: 01:AB:FF"); +} + +TEST(LogHexTest, ExtraArgumentsComeFirstAndLongDataIsCut) { + static constexpr uint8_t DATA[] = {0x01, 0xAB, 0xFF}; + ESPHOME_LOG_HEX_(CAPTURE_LOG, TAG, DATA, sizeof(DATA), 2, "Type %02X %s: %s", 0x10, "truncated"); + EXPECT_STREQ(captured, "Type 10 truncated: 01:AB"); +} + +} // namespace esphome::testing From 7671d4410c3c00ded377572ac32b3c1070e80c32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:55:53 -1000 Subject: [PATCH 485/651] [i2c] Add check_chip_id helper (#20941) --- esphome/components/i2c/i2c.cpp | 4 + esphome/components/i2c/i2c.h | 18 +++++ tests/components/i2c/test_i2c_chip_id.cpp | 94 +++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 tests/components/i2c/test_i2c_chip_id.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index ba23217486e..59b0cb744fb 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -21,6 +21,10 @@ void I2CBus::i2c_scan_() { } } +void log_wrong_chip_id(uint8_t id, uint8_t expected) { + ESP_LOGE(TAG, "Wrong chip ID 0x%02X, expected 0x%02X", id, expected); +} + ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len) { return bus_->write_readv(this->address_, &a_register, 1, data, len); } diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 0d60f519e32..4546047d603 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -2,7 +2,9 @@ #include #include +#include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/optional.h" #include "i2c_bus.h" @@ -12,6 +14,9 @@ namespace esphome::i2c { class I2CDevice; // forward declaration +/// Out of line so each check_chip_id() caller only pays for a call. +void log_wrong_chip_id(uint8_t id, uint8_t expected); + /// @brief This class is used to create I2CRegister objects that act as proxies to read/write internal registers on an /// I2C device. /// @details @@ -208,6 +213,19 @@ class I2CDevice { /// @return an i2c::ErrorCode ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len) const; + /// Read the 8-bit ID at \p a_register and accept it when it matches \p expected in the bits set in \p mask. + /// Otherwise mark \p component failed and return false. + bool check_chip_id(Component *component, uint8_t a_register, uint8_t expected, uint8_t mask = 0xFF) { + uint8_t id; + const ErrorCode err = this->read_register(a_register, &id, 1); + if (err == ERROR_OK && (id & mask) == (expected & mask)) + return true; + if (err == ERROR_OK) + log_wrong_chip_id(id, expected & mask); + component->mark_failed(err == ERROR_OK ? LOG_STR("Wrong chip ID") : LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + /// /// Compat APIs /// All methods below have been added for compatibility reasons. They do not bring any functionality and therefore on diff --git a/tests/components/i2c/test_i2c_chip_id.cpp b/tests/components/i2c/test_i2c_chip_id.cpp new file mode 100644 index 00000000000..c3e3fdd4907 --- /dev/null +++ b/tests/components/i2c/test_i2c_chip_id.cpp @@ -0,0 +1,94 @@ +#include + +#include + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/application.h" +#include "esphome/core/component.h" + +namespace esphome::i2c::testing { + +class IdBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + ++this->transactions; + if (this->fail) + return ERROR_NOT_ACKNOWLEDGED; + if (write_count != 1 || read_count != 1) + return ERROR_INVALID_ARGUMENT; + this->last_register = write_buffer[0]; + read_buffer[0] = this->id; + return ERROR_OK; + } + + uint8_t id{0}; + uint8_t last_register{0}; + unsigned transactions{0}; + bool fail{false}; +}; + +class ChipDevice : public Component, public I2CDevice {}; + +class I2CChipIdTest : public ::testing::Test { + protected: + void SetUp() override { + new (&App) Application; + static char name[] = "i2c-chip-id-tests"; + static char friendly_name[] = "I2C chip ID tests"; + App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1); + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + IdBus bus_; + ChipDevice device_; +}; + +TEST_F(I2CChipIdTest, MatchingIdPasses) { + this->bus_.id = 0x33; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x0F, 0x33)); + EXPECT_EQ(this->bus_.last_register, 0x0F); + EXPECT_EQ(this->bus_.transactions, 1u); + EXPECT_FALSE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, WrongIdFails) { + this->bus_.id = 0x32; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x0F, 0x33)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, ReadFailureFails) { + this->bus_.fail = true; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x0F, 0x00)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, MaskAcceptsTwoIds) { + // 0xB1 and 0xB3 differ only in bit 1 + for (uint8_t id : {0xB1, 0xB3}) { + this->bus_.id = id; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x06, 0xB1, 0xFD)); + } + EXPECT_FALSE(this->device_.is_failed()); + this->bus_.id = 0xB5; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x06, 0xB1, 0xFD)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, MaskIgnoresRevisionBits) { + this->bus_.id = 0x27; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x92, 0x24, 0xFC)); + EXPECT_FALSE(this->device_.is_failed()); + this->bus_.id = 0x2B; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x92, 0x24, 0xFC)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, ExpectedBitsOutsideTheMaskAreIgnored) { + this->bus_.id = 0x24; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x92, 0x27, 0xFC)); + EXPECT_FALSE(this->device_.is_failed()); +} + +} // namespace esphome::i2c::testing From a5a883fd190db7e2f13afe27c6df675ca5da63cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:57:00 -1000 Subject: [PATCH 486/651] [core] Add xor_bytes and sum16_bytes checksum helpers (#20921) --- esphome/core/helpers.h | 10 ++++++++++ tests/components/core/helpers_test.cpp | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 94921e1d088..72bd3a0c6ba 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -887,6 +887,16 @@ constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum = 0) { return std::accumulate(data, data + len, sum); } +/// Add up the bytes of \p data with size \p len, truncated to 16 bits. +constexpr uint16_t sum16_bytes(const uint8_t *data, size_t len, uint16_t sum = 0) { + return std::accumulate(data, data + len, sum); +} + +/// XOR the bytes of \p data with size \p len, starting from \p init. +constexpr uint8_t xor_bytes(const uint8_t *data, size_t len, uint8_t init = 0) { + return std::accumulate(data, data + len, init, std::bit_xor<>()); +} + /// 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 92a7f841830..499aea5a8ad 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -78,6 +78,24 @@ TEST(HelpersTest, SumBytes) { static_assert(sum_bytes(FRAME, 2) == 0xEB, "usable in constant expressions"); } +TEST(HelpersTest, Sum16Bytes) { + static constexpr uint8_t FRAME[] = {0x42, 0x4D, 0xE1, 0x00, 0x01}; + EXPECT_EQ(sum16_bytes(FRAME, sizeof(FRAME)), 0x0171); // the PMSx003 active mode command checksum + EXPECT_EQ(sum16_bytes(FRAME, 0), 0); + EXPECT_EQ(sum16_bytes(FRAME, sizeof(FRAME), 0x1234), 0x13A5); + EXPECT_EQ(sum16_bytes(FRAME, sizeof(FRAME), 0xFFFF), 0x0170); // wraps at 16 bits + static_assert(sum16_bytes(FRAME, 2) == 0x8F, "usable in constant expressions"); +} + +TEST(HelpersTest, XorBytes) { + static constexpr uint8_t FRAME[] = {0x01, 0x03, 0x00, 0x15, 0x00}; + EXPECT_EQ(xor_bytes(FRAME + 1, 3, 0xFF), 0xE9); // the Z-Wave GetVersion frame checksum + EXPECT_EQ(xor_bytes(FRAME, 0), 0); + EXPECT_EQ(xor_bytes(FRAME, 0, 0x5A), 0x5A); + EXPECT_EQ(xor_bytes(FRAME, sizeof(FRAME)), 0x17); + static_assert(xor_bytes(FRAME, 2) == 0x02, "usable in constant expressions"); +} + TEST(StaticVectorTest, ConvertingConstructorFromSmaller) { StaticVector small{0x03, 0x00, 0x10, 0x00, 0x01}; StaticVector big = small; From d2a1f7ee31ea1f51768ea02bd91e573ac0ff1b48 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Fri, 16 Oct 2026 23:02:54 -0400 Subject: [PATCH 487/651] [zephyr] Fix preference log levels and report settings read/save errors (#20810) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/zephyr/preferences.cpp | 29 ++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index 6be5f1ce4a5..d5522da03aa 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -5,6 +5,7 @@ #include "preferences.h" #include "esphome/core/log.h" #include +#include #include #include @@ -23,7 +24,11 @@ bool ZephyrPreferenceBackend::load(uint8_t *data, size_t len) { if (len != this->data.size()) { char key_buf[KEY_BUFFER_SIZE]; this->format_key(key_buf, sizeof(key_buf)); - ESP_LOGE(TAG, "size of setting key %s changed, from: %zu, to: %zu", key_buf, this->data.size(), len); + if (this->data.empty()) { + ESP_LOGV(TAG, "setting key %s not found", key_buf); + } else { + ESP_LOGE(TAG, "size of setting key %s changed, from: %zu, to: %zu", key_buf, this->data.size(), len); + } return false; } std::memcpy(data, this->data.data(), len); @@ -87,7 +92,7 @@ bool ZephyrPreferences::load_from_key(uint32_t type, uint8_t *data, size_t len) } bool ZephyrPreferences::sync() { - ESP_LOGD(TAG, "Save settings"); + ESP_LOGV(TAG, "Save settings"); int err = settings_save(); if (err) { ESP_LOGE(TAG, "Cannot save settings, err: %d", err); @@ -117,22 +122,34 @@ int ZephyrPreferences::load_setting(const char *name, size_t len, settings_read_ return 1; } std::vector data(len); - int err = read_cb(cb_arg, data.data(), len); + ssize_t read_len = read_cb(cb_arg, data.data(), len); + if (read_len != static_cast(len)) { + ESP_LOGE(TAG, "Cannot read setting %s, got %d of %zu", name, static_cast(read_len), len); + return 0; + } - ESP_LOGD(TAG, "load setting, name: %s(%" PRIu32 "), len %zu, err %d", name, *type, len, err); + ESP_LOGD(TAG, "load setting, name: %s(%" PRIu32 "), len %zu", name, *type, len); auto *pref = new ZephyrPreferenceBackend(*type, std::move(data)); // NOLINT(cppcoreguidelines-owning-memory) get_preferences()->backends_.push_back(pref); return 0; } int ZephyrPreferences::export_settings(int (*cb)(const char *name, const void *value, size_t val_len)) { + int first_err = 0; for (auto *backend : get_preferences()->backends_) { char name[KEY_BUFFER_SIZE]; backend->format_key(name, sizeof(name)); int err = cb(name, backend->data.data(), backend->data.size()); - ESP_LOGD(TAG, "save in flash, name %s, len %zu, err %d", name, backend->data.size(), err); + if (err) { + ESP_LOGE(TAG, "Cannot save setting %s, err %d", name, err); + if (first_err == 0) { + first_err = err; + } + continue; + } + ESP_LOGV(TAG, "save in flash, name %s, len %zu", name, backend->data.size()); } - return 0; + return first_err; } static ZephyrPreferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From dcdc042a4b88cf6555cd60adcfdbfe6588792555 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 17:53:33 -1000 Subject: [PATCH 488/651] [host] Define a no-op InterruptLock (#20974) --- esphome/components/host/helpers.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index f8b7a0c5f08..4896c441f7d 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -31,6 +31,10 @@ bool random_bytes(uint8_t *data, size_t len) { return true; } +// The host has no interrupts to mask +InterruptLock::InterruptLock() {} +InterruptLock::~InterruptLock() {} + // Host platform uses std::mutex for proper thread synchronization Mutex::Mutex() { handle_ = new std::mutex(); } Mutex::~Mutex() { delete static_cast(handle_); } From 687466b6cddef38738e9c93422e8ea1fee38ab55 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 17 Oct 2026 06:14:51 +0200 Subject: [PATCH 489/651] [logger][usb_cdc_acm][core] Add CDC uart to NRF52 (#11634) --- esphome/components/logger/__init__.py | 16 +- esphome/components/usb_cdc_acm/__init__.py | 119 +++++++-- .../components/usb_cdc_acm/usb_cdc_acm.cpp | 43 +++- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 70 +++++- .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 53 +--- .../usb_cdc_acm/usb_cdc_acm_zephyr.cpp | 230 ++++++++++++++++++ esphome/components/zephyr/__init__.py | 67 ++++- .../components/zephyr_mcumgr/ota/__init__.py | 13 +- esphome/core/defines.h | 2 + .../usb_cdc_acm/test.nrf52-adafruit.yaml | 7 + .../usb_cdc_acm/test.nrf52-mcumgr.yaml | 5 + .../usb_cdc_acm/test.nrf52-xiao-ble.yaml | 8 + tests/unit_tests/test_zephyr_cdc_acm.py | 67 +++++ 13 files changed, 605 insertions(+), 95 deletions(-) create mode 100644 esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp create mode 100644 tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml create mode 100644 tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml create mode 100644 tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml create mode 100644 tests/unit_tests/test_zephyr_cdc_acm.py diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 67137214c1e..cb222b39e34 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -31,9 +31,9 @@ from esphome.components.libretiny.const import ( COMPONENT_RTL87XX, ) from esphome.components.zephyr import ( - zephyr_add_cdc_acm, zephyr_add_overlay, zephyr_add_prj_conf, + zephyr_claim_cdc_acm, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -360,6 +360,19 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config: ConfigType) -> None: + # Serial logging over USB CDC writes to CDC ACM port 0 with uart_poll_out() + if ( + CORE.is_nrf52 + and config[CONF_BAUD_RATE] != 0 + and config.get(CONF_HARDWARE_UART) == USB_CDC + ): + zephyr_claim_cdc_acm(0, "logger", write_only=True) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.EARLY_INIT) async def to_code(config: ConfigType) -> None: baud_rate: int = config[CONF_BAUD_RATE] @@ -521,7 +534,6 @@ async def _late_logger_init(config: ConfigType) -> None: if config[CONF_HARDWARE_UART] == USB_CDC: cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") zephyr_add_prj_conf("UART_LINE_CTRL", True) - zephyr_add_cdc_acm(config, 0) # Register at end for safe mode await cg.register_component(log, config) diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index 08cd13e3d27..bdc1a964ee1 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator + import esphome.codegen as cg from esphome.components import esp32, uart from esphome.components.esp32 import ( @@ -8,15 +10,30 @@ from esphome.components.esp32 import ( VARIANT_ESP32S31, add_idf_sdkconfig_option, ) +from esphome.components.uart import debug_to_code, maybe_empty_debug, uart_ns +from esphome.components.zephyr import zephyr_add_prj_conf, zephyr_claim_cdc_acm import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_RX_BUFFER_SIZE, CONF_TX_BUFFER_SIZE +from esphome.const import ( + CONF_DEBUG, + CONF_DISABLED, + CONF_ID, + CONF_RX_BUFFER_SIZE, + CONF_TX_BUFFER_SIZE, +) +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] -AUTO_LOAD = ["uart"] -DEPENDENCIES = ["tinyusb"] DOMAIN = "usb_cdc_acm" + +def AUTO_LOAD() -> list[str]: + if CORE.using_zephyr: + return ["uart"] + return ["uart", "tinyusb"] + + CONF_INTERFACES = "interfaces" usb_cdc_acm_ns = cg.esphome_ns.namespace("usb_cdc_acm") @@ -30,9 +47,24 @@ USBCDCACMInstance = usb_cdc_acm_ns.class_( INTERFACE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.All(cv.declare_id(USBCDCACMInstance), uart.mark_unclocked), + cv.Optional(CONF_DEBUG): maybe_empty_debug, + cv.Optional(CONF_DISABLED): cv.All(cv.boolean, cv.only_on_nrf52), } ) + +def _enabled_interfaces(config: ConfigType) -> Iterator[tuple[int, ConfigType]]: + for index, conf in enumerate(config[CONF_INTERFACES]): + if not conf.get(CONF_DISABLED, False): + yield index, conf + + +def _validate_any_interface_enabled(config: ConfigType) -> ConfigType: + if next(_enabled_interfaces(config), None) is None: + raise cv.Invalid("At least one interface must be enabled") + return config + + # Main component schema CONFIG_SCHEMA = cv.All( cv.Schema( @@ -50,37 +82,70 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - esp32.only_on_variant( - supported=[ - VARIANT_ESP32H4, - VARIANT_ESP32P4, - VARIANT_ESP32S2, - VARIANT_ESP32S3, - VARIANT_ESP32S31, - ], + _validate_any_interface_enabled, + cv.Any( + cv.only_on_nrf52, + cv.All( + cv.only_on_esp32, + esp32.only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], + ), + ), ), ) +def _final_validate(config: ConfigType) -> None: + if not CORE.using_zephyr: + return + for index, _ in _enabled_interfaces(config): + zephyr_claim_cdc_acm(index, DOMAIN) + + +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) - # Create and register interface instances - for interface_index, interface_conf in enumerate(config[CONF_INTERFACES]): - interface = cg.new_Pvariable(interface_conf[CONF_ID]) - await cg.register_parented(interface, var) - cg.add(interface.set_interface_number(interface_index)) - cg.add(var.add_interface(interface)) - - # Configure TinyUSB with the correct number of CDC interfaces num_interfaces = len(config[CONF_INTERFACES]) - add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_COUNT", num_interfaces) - add_idf_sdkconfig_option( - "CONFIG_TINYUSB_CDC_RX_BUFSIZE", config[CONF_RX_BUFFER_SIZE] - ) - add_idf_sdkconfig_option( - "CONFIG_TINYUSB_CDC_TX_BUFSIZE", config[CONF_TX_BUFFER_SIZE] - ) cg.add_define("ESPHOME_MAX_USB_CDC_INSTANCES", num_interfaces) + # Create and register interface instances + for index, conf in _enabled_interfaces(config): + args = ( + (MockObj(f"DEVICE_DT_GET_OR_NULL(DT_NODELABEL(cdc_acm_uart{index}))"),) + if CORE.using_zephyr + else () + ) + interface = cg.new_Pvariable(conf[CONF_ID], *args) + await cg.register_parented(interface, var) + cg.add(interface.set_interface_number(index)) + cg.add(var.add_interface(interface)) + if (debug := conf.get(CONF_DEBUG)) is not None: + cg.add_global(uart_ns.using) + await debug_to_code(debug, interface) + if CORE.using_zephyr: + zephyr_add_prj_conf("UART_LINE_CTRL", True) + zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) + # Every CDC ACM instance (the logger's and any the board tree adds) holds a transfer + # slot per endpoint; the default of 4 leaves a third instance without any + zephyr_add_prj_conf("USB_MAX_NUM_TRANSFERS", 8) + cg.add_define("ESPHOME_CDC_RX_RING_BUFFER_SIZE", config[CONF_RX_BUFFER_SIZE]) + cg.add_define("ESPHOME_CDC_TX_RING_BUFFER_SIZE", config[CONF_TX_BUFFER_SIZE]) + else: + # Configure TinyUSB with the correct number of CDC interfaces + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_COUNT", num_interfaces) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_RX_BUFSIZE", config[CONF_RX_BUFFER_SIZE] + ) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_TX_BUFSIZE", config[CONF_TX_BUFFER_SIZE] + ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index f24339b4bf3..da12a10b16a 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -1,7 +1,8 @@ #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) + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ZEPHYR) #include "usb_cdc_acm.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::usb_cdc_acm { @@ -15,6 +16,7 @@ USBCDCACMComponent *global_usb_cdc_component = nullptr; // NOLINT(cppcoreguidel // USBCDCACMInstance Implementation //============================================================================== +// NOLINTBEGIN(clang-analyzer-unix.Malloc) void USBCDCACMInstance::queue_line_state_event(bool dtr, bool rts) { // Allocate event from pool CDCEvent *event = this->event_pool_.allocate(); @@ -53,6 +55,7 @@ void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_ this->event_queue_.push(event); App.wake_loop_threadsafe(); } +// NOLINTEND(clang-analyzer-unix.Malloc) void USBCDCACMInstance::process_events_() { // Process all pending events from the queue @@ -113,6 +116,44 @@ void USBCDCACMInstance::process_events_() { } } +bool USBCDCACMInstance::peek_byte(uint8_t *data) { + if (this->has_peek_) { + *data = this->peek_buffer_; + return true; + } + + if (this->read_byte(&this->peek_buffer_)) { + *data = this->peek_buffer_; + this->has_peek_ = true; + return true; + } + + return false; +} + +bool USBCDCACMInstance::consume_peek_(uint8_t *&data, size_t &len) { + if (!this->has_peek_) { + return false; + } + // No debug callback here: peek_byte() read this byte through read_array(), which already reported it + *data++ = this->peek_buffer_; + this->has_peek_ = false; + return --len == 0; +} + +void USBCDCACMInstance::record_tx_drop_(size_t dropped) { + // During a sustained host stall the buffer stays full, so this runs for every write; throttle the + // warning. The total is never reset, so each line accounts for every byte dropped so far and the + // first drop since boot is logged unthrottled. + const bool first_drop = this->tx_dropped_bytes_ == 0; + this->tx_dropped_bytes_ += dropped; + const uint32_t now = millis(); + if (first_drop || (now - this->tx_dropped_log_ms_) >= LOG_THROTTLE_MS) { + this->tx_dropped_log_ms_ = now; + ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %zu bytes dropped total", this->itf_, this->tx_dropped_bytes_); + } +} + //============================================================================== // USBCDCACMComponent Implementation //============================================================================== diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 83cb5de89f8..4dc9c54c985 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -1,6 +1,6 @@ #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) + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ZEPHYR) #include "esphome/core/component.h" #include "esphome/core/event_pool.h" @@ -11,14 +11,22 @@ #include #include #include +#ifdef USE_ZEPHYR +#include +#include +#else #include "freertos/ringbuf.h" #include "esp_err.h" #include "tinyusb_cdc_acm.h" +#endif namespace esphome::usb_cdc_acm { static const uint8_t EVENT_QUEUE_SIZE = 12; +// Minimum interval between repeated warnings while a host stall persists +static constexpr uint32_t LOG_THROTTLE_MS = 1000; +#ifndef USE_ZEPHYR // Drain up to out_buf_sz bytes from a byte ring buffer, handling FreeRTOS's wrapped // case with a second read. Shared with the cdc_acm_uart bridge platform, whose worker // tasks drain the same ring buffers. @@ -47,6 +55,7 @@ inline esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, return ESP_OK; } +#endif // Callback types for line coding and line state changes using LineCodingCallback = std::function; @@ -93,12 +102,19 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parenteditf_ = itf; } // Get the CDC port number for this instance uint8_t get_itf() const { return this->itf_; } +#ifdef USE_ZEPHYR + explicit USBCDCACMInstance(const device *uart_dev) : uart_dev_(uart_dev) {} +#else + static void usb_tx_task_fn(void *arg); + void usb_tx_task(); + // Ring buffer accessors for bridge components RingbufHandle_t get_tx_ringbuf() const { return this->usb_tx_ringbuf_; } RingbufHandle_t get_rx_ringbuf() const { return this->usb_rx_ringbuf_; } // Task handle accessor for notifying TX task TaskHandle_t get_tx_task_handle() const { return this->usb_tx_task_handle_; } +#endif // Callback registration for line coding and line state changes void set_line_coding_callback(LineCodingCallback callback) { this->line_coding_callback_ = std::move(callback); } @@ -108,9 +124,6 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parenteddebug_callback_.call(uart::UART_DIRECTION_RX, data[i]); + } +#endif + } + void report_tx_debug_(const uint8_t *data, size_t len) { +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(uart::UART_DIRECTION_TX, data[i]); + } +#endif + } +#ifdef USE_ZEPHYR + static void uart_irq_handler(const device *dev, void *instance); + void uart_rx_process_(); + void uart_tx_process_(); + + const device *uart_dev_; + uint32_t last_line_poll_ms_{0}; + ring_buf rx_ringbuf_; + ring_buf tx_ringbuf_; + std::atomic rx_irq_disabled_{false}; + std::atomic tx_irq_disabled_{false}; + // Set by the IRQ handler when our TX ring buffer and the driver's are both empty + std::atomic tx_drained_{false}; + bool dtr_{false}; + bool rts_{false}; + uint8_t rx_ringbuf_data_[ESPHOME_CDC_RX_RING_BUFFER_SIZE]; + uint8_t tx_ringbuf_data_[ESPHOME_CDC_TX_RING_BUFFER_SIZE]; +#else // True while TX bytes are still in the ring buffer or held by the TX task bool tx_pending_(); TaskHandle_t usb_tx_task_handle_{nullptr}; @@ -146,18 +197,19 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented ops instead of inlining // them; atomic inlines correctly on all platforms. std::atomic usb_tx_busy_{0}; +#endif // Running total of bytes dropped by write_array() (never reset), and the timestamp // of the last "buffer full" log line (throttled so a sustained host stall doesn't // flood the log). - uint32_t tx_dropped_bytes_{0}; + size_t tx_dropped_bytes_{0}; uint32_t tx_dropped_log_ms_{0}; + // User-registered callbacks (called from main loop) + LineCodingCallback line_coding_callback_{nullptr}; + LineStateCallback line_state_callback_{nullptr}; // RX buffer for peek functionality uint8_t peek_buffer_{0}; bool has_peek_{false}; uint8_t itf_{0}; - // User-registered callbacks (called from main loop) - LineCodingCallback line_coding_callback_{nullptr}; - LineStateCallback line_state_callback_{nullptr}; // Lock-free queue and event pool for cross-task event passing // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 9ac94802e17..0f02ddf3c7a 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -29,9 +29,6 @@ static constexpr size_t USB_TX_TASK_STACK_SIZE_VV = 8192; // the final TinyUSB flush share this budget. static constexpr uint32_t FLUSH_TIMEOUT_MS = 100; -// Minimum interval between repeated warnings while a host stall persists. -static constexpr uint32_t LOG_THROTTLE_MS = 1000; - static USBCDCACMInstance *get_instance_by_itf(int itf) { if (global_usb_cdc_component == nullptr) { return nullptr; @@ -278,41 +275,15 @@ void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { // Write data to TX ring buffer BaseType_t send_res = xRingbufferSend(this->usb_tx_ringbuf_, data, len, 0); if (send_res != pdTRUE) { - // During a sustained host stall the ring buffer stays full (that is the intended - // backpressure), so this path runs for every write; throttle the warning so the - // log stays readable. The counter is a running total that is never reset: each - // line reports all bytes dropped so far, so bytes dropped in the tail of one - // stall are still accounted for by the next line, whenever that is. It also makes - // the very first drop since boot detectable, which is logged unthrottled. - const bool first_drop = this->tx_dropped_bytes_ == 0; - this->tx_dropped_bytes_ += len; - const uint32_t now = millis(); - if (first_drop || (now - this->tx_dropped_log_ms_) >= LOG_THROTTLE_MS) { - this->tx_dropped_log_ms_ = now; - ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %" PRIu32 " bytes dropped total", this->itf_, this->tx_dropped_bytes_); - } + // A full ring buffer is the intended backpressure during a host stall + this->record_tx_drop_(len); return; } - // Notify TX task that data is available if (this->usb_tx_task_handle_ != nullptr) { xTaskNotifyGive(this->usb_tx_task_handle_); } -} - -bool USBCDCACMInstance::peek_byte(uint8_t *data) { - if (this->has_peek_) { - *data = this->peek_buffer_; - return true; - } - - if (this->read_byte(&this->peek_buffer_)) { - *data = this->peek_buffer_; - this->has_peek_ = true; - return true; - } - - return false; + this->report_tx_debug_(data, len); } bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { @@ -320,20 +291,12 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { return true; } + if (this->consume_peek_(data, len)) { + return true; + } size_t original_len = len; size_t bytes_read = 0; - // First, use the peek buffer if available - if (this->has_peek_) { - data[0] = this->peek_buffer_; - this->has_peek_ = false; - bytes_read = 1; - data++; - if (--len == 0) { // Decrement len first, then check it... - return true; // No more to read - } - } - // Read remaining bytes from RX ring buffer size_t rx_size = 0; uint8_t *buf = static_cast(xRingbufferReceiveUpTo(this->usb_rx_ringbuf_, &rx_size, 0, len)); @@ -343,6 +306,7 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { memcpy(data, buf, rx_size); vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + this->report_rx_debug_(data, rx_size); bytes_read += rx_size; data += rx_size; len -= rx_size; @@ -358,6 +322,7 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { memcpy(data, buf, rx_size); vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + this->report_rx_debug_(data, rx_size); bytes_read += rx_size; return bytes_read == original_len; @@ -421,7 +386,5 @@ uart::UARTFlushResult USBCDCACMInstance::flush() { } } -void USBCDCACMInstance::check_logger_conflict() {} - } // namespace esphome::usb_cdc_acm #endif diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp new file mode 100644 index 00000000000..ab3f0531b88 --- /dev/null +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp @@ -0,0 +1,230 @@ +#if defined(USE_ZEPHYR) +#include "usb_cdc_acm.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include +#include +#include "esphome/components/zephyr/cdc_acm.h" + +// flush() depends on how the legacy cdc_acm driver reports TX state (see uart_tx_process_()) +#ifdef CONFIG_USB_DEVICE_STACK_NEXT +#error "usb_cdc_acm supports only the legacy USB device stack (CONFIG_USB_DEVICE_STACK_NEXT must be disabled)" +#endif + +//============================================================================== +// USBCDCACMInstance Implementation +//============================================================================== +namespace esphome::usb_cdc_acm { + +ESPHOME_LOG_TAG(TAG, "usb_cdc_acm"); +static constexpr uint32_t FLUSH_TIMEOUT_MS = 500; +static constexpr uint32_t LINE_STATE_POLL_MS = 100; + +void USBCDCACMInstance::uart_tx_process_() { + uint8_t *data; + uint32_t send_len = ring_buf_get_claim(&this->tx_ringbuf_, &data, UINT32_MAX); + if (send_len == 0) { + // Our ring buffer is empty. Here (tx_ready set) cdc_acm reports the free space in its own + // TX ring buffer. tx_ready is cleared before each USB transfer and set again when it + // completes, so a fully free driver buffer means the host has received everything. + if (uart_irq_tx_ready(this->uart_dev_) == CONFIG_USB_CDC_ACM_RINGBUF_SIZE) { + this->tx_drained_ = true; + } + uart_irq_tx_disable(this->uart_dev_); + return; + } + int sent = uart_fifo_fill(this->uart_dev_, data, send_len); + if (sent <= 0) { + // No progress: the USB device is suspended (host asleep, hub power save) or not configured. + // fill() then returns 0 without clearing tx_ready, and irq_update() always returns 1 for + // cdc_acm, so the IRQ handler would loop forever on the cooperative USB work queue and + // block the main loop. Disabling TX ends that loop, because irq_is_pending() only reports + // TX while the TX IRQ is enabled. loop() turns it back on while data is waiting. + ring_buf_get_finish(&this->tx_ringbuf_, 0); + uart_irq_tx_disable(this->uart_dev_); + this->tx_irq_disabled_ = true; + return; + } + ring_buf_get_finish(&this->tx_ringbuf_, sent); +} + +void USBCDCACMInstance::uart_rx_process_() { + uint8_t *data; + uint32_t recv_len = ring_buf_put_claim(&this->rx_ringbuf_, &data, UINT32_MAX); + if (recv_len == 0) { + // Ring buffer full: disable RX IRQ so the ISR stops firing. read_array() re-enables it + // once it has freed space. + uart_irq_rx_disable(this->uart_dev_); + this->rx_irq_disabled_ = true; + return; + } + int rx = uart_fifo_read(this->uart_dev_, data, recv_len); + if (rx < 0) { + ESP_LOGE(TAG, "Failed to read UART FIFO, err %d", rx); + } + ring_buf_put_finish(&this->rx_ringbuf_, rx > 0 ? rx : 0); +} + +void USBCDCACMInstance::uart_irq_handler(const device *dev, void *instance) { + USBCDCACMInstance *thiz = static_cast(instance); + while (uart_irq_update(dev) && uart_irq_is_pending(dev)) { + if (uart_irq_rx_ready(dev)) { + thiz->uart_rx_process_(); + } + + // No early exit here: pending RX data must still be read. The loop ends once neither + // RX nor TX is pending (both process functions disable their IRQ when they cannot progress). + if (uart_irq_tx_ready(dev)) { + thiz->uart_tx_process_(); + } + } +} + +void USBCDCACMInstance::setup() { + if (!device_is_ready(this->uart_dev_)) { + ESP_LOGE(TAG, "UART Bus %s: is not ready.", this->uart_dev_->name); + this->get_parent()->mark_failed(); + return; + } + usb_enable(nullptr); + ring_buf_init(&this->rx_ringbuf_, ESPHOME_CDC_RX_RING_BUFFER_SIZE, this->rx_ringbuf_data_); + ring_buf_init(&this->tx_ringbuf_, ESPHOME_CDC_TX_RING_BUFFER_SIZE, this->tx_ringbuf_data_); + // The driver only reports the bit rate; the frame format is fixed on USB + this->data_bits_ = 8; + this->stop_bits_ = 1; +#if defined(CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT) + zephyr::global_cdc_acm->add_on_rate_callback([this](const device *dev, uint32_t bit_rate) { + if (dev == this->uart_dev_) { + // Queue event for processing in main loop + this->queue_line_coding_event(bit_rate, this->stop_bits_, this->parity_, this->data_bits_); + } + }); +#endif + uart_irq_callback_user_data_set(this->uart_dev_, uart_irq_handler, this); + + uart_irq_rx_enable(this->uart_dev_); +} + +void USBCDCACMInstance::loop() { + // Retry TX stopped by uart_tx_process_() (e.g. after the host resumes from suspend) + if (this->tx_irq_disabled_ && !ring_buf_is_empty(&this->tx_ringbuf_)) { + this->tx_irq_disabled_ = false; + uart_irq_tx_enable(this->uart_dev_); + } + // Line state is polled here on the main loop, so it needs no event queue + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_line_poll_ms_ >= LINE_STATE_POLL_MS) { + this->last_line_poll_ms_ = now; + uint32_t dtr_val = 0; + uint32_t rts_val = 0; + uart_line_ctrl_get(this->uart_dev_, UART_LINE_CTRL_DTR, &dtr_val); + uart_line_ctrl_get(this->uart_dev_, UART_LINE_CTRL_RTS, &rts_val); + const bool dtr = dtr_val != 0; + const bool rts = rts_val != 0; + if (dtr != this->dtr_) { + ESP_LOGD(TAG, "UART Bus %s: %s", this->uart_dev_->name, + dtr ? LOG_STR_LITERAL("Opened") : LOG_STR_LITERAL("Closed")); + } + if (dtr != this->dtr_ || rts != this->rts_) { + ESP_LOGV(TAG, "Line state device %s: DTR=%d, RTS=%d", this->uart_dev_->name, dtr, rts); + this->dtr_ = dtr; + this->rts_ = rts; + if (this->line_state_callback_ != nullptr) { + this->line_state_callback_(dtr, rts); + } + } + } + // Line coding changes arrive from the USB thread through the event queue + this->process_events_(); +} + +void USBCDCACMInstance::dump_config() { + ESP_LOGCONFIG(TAG, + "UART Bus %s:\n" + " Opened: %s\n" + " Baud Rate: %" PRIu32 " baud\n" + " Data Bits: %u\n" + " Parity: %s\n" + " Stop bits: %u", + this->uart_dev_->name, YESNO(this->dtr_), this->baud_rate_, this->data_bits_, + LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); +} + +size_t USBCDCACMInstance::available() { + uint32_t size = ring_buf_size_get(&this->rx_ringbuf_) + (this->has_peek_ ? 1 : 0); + ESP_LOGVV(TAG, "UART Bus %s: available %" PRIu32, this->uart_dev_->name, size); + return size; +} + +bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { + if (len == 0) { + return true; + } + if (this->available() < len) { + return false; + } + + if (this->consume_peek_(data, len)) { + return true; + } + const uint32_t got = ring_buf_get(&this->rx_ringbuf_, data, len); + // Space is free again: resume RX stopped by uart_rx_process_() + if (got > 0 && this->rx_irq_disabled_.exchange(false)) { + uart_irq_rx_enable(this->uart_dev_); + } + if (got != len) { + ESP_LOGE(TAG, "UART CDC unexpected size"); + return false; + } + this->report_rx_debug_(data, len); + return true; +} + +uart::UARTFlushResult USBCDCACMInstance::flush() { + // The driver only moves data while the host reads the port. With no host listening (DTR off) + // the data cannot drain, so do not stall the caller waiting for it. + if (!this->dtr_) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } + // flush() and write_array() both run on the main loop, so nothing is added while we wait. + // The IRQ handler sets tx_drained_ once both our ring buffer and the driver are empty. + this->tx_drained_ = false; + // A stall may have left the flag set; clear it once so a handler run during the wait is not undone. + // The handler sets it again if the device is still suspended. + this->tx_irq_disabled_ = false; + uint32_t start = millis(); + while (true) { + // Wakes the IRQ handler now if the driver is idle, or after the transfer in progress + uart_irq_tx_enable(this->uart_dev_); + // ring_buf_is_empty() is not enough: it is already true while the IRQ handler holds + // claimed bytes that it has not yet copied into the driver. + if (this->tx_drained_ && ring_buf_space_get(&this->tx_ringbuf_) == ESPHOME_CDC_TX_RING_BUFFER_SIZE) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; + } + // USB device suspended or not configured (see uart_tx_process_()): nothing will drain + if (this->tx_irq_disabled_) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } + if (millis() - start > FLUSH_TIMEOUT_MS) { + ESP_LOGW(TAG, "Flush timeout"); + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; + } + delay(1); + } +} + +void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { + if (!device_is_ready(this->uart_dev_)) { + return; + } + uint32_t recv_len = ring_buf_put(&this->tx_ringbuf_, data, len); + if (recv_len < len) { + this->record_tx_drop_(len - recv_len); + } + uart_irq_tx_enable(this->uart_dev_); + // Only report the bytes that were queued, not the dropped ones + this->report_tx_debug_(data, recv_len); +} + +} // namespace esphome::usb_cdc_acm +#endif diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 4f69f00339e..1ba3ec098cd 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,7 +1,7 @@ from collections.abc import Callable from pathlib import Path import textwrap -from typing import TypedDict +from typing import NamedTuple, TypedDict import esphome.codegen as cg import esphome.config_validation as cv @@ -68,6 +68,14 @@ class Section: ) +class CdcAcmUser(NamedTuple): + name: str + # Only writes with uart_poll_out() and never sets the IRQ callback (the logger) + write_only: bool + # Can share the port with write-only users (protocols that skip unframed text) + allow_write_only: bool + + class ZephyrData(TypedDict): board: str bootloader: str @@ -78,6 +86,8 @@ class ZephyrData(TypedDict): kconfig: str sysbuild: bool overlay_builder: list[Callable[[], str]] + # CDC ACM port number -> components that use it + cdc_acm_users: dict[int, list[CdcAcmUser]] def zephyr_set_core_data(config: ConfigType) -> None: @@ -89,6 +99,7 @@ def zephyr_set_core_data(config: ConfigType) -> None: "": "", }, # set empty to make sure that overlay is cleared after config change overlay_builder=[], + cdc_acm_users={}, extra_build_files={}, pm_static=[], kconfig="", @@ -177,6 +188,9 @@ def zephyr_to_code(config: ConfigType) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _cdc_acm_to_code(config: ConfigType) -> None: + # Runs last so these required options override the optional defaults that + # platforms (nrf52) set during their own to_code + _cdc_acm_add_ports() need_cdc_cb = zephyr_data()[KEY_PRJ_CONF][""].get( "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", (False,) )[0] @@ -205,7 +219,46 @@ def _format_prj_conf_val(value: PrjConfValueType) -> str: raise ValueError -def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: +def zephyr_claim_cdc_acm( + id: int, + user: str, + *, + write_only: bool = False, + allow_write_only: bool = False, +) -> None: + """Reserve CDC ACM port `id` for `user`. Call from FINAL_VALIDATE_SCHEMA. + + zephyr_to_code() later adds the device tree node and Kconfig options for + every claimed port, so components do not set up the port themselves. + + Components on the same port share one device, and the device has only one + IRQ callback slot: the component set up last takes all received data from + the other. So a port normally has one user. The exception is a `write_only` + user (the logger), which never sets the IRQ callback; it may share the port + with a user that sets `allow_write_only` because its protocol skips text + that is not part of its own frames (mcumgr). + """ + new = CdcAcmUser(user, write_only, allow_write_only) + users = zephyr_data()["cdc_acm_users"].setdefault(id, []) + for other in users: + if other.name == user: + return + if (new.write_only and other.allow_write_only) or ( + other.write_only and new.allow_write_only + ): + continue + raise cv.Invalid( + f"USB CDC ACM port {id} (cdc_acm_uart{id}) is used by both " + f"'{other.name}' and '{user}'. Each port can only be used by one component." + ) + users.append(new) + + +def _cdc_acm_add_ports() -> None: + """Set up every CDC ACM port claimed with zephyr_claim_cdc_acm().""" + users = zephyr_data()["cdc_acm_users"] + if not users: + return framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if CORE.is_nrf52 and framework_ver >= cv.Version(3, 2, 0): zephyr_add_prj_conf("CONFIG_USB_DEVICE_STACK_NEXT", False) @@ -217,12 +270,16 @@ def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: zephyr_add_prj_conf("USB_DEVICE_REMOTE_WAKEUP", False) # prevent logging when buffer is full zephyr_add_prj_conf("USB_CDC_ACM_LOG_LEVEL_WRN", True) - zephyr_add_overlay( + nodes = "".join( f""" - &zephyr_udc0 {{ cdc_acm_uart{id}: cdc_acm_uart{id} {{ compatible = "zephyr,cdc-acm-uart"; - }}; + }};""" + for id in sorted(users) + ) + zephyr_add_overlay( + f""" + &zephyr_udc0 {{{nodes} }}; """ ) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 44c8be9e374..5b438bbda1c 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -5,9 +5,9 @@ from esphome.components.nrf52.framework import include_west_project from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( HexValue, - zephyr_add_cdc_acm, zephyr_add_overlay, zephyr_add_prj_conf, + zephyr_claim_cdc_acm, zephyr_data, ) from esphome.components.zephyr.const import ( @@ -114,6 +114,11 @@ def _validate_bootloader(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: _validate_ble_server(config) _validate_bootloader(config) + if (uart := config[CONF_TRANSPORT].get(CONF_HARDWARE_UART)) is not None: + cdc_id = UARTS[uart][1] + if cdc_id >= 0: + # SMP frames are marked, so log text from the logger on the same port is skipped + zephyr_claim_cdc_acm(cdc_id, "zephyr_mcumgr", allow_write_only=True) FINAL_VALIDATE_SCHEMA = _final_validate @@ -160,11 +165,7 @@ async def to_code(config: ConfigType) -> None: request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: zephyr_add_prj_conf("SERIAL", True) - uart = UARTS[transport[CONF_HARDWARE_UART]] - uart_name = uart[0] - cdc_id = uart[1] - if cdc_id >= 0: - zephyr_add_cdc_acm(config, cdc_id) + uart_name = UARTS[transport[CONF_HARDWARE_UART]][0] zephyr_add_prj_conf("MCUMGR_TRANSPORT_UART", True) zephyr_add_prj_conf("BASE64", True) zephyr_add_prj_conf("CONSOLE", True) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cd0f0d39103..a5fb354199b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -761,6 +761,8 @@ // Default counts for static analysis #define ESPHOME_AREA_COUNT 10 +#define ESPHOME_CDC_RX_RING_BUFFER_SIZE 256 +#define ESPHOME_CDC_TX_RING_BUFFER_SIZE 256 #define ESPHOME_COMPONENT_COUNT 50 #define ESPHOME_DEVICE_COUNT 10 #define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 diff --git a/tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml b/tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..8657c03ecf8 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml @@ -0,0 +1,7 @@ +# Interface 0 needs CDC ACM port 0, which the logger uses by default on nRF52 +logger: + hardware_uart: UART0 + +usb_cdc_acm: + interfaces: + id: usb_cdc_acm1 diff --git a/tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml b/tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..40d58596e59 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml @@ -0,0 +1,5 @@ +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 + disabled: true + - id: usb_cdc_acm2 diff --git a/tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml b/tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..871e11705ce --- /dev/null +++ b/tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml @@ -0,0 +1,8 @@ +# Interface 0 needs CDC ACM port 0, which the logger uses by default on nRF52 +logger: + baud_rate: 0 + +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 + - id: usb_cdc_acm2 diff --git a/tests/unit_tests/test_zephyr_cdc_acm.py b/tests/unit_tests/test_zephyr_cdc_acm.py new file mode 100644 index 00000000000..8d010cd0388 --- /dev/null +++ b/tests/unit_tests/test_zephyr_cdc_acm.py @@ -0,0 +1,67 @@ +"""Tests for CDC ACM port reservation in the Zephyr component.""" + +import pytest + +from esphome.components.zephyr import CdcAcmUser, zephyr_claim_cdc_acm +from esphome.components.zephyr.const import KEY_ZEPHYR +import esphome.config_validation as cv +from esphome.core import CORE + + +@pytest.fixture(autouse=True) +def clean_zephyr_data() -> None: + CORE.data[KEY_ZEPHYR] = {"cdc_acm_users": {}} + + +def _users(id: int) -> list[str]: + return [user.name for user in CORE.data[KEY_ZEPHYR]["cdc_acm_users"][id]] + + +def test_different_ports_can_be_claimed() -> None: + zephyr_claim_cdc_acm(0, "logger", write_only=True) + zephyr_claim_cdc_acm(1, "usb_cdc_acm") + + assert _users(0) == ["logger"] + assert _users(1) == ["usb_cdc_acm"] + + +def test_same_user_can_claim_a_port_again() -> None: + zephyr_claim_cdc_acm(0, "usb_cdc_acm") + zephyr_claim_cdc_acm(0, "usb_cdc_acm") + + assert CORE.data[KEY_ZEPHYR]["cdc_acm_users"][0] == [ + CdcAcmUser("usb_cdc_acm", False, False) + ] + + +LOGGER = ("logger", {"write_only": True}) +RAW = ("usb_cdc_acm", {}) +MCUMGR = ("zephyr_mcumgr", {"allow_write_only": True}) + + +@pytest.mark.parametrize( + ("first", "second", "ok"), + [ + (LOGGER, RAW, False), + (RAW, LOGGER, False), + (LOGGER, MCUMGR, True), + (MCUMGR, LOGGER, True), + ], +) +def test_logger_sharing( + first: tuple[str, dict[str, bool]], second: tuple[str, dict[str, bool]], ok: bool +) -> None: + zephyr_claim_cdc_acm(0, first[0], **first[1]) + if not ok: + with pytest.raises(cv.Invalid, match="is used by both"): + zephyr_claim_cdc_acm(0, second[0], **second[1]) + return + zephyr_claim_cdc_acm(0, second[0], **second[1]) + assert _users(0) == [first[0], second[0]] + + +def test_two_irq_users_are_rejected_even_if_they_allow_the_logger() -> None: + zephyr_claim_cdc_acm(0, "zephyr_mcumgr", allow_write_only=True) + + with pytest.raises(cv.Invalid, match="'zephyr_mcumgr' and 'usb_cdc_acm'"): + zephyr_claim_cdc_acm(0, "usb_cdc_acm") From 4dc2b5911bd86a6a4cc9634c4e7e98a32f809938 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 18:21:04 -1000 Subject: [PATCH 490/651] [core] Move CONF_DRIVE_STRENGTH to the shared component constants (#20975) --- esphome/components/const/__init__.py | 1 + esphome/components/esp32/gpio.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 5d900bbd5b4..966db92c6fd 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -30,6 +30,7 @@ CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" +CONF_DRIVE_STRENGTH = "drive_strength" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GREEN_CHANNEL = "green_channel" diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 7753574bc85..f81584891b6 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -5,7 +5,7 @@ from typing import Any from esphome import pins import esphome.codegen as cg -from esphome.components.const import CONF_HOLD_STATE +from esphome.components.const import CONF_DRIVE_STRENGTH, CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -238,7 +238,6 @@ DRIVE_STRENGTHS = { } gpio_num_t = cg.global_ns.enum("gpio_num_t") -CONF_DRIVE_STRENGTH = "drive_strength" ESP32_PIN_SCHEMA = cv.All( pins.gpio_base_schema(ESP32InternalGPIOPin, gpio_pin_number_validator).extend( From 6d664d3fef9598588e102abd3c6be9bcbafd8160 Mon Sep 17 00:00:00 2001 From: Zhaoqi Xu Date: Sat, 17 Oct 2026 13:15:04 +0800 Subject: [PATCH 491/651] [web_server] Report numbers without a state as NA (#20973) --- esphome/components/web_server/web_server.cpp | 13 ++++++++----- esphome/components/web_server/web_server.h | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 04cd0ff43d2..48a2ce4a63b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1216,7 +1216,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); json::JsonBuilder builder; - this->number_json_(obj, obj->state, detail, builder); + this->number_json_(obj, detail, builder); send_json(request, builder); return; } @@ -1236,13 +1236,16 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM } void WebServer::number_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { - web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_STATE, builder); + auto *obj = (number::Number *) (source); + web_server->number_json_(obj, DETAIL_STATE, builder); } void WebServer::number_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { - web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_ALL, builder); + auto *obj = (number::Number *) (source); + web_server->number_json_(obj, DETAIL_ALL, builder); } -void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder) { +void WebServer::number_json_(number::Number *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); + const float value = obj->has_state() ? obj->state : NAN; const auto uom_ref = obj->get_unit_of_measurement_ref(); const int8_t accuracy = step_to_accuracy_decimals(obj->traits.get_step()); @@ -1250,7 +1253,7 @@ 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]; - JsonString val_str = linked("\"NaN\""); + JsonString val_str = linked("NaN"); JsonString state_str = linked("NA"); if (!std::isnan(value)) { value_accuracy_to_buf(val_buf, value, accuracy); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index f8ef12f9bff..d545fbf95dd 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -670,7 +670,7 @@ class WebServer final : public Component, public AsyncWebHandler { void cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_NUMBER - void number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder); + void number_json_(number::Number *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATE void date_json_(datetime::DateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); From 375900bfcde799161dd3d72f876ec195bfc930c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 19:54:11 -1000 Subject: [PATCH 492/651] [core] Add LineReader to collect lines in a fixed buffer (#20952) --- esphome/core/config.py | 5 +- esphome/core/defines.h | 1 + esphome/core/line_reader.cpp | 10 ++ esphome/core/line_reader.h | 99 ++++++++++++ script/build_helpers.py | 7 +- tests/components/README.md | 5 +- tests/components/core/__init__.py | 5 +- tests/components/core/line_reader_test.cpp | 180 +++++++++++++++++++++ tests/components/esphome/__init__.py | 4 +- tests/script/test_test_helpers.py | 73 ++++++++- tests/testing_helpers.py | 17 +- 11 files changed, 394 insertions(+), 12 deletions(-) create mode 100644 esphome/core/line_reader.cpp create mode 100644 esphome/core/line_reader.h create mode 100644 tests/components/core/line_reader_test.cpp diff --git a/esphome/core/config.py b/esphome/core/config.py index 531a0276471..667d780f71f 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -1019,7 +1019,10 @@ _filter_platform_sources = filter_source_files_from_platform( # util.cpp holds only the out-of-line MQTT connectivity helpers; without # USE_MQTT the header provides inline stubs and the file must not compile. -_filter_define_sources = filter_source_files_from_defines({"util.cpp": "USE_MQTT"}) +# line_reader.cpp compiles only for components that opt in with USE_LINE_READER. +_filter_define_sources = filter_source_files_from_defines( + {"util.cpp": "USE_MQTT", "line_reader.cpp": "USE_LINE_READER"} +) def FILTER_SOURCE_FILES() -> list[str]: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a5fb354199b..756485e0157 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -114,6 +114,7 @@ #define USE_LIGHT_GAMMA_LUT #define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL +#define USE_LINE_READER #define USE_LOCK #define USE_LOGGER #define USE_LOGGER_LEVEL_LISTENERS diff --git a/esphome/core/line_reader.cpp b/esphome/core/line_reader.cpp new file mode 100644 index 00000000000..e821dd0bc4e --- /dev/null +++ b/esphome/core/line_reader.cpp @@ -0,0 +1,10 @@ +#include "esphome/core/line_reader.h" +#include "esphome/core/log.h" + +namespace esphome { + +void log_line_dropped(const char *tag, size_t max_len) { + ESP_LOGW(tag, "Line over %u chars dropped", static_cast(max_len)); +} + +} // namespace esphome diff --git a/esphome/core/line_reader.h b/esphome/core/line_reader.h new file mode 100644 index 00000000000..201267ec3d8 --- /dev/null +++ b/esphome/core/line_reader.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include + +namespace esphome { + +/// Logs the shared "line dropped" warning; out of line to keep each caller small. Components that call it +/// (directly or via log_if_overflowed) add cg.add_define("USE_LINE_READER") so line_reader.cpp is compiled. +void log_line_dropped(const char *tag, size_t max_len); + +/// Collects bytes into NUL terminated lines of up to N - 1 characters; IGNORE_CHAR bytes are skipped and an +/// overlong line is dropped up to its terminator. +template class LineReader { + // pos_ is the length so far; N means "overflowed on this byte", N + 1 "dropping up to the terminator" + using pos_t = std::conditional_t<(N < 0xFE), uint8_t, uint16_t>; + static_assert(N >= 2 && N < 0xFFFE, "LineReader size out of range"); + static_assert(TERMINATOR_CHAR != IGNORE_CHAR, "LineReader terminator and ignore byte must differ"); + + public: + /// Feed one byte; returns the completed line (empty lines included), valid until the next call, or nullptr. + char *feed(uint8_t c) { + size_t len = 0; + return this->feed(c, len); + } + + /// As feed(c), and sets len to the line length when a line is returned. + char *feed(uint8_t c, size_t &len) { + pos_t pos = this->pos_; + if (pos == N) { + this->pos_ = pos = N + 1; + } + if (c == static_cast(IGNORE_CHAR)) { + return nullptr; + } + if (c == static_cast(TERMINATOR_CHAR)) { + this->pos_ = 0; + if (pos > N) { + return nullptr; + } + this->buf_[pos] = '\0'; + len = pos; + return this->buf_; + } + if (pos < N - 1) { + this->buf_[pos] = static_cast(c); + this->pos_ = pos + 1; + } else if (pos == N - 1) { + this->pos_ = N; + } + return nullptr; + } + + /// Length of the partial line; 0 after a line is returned and while an overlong line is dropped. + size_t length() const { return this->pos_ < N ? this->pos_ : 0; } + + /// The partial line, NUL terminated, for prompts with no line end; the first N - 1 characters on the byte + /// that overflowed, empty while the rest of that line is dropped. + const char *partial() { + this->buf_[this->end_()] = '\0'; + return this->buf_; + } + + /// True only right after the feed() that found the line too long; the rest of that line is then dropped. + bool overflowed() const { return this->pos_ == N; } + + /// Log the shared "line dropped" warning when the last feed() overflowed. + void log_if_overflowed(const char *tag) const { + if (this->overflowed()) { + log_line_dropped(tag, N - 1); + } + } + + /// Forget the partial line and start a new one with the next byte. + void reset() { this->pos_ = 0; } + + /// Start a new line with c, e.g. the byte that overflowed; c must not be TERMINATOR_CHAR or IGNORE_CHAR. + void restart_with(uint8_t c) { + this->buf_[0] = static_cast(c); + this->pos_ = 1; + } + + /// Drop everything up to and including the next terminator. + void discard_line() { this->pos_ = N + 1; } + + protected: + pos_t end_() const { + if (this->pos_ < N) { + return this->pos_; + } + return this->pos_ == N ? N - 1 : 0; + } + + char buf_[N]; + pos_t pos_{0}; +}; + +} // namespace esphome diff --git a/script/build_helpers.py b/script/build_helpers.py index 885a5a14758..c15c7118b23 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -285,10 +285,15 @@ def load_test_manifest_overrides( defines ``override_manifest(manifest)``, it is called to customise the override (e.g. ``manifest.enable_codegen()``). """ + wrapped: dict[str, ComponentManifestOverride] = {} for comp_name in components: - override = _wrap_manifest(comp_name) + # tests/components/core and tests/components/esphome both override the + # "esphome" core manifest, so they share one wrapper + manifest_name = ESPHOME_KEY if comp_name == "core" else comp_name + override = wrapped.get(manifest_name) or _wrap_manifest(manifest_name) if override is None: continue + wrapped[manifest_name] = override if "." in comp_name: domain, component = comp_name.split(".", maxsplit=1) diff --git a/tests/components/README.md b/tests/components/README.md index be5e887767e..a28e7f4f89c 100644 --- a/tests/components/README.md +++ b/tests/components/README.md @@ -69,8 +69,9 @@ Key methods: | Method | Effect | |---|---| -| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation | -| `manifest.restore()` | Clear **all** overrides, reverting every attribute to the original | +| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation; a custom `to_code` set by another override is kept | +| `manifest.add_resources(*files)` | Copy extra `FileResource` files into the build; the component's own file list is still read at build time, after `FILTER_SOURCE_FILES` sees the defines | +| `manifest.restore()` | Clear **all** overrides and added resources, reverting every attribute to the original | The function is called after `to_code` has already been set to `None`, so calling `enable_codegen()` is a deliberate opt-in. diff --git a/tests/components/core/__init__.py b/tests/components/core/__init__.py index 34ca4fbe4f0..6d2829511a7 100644 --- a/tests/components/core/__init__.py +++ b/tests/components/core/__init__.py @@ -1,8 +1,9 @@ -from tests.testing_helpers import ComponentManifestOverride +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code def override_manifest(manifest: ComponentManifestOverride) -> None: # core (esphome/core/config.py) must run its to_code during C++ test builds # because it bootstraps the fundamental application infrastructure that all # components depend on (component registration, event loop, etc.). - manifest.enable_codegen() + # USE_LINE_READER keeps line_reader.cpp in the build for its tests. + add_defines_to_code(manifest, "USE_LINE_READER") diff --git a/tests/components/core/line_reader_test.cpp b/tests/components/core/line_reader_test.cpp new file mode 100644 index 00000000000..3b2fad1ce61 --- /dev/null +++ b/tests/components/core/line_reader_test.cpp @@ -0,0 +1,180 @@ +#include +#include +#include +#include "esphome/core/line_reader.h" + +namespace esphome::core::testing { + +using Lines = std::vector; + +// Feeds a string and collects every completed line. +template static Lines feed_all(R &reader, const std::string &input) { + Lines lines; + for (char c : input) { + const char *line = reader.feed(static_cast(c)); + if (line != nullptr) { + EXPECT_EQ(reader.length(), 0u); + lines.emplace_back(line); + } + } + return lines; +} + +static_assert(sizeof(LineReader<80>) == 81); +static_assert(sizeof(LineReader<1025>) == 1028); +// 0xFD is the largest size with a one byte index; 0xFE switches to two bytes +static_assert(sizeof(LineReader<0xFD>) == 0xFE); +static_assert(sizeof(LineReader<0xFE>) == 0x100); + +TEST(LineReaderTest, CrLfLines) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "abc\r\ndef\r\n"), (Lines{"abc", "def"})); +} + +TEST(LineReaderTest, LfOnlyLines) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "abc\ndef\n"), (Lines{"abc", "def"})); +} + +TEST(LineReaderTest, CrOnlyLinesWithCrTerminator) { + LineReader<16, '\r', '\n'> reader; + EXPECT_EQ(feed_all(reader, "R1234\rR0567\r"), (Lines{"R1234", "R0567"})); + // The LF of a CRLF pair is ignored with this setup + EXPECT_EQ(feed_all(reader, "x\r\ny\r\n"), (Lines{"x", "y"})); +} + +TEST(LineReaderTest, CrInsideLineIsIgnored) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "a\rb\n"), (Lines{"ab"})); +} + +TEST(LineReaderTest, EmptyLinesAreReturned) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "\r\n\nx\n"), (Lines{"", "", "x"})); +} + +TEST(LineReaderTest, LineOfExactlyMaxLengthFits) { + LineReader<6> reader; + EXPECT_EQ(feed_all(reader, "12345\n"), (Lines{"12345"})); + EXPECT_FALSE(reader.overflowed()); +} + +TEST(LineReaderTest, OverlongLineIsDroppedUpToTerminator) { + LineReader<6> reader; + int overflow_count = 0; + Lines lines; + for (char c : std::string("123456789\r\nok\n")) { + const char *line = reader.feed(c); + if (reader.overflowed()) { + overflow_count++; + EXPECT_STREQ(reader.partial(), "12345"); + EXPECT_EQ(reader.length(), 0u); + reader.log_if_overflowed("line_reader_test"); + } + if (line != nullptr) + lines.emplace_back(line); + } + EXPECT_EQ(overflow_count, 1); + EXPECT_EQ(lines, (Lines{"ok"})); +} + +TEST(LineReaderTest, SixteenBitIndexOverflowAndRecovery) { + LineReader<300> reader; + const std::string full(299, 'a'); + EXPECT_EQ(feed_all(reader, full + "\n"), (Lines{full})); + int overflow_count = 0; + Lines lines; + for (char c : full + "bcd\r\nok\n") { + const char *line = reader.feed(c); + if (reader.overflowed()) { + overflow_count++; + EXPECT_EQ(std::string(reader.partial()), full); + } + if (line != nullptr) + lines.emplace_back(line); + } + EXPECT_EQ(overflow_count, 1); + EXPECT_EQ(lines, (Lines{"ok"})); +} + +TEST(LineReaderTest, ResetAfterOverflowRestartsAtNextByte) { + LineReader<4> reader; + Lines lines; + for (char c : std::string("abcdefg\n")) { + const char *line = reader.feed(c); + if (reader.overflowed()) + reader.reset(); + if (line != nullptr) + lines.emplace_back(line); + } + // "abc" fills the buffer, 'd' overflows and is dropped, "efg" is the next line + EXPECT_EQ(lines, (Lines{"efg"})); +} + +TEST(LineReaderTest, RestartWithKeepsOverflowByte) { + LineReader<4> reader; + Lines lines; + for (char c : std::string("abcdef\n")) { + const char *line = reader.feed(c); + if (reader.overflowed()) { + reader.restart_with(c); + } + if (line != nullptr) { + lines.emplace_back(line); + } + } + // 'd' overflows and starts the next line + EXPECT_EQ(lines, (Lines{"def"})); +} + +TEST(LineReaderTest, FeedReportsLength) { + LineReader<16> reader; + size_t len = 99; + char *line = nullptr; + for (char c : std::string("ab\rc\n")) { + line = reader.feed(c, len); + } + ASSERT_NE(line, nullptr); + EXPECT_STREQ(line, "abc"); + EXPECT_EQ(len, 3u); + EXPECT_NE(reader.feed('\n', len), nullptr); + EXPECT_EQ(len, 0u); +} + +TEST(LineReaderTest, PartialForPromptWithoutLineEnd) { + LineReader<32> reader; + EXPECT_EQ(feed_all(reader, "DFRobot:/>"), Lines{}); + EXPECT_STREQ(reader.partial(), "DFRobot:/>"); + EXPECT_EQ(reader.length(), 10u); + reader.reset(); + EXPECT_STREQ(reader.partial(), ""); + EXPECT_EQ(feed_all(reader, "sensorStart\r\n"), (Lines{"sensorStart"})); +} + +TEST(LineReaderTest, DiscardLineDropsUntilTerminator) { + LineReader<16> reader; + feed_all(reader, "half"); + reader.discard_line(); + EXPECT_EQ(reader.length(), 0u); + EXPECT_STREQ(reader.partial(), ""); + EXPECT_EQ(feed_all(reader, "rest\nnext\n"), (Lines{"next"})); +} + +TEST(LineReaderTest, CustomIgnoreByte) { + LineReader<8, '\r', static_cast(0xFF)> reader; + EXPECT_EQ(feed_all(reader, "R12\xFF" + "34\r"), + (Lines{"R1234"})); +} + +TEST(LineReaderTest, LineIsWritable) { + LineReader<16> reader; + char *line = nullptr; + for (char c : std::string("a,b\n")) + line = reader.feed(c); + ASSERT_NE(line, nullptr); + line[1] = '\0'; + EXPECT_STREQ(line, "a"); +} + +} // namespace esphome::core::testing diff --git a/tests/components/esphome/__init__.py b/tests/components/esphome/__init__.py index 713c0d5a63a..c1d86f29db4 100644 --- a/tests/components/esphome/__init__.py +++ b/tests/components/esphome/__init__.py @@ -6,7 +6,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # to_code emits the component count the application needs manifest.enable_codegen() # Only the decoder is under test; its ota platform is not in this build - manifest.resources = manifest.resources + [ + manifest.add_resources( FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.c"), FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.h"), - ] + ) diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index 4b05cab3767..d67d7071aba 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -12,7 +12,7 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve( import build_helpers # noqa: E402 -from esphome.loader import ComponentManifest # noqa: E402 +from esphome.loader import ComponentManifest, FileResource # noqa: E402 from tests.testing_helpers import ComponentManifestOverride # noqa: E402 # --------------------------------------------------------------------------- @@ -241,6 +241,77 @@ def test_load_wraps_top_level_component(tmp_path: Path) -> None: assert installed.to_code is None +def test_load_core_overrides_esphome_manifest(tmp_path: Path) -> None: + """tests/components/core/__init__.py overrides the "esphome" core manifest.""" + core_dir = tmp_path / "core" + core_dir.mkdir() + (core_dir / "__init__.py").write_text( + textwrap.dedent("""\ + def override_manifest(manifest): + manifest.dependencies = ["injected"] + """) + ) + inner = _make_component_manifest() + + with ( + patch("build_helpers.get_component", return_value=inner) as mock_get, + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["core"], tmp_path) + + mock_get.assert_called_once_with("esphome") + key, installed = mock_set.call_args[0] + assert key == "esphome" + assert installed.dependencies == ["injected"] + + +def test_load_core_and_esphome_share_one_wrapper(tmp_path: Path) -> None: + """Both test dirs override the "esphome" manifest; both overrides apply to one wrapper.""" + for name, dep in (("core", "from_core"), ("esphome", "from_esphome")): + (tmp_path / name).mkdir() + (tmp_path / name / "__init__.py").write_text( + textwrap.dedent(f"""\ + def override_manifest(manifest): + manifest.dependencies = manifest.dependencies + ["{dep}"] + """) + ) + inner = _make_component_manifest() + + with ( + patch("build_helpers.get_component", return_value=inner), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["core", "esphome"], tmp_path) + + mock_set.assert_called_once() + installed: ComponentManifestOverride = mock_set.call_args[0][1] + assert installed.dependencies == ["from_core", "from_esphome"] + + +def test_enable_codegen_keeps_custom_to_code() -> None: + """enable_codegen() lifts the suppression but keeps a to_code another override set.""" + + async def custom_to_code(config): + pass + + override = ComponentManifestOverride(_make_component_manifest()) + override.to_code = custom_to_code + override.enable_codegen() + assert override.to_code is custom_to_code + + +def test_add_resources_reads_component_resources_lazily() -> None: + """add_resources() appends to the resources computed at read time.""" + inner = MagicMock() + inner.resources = [FileResource("pkg", "a.cpp")] + override = ComponentManifestOverride(inner) + override.add_resources(FileResource("pkg", "extra.c")) + inner.resources = [FileResource("pkg", "a.cpp"), FileResource("pkg", "b.cpp")] + assert [r.resource for r in override.resources] == ["a.cpp", "b.cpp", "extra.c"] + override.restore() + assert [r.resource for r in override.resources] == ["a.cpp", "b.cpp"] + + def test_load_wraps_platform_component(tmp_path: Path) -> None: inner = _make_component_manifest() diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 23bef718694..8459a552bad 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -1,6 +1,6 @@ from typing import Any -from esphome.loader import ComponentManifest, _replace_component_manifest +from esphome.loader import ComponentManifest, FileResource, _replace_component_manifest from esphome.types import ConfigType @@ -29,12 +29,16 @@ class ComponentManifestOverride: def __init__(self, wrapped: "ComponentManifest") -> None: object.__setattr__(self, "_wrapped", wrapped) object.__setattr__(self, "_overrides", {}) + object.__setattr__(self, "_extra_resources", []) def __getattr__(self, name: str) -> Any: overrides: dict[str, Any] = object.__getattribute__(self, "_overrides") if name in overrides: return overrides[name] wrapped: ComponentManifest = object.__getattribute__(self, "_wrapped") + if name == "resources": + # Read lazily so FILTER_SOURCE_FILES sees the defines to_code added + return wrapped.resources + object.__getattribute__(self, "_extra_resources") return getattr(wrapped, name) def __setattr__(self, name: str, value: Any) -> None: @@ -48,11 +52,18 @@ class ComponentManifestOverride: custom stub) ``to_code`` to run during C++ unit test builds. """ overrides: dict[str, Any] = object.__getattribute__(self, "_overrides") - overrides.pop("to_code", None) + # Keep a custom to_code another override of the same manifest installed + if overrides.get("to_code") is None: + overrides.pop("to_code", None) + + def add_resources(self, *resources: FileResource) -> None: + """Copy extra source files into the build next to the component's own.""" + object.__getattribute__(self, "_extra_resources").extend(resources) def restore(self) -> None: - """Clear all overrides, reverting to the wrapped manifest's values.""" + """Clear all overrides and added resources, reverting to the wrapped manifest's values.""" object.__getattribute__(self, "_overrides").clear() + object.__getattribute__(self, "_extra_resources").clear() def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> None: From 4e27b2fc125933d94cb9f0362550a55a3fb99816 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 19:54:11 -1000 Subject: [PATCH 493/651] [components] Collect UART lines with LineReader (#20956) --- .../apc_proteous/apc_proteous_cover.cpp | 40 +++--- .../apc_proteous/apc_proteous_cover.h | 6 +- .../components/dfrobot_sen0609/__init__.py | 1 + .../dfrobot_sen0609/dfrobot_sen0609.cpp | 27 +--- .../dfrobot_sen0609/dfrobot_sen0609.h | 5 +- esphome/components/emontx/__init__.py | 1 + esphome/components/emontx/emontx.cpp | 67 ++++------ esphome/components/emontx/emontx.h | 8 +- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 55 ++++---- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.h | 6 +- .../components/hydreon_rgxx/hydreon_rgxx.cpp | 122 +++++++++--------- .../components/hydreon_rgxx/hydreon_rgxx.h | 9 +- esphome/components/hydreon_rgxx/sensor.py | 1 + esphome/components/sim800l/__init__.py | 1 + esphome/components/sim800l/sim800l.cpp | 26 ++-- esphome/components/sim800l/sim800l.h | 4 +- esphome/components/smt100/sensor.py | 1 + esphome/components/smt100/smt100.cpp | 31 +---- esphome/components/smt100/smt100.h | 9 +- tests/components/apc_proteous/common.h | 2 +- .../cover/apc_proteous_cover_test.cpp | 8 +- tests/components/dfrobot_sen0609/__init__.py | 14 ++ tests/components/emontx/__init__.py | 11 ++ 23 files changed, 203 insertions(+), 252 deletions(-) create mode 100644 tests/components/dfrobot_sen0609/__init__.py create mode 100644 tests/components/emontx/__init__.py diff --git a/esphome/components/apc_proteous/apc_proteous_cover.cpp b/esphome/components/apc_proteous/apc_proteous_cover.cpp index f6a8432faa0..c17773da29c 100644 --- a/esphome/components/apc_proteous/apc_proteous_cover.cpp +++ b/esphome/components/apc_proteous/apc_proteous_cover.cpp @@ -32,40 +32,35 @@ void APCProteousCover::loop() { if (!this->read_byte(&data)) { continue; } - if (data == '\r') { - if (this->rx_len_ != 0) { - this->rx_buffer_[this->rx_len_] = '\0'; - ESP_LOGVV(TAG, "rx: '%s'", this->rx_buffer_); - this->parse_response_(); - this->rx_len_ = 0; - } - } else if (data != '\n') { - if (this->rx_len_ >= MAX_RESPONSE_LEN) { - ESP_LOGVV(TAG, "rx overflow, discarding"); - this->rx_len_ = 0; - } - this->rx_buffer_[this->rx_len_++] = static_cast(data); + const char *line = this->line_reader_.feed(data); + if (this->line_reader_.overflowed()) { + // Start over from this byte, so a frame right after unterminated noise is still read + ESP_LOGVV(TAG, "rx overflow, discarding"); + this->line_reader_.restart_with(data); + } else if (line != nullptr && line[0] != '\0') { + ESP_LOGVV(TAG, "rx: '%s'", line); + this->parse_response_(line); } } } -void APCProteousCover::parse_response_() { +void APCProteousCover::parse_response_(const char *line) { // Expected format: "?s=XX" or "?x=XX" where XX is a hex value - if (this->rx_len_ < 4 || this->rx_buffer_[0] != '?' || this->rx_buffer_[2] != '=') { - ESP_LOGV(TAG, "Invalid response: %s", this->rx_buffer_); + if (line[0] != '?' || line[1] == '\0' || line[2] != '=' || line[3] == '\0') { + ESP_LOGV(TAG, "Invalid response: %s", line); return; } - const char *hex_str = this->rx_buffer_ + 3; + const char *hex_str = line + 3; char *end_ptr; uint8_t value = static_cast(strtol(hex_str, &end_ptr, 16)); if (end_ptr == hex_str) { - ESP_LOGW(TAG, "Failed to parse hex value: %s", this->rx_buffer_); + ESP_LOGW(TAG, "Failed to parse hex value: %s", line); return; } bool state_changed = false; - char type = this->rx_buffer_[1]; + char type = line[1]; if (type == 's') { // bit 0 = operating, bit 1 = direction (0 = opening, 1 = closing) CoverOperation new_operation = COVER_OPERATION_IDLE; @@ -154,11 +149,10 @@ void APCProteousCover::update() { return; } // The line is idle, so anything left in the buffer is an unterminated echo - if (this->rx_len_ != 0) { - this->rx_buffer_[this->rx_len_] = '\0'; - ESP_LOGVV(TAG, "discarding stale rx '%s' before query", this->rx_buffer_); - this->rx_len_ = 0; + if (this->line_reader_.length() != 0) { + ESP_LOGVV(TAG, "discarding stale rx '%s' before query", this->line_reader_.partial()); } + this->line_reader_.reset(); const char *query = this->query_s_next_ ? QUERY_S : QUERY_X; ESP_LOGVV(TAG, "tx: %s", query); diff --git a/esphome/components/apc_proteous/apc_proteous_cover.h b/esphome/components/apc_proteous/apc_proteous_cover.h index 03c8eb5c4d1..dd15a1a15c7 100644 --- a/esphome/components/apc_proteous/apc_proteous_cover.h +++ b/esphome/components/apc_proteous/apc_proteous_cover.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include "esphome/components/cover/cover.h" @@ -17,7 +18,7 @@ class APCProteousCover : public cover::Cover, public PollingComponent, public ua protected: void control(const cover::CoverCall &call) override; - void parse_response_(); + void parse_response_(const char *line); void send_command_(const char *cmd); void write_command_(const char *cmd, uint32_t now); void stop_cmd_(uint32_t now); @@ -43,8 +44,7 @@ class APCProteousCover : public cover::Cover, public PollingComponent, public ua const char *pending_command_{nullptr}; uint32_t pending_command_time_{0}; uint32_t last_command_tx_{0}; - char rx_buffer_[MAX_RESPONSE_LEN + 1]; - uint8_t rx_len_{0}; + LineReader line_reader_; uint8_t command_retries_{0}; bool query_s_next_{true}; }; diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py index c1d2f806377..76ca362949a 100644 --- a/esphome/components/dfrobot_sen0609/__init__.py +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -80,5 +80,6 @@ def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index 5f42d05a827..e1a1dbe89d0 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -210,30 +210,15 @@ void DFRobotSEN0609Component::loop() { } void DFRobotSEN0609Component::readline_(uint8_t c) { - if (c == '\r') - return; - if (c == '\n') { - if (!this->line_overflow_) { - this->line_[this->line_pos_] = '\0'; - this->handle_line_(this->line_); - } - this->line_overflow_ = false; - this->line_pos_ = 0; + char *line = this->line_reader_.feed(c); + if (line != nullptr) { + this->handle_line_(line); return; } - if (this->line_overflow_) - return; - if (this->line_pos_ >= MAX_LINE_LENGTH - 1) { - ESP_LOGW(TAG, "Line too long, dropped"); - this->line_overflow_ = true; - return; - } - this->line_[this->line_pos_++] = static_cast(c); + this->line_reader_.log_if_overflowed(TAG); // The prompt has no line end, and the echo of the next command follows it on the same line - if (c == '>') { - this->line_[this->line_pos_] = '\0'; - if (ESPHOME_strcmp_P(this->line_, ESPHOME_PSTR("DFRobot:/>")) == 0) - this->line_pos_ = 0; + if (c == '>' && ESPHOME_strcmp_P(this->line_reader_.partial(), ESPHOME_PSTR("DFRobot:/>")) == 0) { + this->line_reader_.reset(); } } diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h index ca70fa14943..2a017492691 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -24,6 +24,7 @@ #ifdef USE_BUTTON #include "esphome/components/button/button.h" #endif +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include @@ -188,7 +189,7 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice std::array switch_states_{}; #endif - char line_[MAX_LINE_LENGTH]; + LineReader line_reader_; char command_[MAX_COMMAND_LENGTH]{}; float response_[MAX_RESPONSE_VALUES]{}; uint32_t sent_ms_{0}; @@ -210,7 +211,6 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice RunMode mode_; Operation operation_{Operation::OPERATION_NONE}; Param param_{PARAM_COUNT}; - uint8_t line_pos_{0}; uint8_t response_count_{0}; uint8_t out_pin_level_{0}; uint8_t attempts_{0}; @@ -221,7 +221,6 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice uint8_t led_on_{0}; bool sent_led_on_{false}; #endif - bool line_overflow_{false}; bool read_failed_{false}; bool bad_frame_logged_{false}; bool echo_seen_{false}; diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 8d2351f1fad..bdb07ed4990 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -132,6 +132,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") # Initialize sensor storage with count from final_validate before any # await, so platform to_code() calls always see it initialized diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index dca5ae2131d..888b12f4bf4 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -55,7 +55,6 @@ static bool supports_lock(const char *version, const char *end) { #endif void EmonTx::setup() { - this->buffer_pos_ = 0; if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { // The line feed ends any boot noise this->write_byte('\n'); @@ -111,11 +110,14 @@ void EmonTx::loop() { 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'; + uint8_t dropped = 0; + while (this->available() > 0 && this->read_byte(&dropped)) { + } + if (dropped == '\n') { + this->line_reader_.reset(); + } else { + this->line_reader_.discard_line(); + } #ifdef USE_EMONTX_VERSION // The emonTx may have been flashed, or a request dropped, during the pause if (this->wants_version_()) @@ -129,43 +131,24 @@ void EmonTx::loop() { if (!this->read_byte(&received)) break; - if (received == '\n') { - // End of line - process the buffer - 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'; - this->buffer_pos_ = 0; - - StringRef line(this->buffer_.data(), len); - ESP_LOGD(TAG, "Received line: %s", line.c_str()); - - // Fire data callbacks for all received lines - this->data_callbacks_.call(line); - - // Check if this line is JSON (starts with '{') - if (this->buffer_[0] == '{') { - ESP_LOGV(TAG, "Line is JSON, parsing..."); - this->parse_json_(this->buffer_.data(), len); - } -#ifdef USE_EMONTX_VERSION - if (this->wants_version_()) { - this->parse_msg_number_(this->buffer_.data(), len); - this->parse_version_line_(this->buffer_.data(), len); - } -#endif - } - } else if (received == '\r' || this->discarding_line_) { - // 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; - this->discarding_line_ = true; - } else { - this->buffer_[this->buffer_pos_++] = static_cast(received); + size_t len; + char *line = this->line_reader_.feed(received, len); + this->line_reader_.log_if_overflowed(TAG); + if (line == nullptr || len == 0) { + continue; } + ESP_LOGD(TAG, "Received line: %s", line); + this->data_callbacks_.call(StringRef(line, len)); + if (line[0] == '{') { + ESP_LOGV(TAG, "Line is JSON, parsing..."); + this->parse_json_(line, len); + } +#ifdef USE_EMONTX_VERSION + if (this->wants_version_()) { + this->parse_msg_number_(line, len); + this->parse_version_line_(line, len); + } +#endif } } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index a8d91d36eb6..520e7772a06 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -4,10 +4,10 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include "esphome/components/json/json_util.h" -#include #include #ifdef USE_SENSOR @@ -19,7 +19,7 @@ namespace esphome::emontx { -/// Maximum line length in bytes (plus one byte reserved for null terminator) +/// Maximum line length in characters static constexpr size_t MAX_LINE_LENGTH = 1024; enum class LockOnReboot : uint8_t { @@ -92,18 +92,16 @@ class EmonTx final : public Component, public uart::UARTDevice { #endif LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; - uint16_t buffer_pos_{0}; std::atomic paused_{false}; bool was_paused_{false}; LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER}; - bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line #ifdef USE_EMONTX_VERSION bool lock_supported_{false}; // The last version reported is v1.2.0 or later bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed uint32_t last_msg_number_{0}; #endif - std::array buffer_{}; + LineReader line_reader_; }; } // namespace esphome::emontx diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 1d58a4317a6..5415b7af74f 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -12,9 +12,6 @@ namespace esphome::hrxl_maxsonar_wr { ESPHOME_LOG_TAG(TAG, "hrxl.maxsonar.wr.sensor"); -static const uint8_t ASCII_CR = 0x0D; -static const uint8_t ASCII_NBSP = 0xFF; -static const int MAX_DATA_LENGTH_BYTES = 6; /** * HRXL sensors output the format "R1234\r" at 6Hz @@ -25,44 +22,36 @@ static const int MAX_DATA_LENGTH_BYTES = 6; void HrxlMaxsonarWrComponent::loop() { uint8_t data; while (this->available() > 0) { - if (this->read_byte(&data)) { - buffer_ += (char) data; - this->check_buffer_(); + if (!this->read_byte(&data)) + continue; + size_t len; + const char *line = this->line_reader_.feed(data, len); + if (line != nullptr) { + ESP_LOGV(TAG, "Read from serial: %s", line); + this->handle_line_(line, len); + } else if (this->line_reader_.overflowed()) { + // No CR in time: report it and start over with the next byte + ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->line_reader_.partial()); + this->line_reader_.reset(); } } } -void HrxlMaxsonarWrComponent::check_buffer_() { - // The sensor seems to inject a rogue ASCII 255 byte from time to time. Get rid of that. - if (this->buffer_.back() == static_cast(ASCII_NBSP)) { - this->buffer_.pop_back(); +void HrxlMaxsonarWrComponent::handle_line_(const char *line, size_t len) { + if (line[0] != 'R') { + ESP_LOGW(TAG, "Invalid data read from sensor: %s", line); return; } + int millimeters = parse_number(line + 1).value_or(0); - // Stop reading at ASCII_CR. Also prevent the buffer from growing - // indefinitely if no ASCII_CR is received after MAX_DATA_LENGTH_BYTES. - if (this->buffer_.back() == static_cast(ASCII_CR) || this->buffer_.length() >= MAX_DATA_LENGTH_BYTES) { - ESP_LOGV(TAG, "Read from serial: %s", this->buffer_.c_str()); - - size_t rpos = this->buffer_.find(static_cast(ASCII_CR)); - - if (this->buffer_.length() <= MAX_DATA_LENGTH_BYTES && this->buffer_[0] == 'R' && rpos != std::string::npos) { - std::string distance = this->buffer_.substr(1, rpos - 1); - int millimeters = parse_number(distance).value_or(0); - - // XL reports in cm instead of mm and reports 3 digits instead of 4 - if (distance.length() == 3) { - millimeters = millimeters * 10; - } - - float meters = float(millimeters) / 1000.0f; - ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); - this->publish_state(meters); - } else { - ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->buffer_.c_str()); - } - this->buffer_.clear(); + // XL reports in cm instead of mm and reports 3 digits instead of 4 + if (len == 4) { + millimeters *= 10; } + + float meters = float(millimeters) / 1000.0f; + ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); + this->publish_state(meters); } void HrxlMaxsonarWrComponent::dump_config() { diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h index 36346c8293e..ada26d21e29 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::hrxl_maxsonar_wr { @@ -15,9 +16,10 @@ class HrxlMaxsonarWrComponent final : public sensor::Sensor, public Component, p void dump_config() override; protected: - void check_buffer_(); + void handle_line_(const char *line, size_t len); - std::string buffer_; + // "R1234" plus the NUL; the sensor ends each reading with CR and sometimes injects a rogue 0xFF byte + LineReader<6, '\r', static_cast(0xFF)> line_reader_; }; } // namespace esphome::hrxl_maxsonar_wr diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index a2f8fc50862..a1d6553073a 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -1,5 +1,6 @@ #include "hydreon_rgxx.h" #include "esphome/core/log.h" +#include namespace esphome::hydreon_rgxx { @@ -7,8 +8,6 @@ ESPHOME_LOG_TAG(TAG, "hydreon_rgxx.sensor"); static constexpr uint32_t REBOOT_INTERVAL_ID = 0; -static const int MAX_DATA_LENGTH_BYTES = 80; -static const uint8_t ASCII_LF = 0x0A; #define HYDREON_RGXX_COMMA , static const char *const PROTOCOL_NAMES[] = {HYDREON_RGXX_PROTOCOL_LIST(, HYDREON_RGXX_COMMA)}; static const char *const IGNORE_STRINGS[] = {HYDREON_RGXX_IGNORE_LIST(, HYDREON_RGXX_COMMA)}; @@ -111,13 +110,14 @@ void HydreonRGxxComponent::update() { void HydreonRGxxComponent::loop() { uint8_t data; while (this->available() > 0) { - if (this->read_byte(&data)) { - buffer_ += (char) data; - if (this->buffer_.back() == static_cast(ASCII_LF) || this->buffer_.length() >= MAX_DATA_LENGTH_BYTES) { - // complete line received - this->process_line_(); - this->buffer_.clear(); - } + if (!this->read_byte(&data)) { + continue; + } + size_t len; + const char *line = this->line_reader_.feed(data, len); + this->line_reader_.log_if_overflowed(TAG); + if (line != nullptr) { + this->process_line_(StringRef(line, len)); } } } @@ -156,33 +156,31 @@ void HydreonRGxxComponent::schedule_reboot_() { }); } -void HydreonRGxxComponent::process_line_() { - ESP_LOGV(TAG, "Read from serial: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); +void HydreonRGxxComponent::process_line_(StringRef line) { + ESP_LOGV(TAG, "Read from serial: %s", line.c_str()); - if (buffer_[0] == ';') { - ESP_LOGI(TAG, "Comment: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + if (line[0] == ';') { + ESP_LOGI(TAG, "Comment: %s", line.c_str()); return; } - std::string::size_type newlineposn = this->buffer_.find('\n'); - if (newlineposn <= 1) { - // allow both \r\n and \n + if (line.empty()) { ESP_LOGD(TAG, "Received empty line"); return; } - if (newlineposn <= 2) { + if (line.size() == 1) { // single character lines, such as acknowledgements - ESP_LOGD(TAG, "Received ack: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGD(TAG, "Received ack: %s", line.c_str()); return; } - if (this->buffer_.find("LensBad") != std::string::npos) { + if (line.find("LensBad") != std::string::npos) { ESP_LOGW(TAG, "Received LensBad!"); this->lens_bad_ = true; } - if (this->buffer_.find("EmSat") != std::string::npos) { + if (line.find("EmSat") != std::string::npos) { ESP_LOGW(TAG, "Received EmSat!"); this->em_sat_ = true; } - if (buffer_.starts_with("PwrDays")) { + if (line.starts_with("PwrDays")) { if (this->boot_count_ <= 0) { this->boot_count_ = 1; } else { @@ -190,7 +188,7 @@ void HydreonRGxxComponent::process_line_() { } this->cancel_interval(REBOOT_INTERVAL_ID); this->no_response_count_ = 0; - ESP_LOGI(TAG, "Boot detected: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGI(TAG, "Boot detected: %s", line.c_str()); if (this->model_ == RG15) { if (this->resolution_ == FORCE_HIGH) { @@ -211,17 +209,18 @@ void HydreonRGxxComponent::process_line_() { } return; } - if (buffer_.starts_with("SW")) { - std::string::size_type majend = this->buffer_.find('.'); - std::string::size_type endversion = this->buffer_.find(' ', 3); + if (line.starts_with("SW")) { + StringRef::size_type majend = line.find('.'); + StringRef::size_type endversion = line.find(' ', 3); if (majend == std::string::npos || endversion == std::string::npos || majend > endversion) { - ESP_LOGW(TAG, "invalid version string: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGW(TAG, "invalid version string: %s", line.c_str()); } - int major = strtol(this->buffer_.substr(3, majend - 3).c_str(), nullptr, 10); - int minor = strtol(this->buffer_.substr(majend + 1, endversion - (majend + 1)).c_str(), nullptr, 10); + // strtol stops at the '.' and the ' ' + int major = line.size() > 3 ? strtol(line.c_str() + 3, nullptr, 10) : 0; + int minor = strtol(line.c_str() + (majend == std::string::npos ? 0 : majend + 1), nullptr, 10); if (major > 10 || minor >= 1000 || minor < 0 || major < 0) { - ESP_LOGW(TAG, "invalid version: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGW(TAG, "invalid version: %s", line.c_str()); } this->sw_version_ = major * 1000 + minor; ESP_LOGI(TAG, "detected sw version %i", this->sw_version_); @@ -229,56 +228,51 @@ void HydreonRGxxComponent::process_line_() { } bool is_data_line = false; for (int i = 0; i < NUM_SENSORS; i++) { - if (this->sensors_[i] != nullptr && this->buffer_.find(PROTOCOL_NAMES[i]) != std::string::npos) { - is_data_line = true; - break; + if (this->sensors_[i] == nullptr) { + continue; } - } - if (is_data_line) { - std::string::size_type tc = this->buffer_.find("TooCold"); - this->too_cold_ |= tc != std::string::npos; - if (this->too_cold_) { - ESP_LOGD(TAG, "Received TooCold"); + StringRef::size_type n = line.find(PROTOCOL_NAMES[i]); + if (n == std::string::npos) { + continue; } - for (int i = 0; i < NUM_SENSORS; i++) { - if (this->sensors_[i] == nullptr) { - continue; - } - std::string::size_type n = this->buffer_.find(PROTOCOL_NAMES[i]); + is_data_line = true; + + if (n == line.find('t', n)) { + // The device temperature ('t') response contains both °C and °F values: + // "t 72F 22C". + // ESPHome uses only °C, only parse °C value (move past 'F'). + n = line.find('F', n); if (n == std::string::npos) { continue; } + n += 1; // move past 'F' + } else { + n += strlen(PROTOCOL_NAMES[i]); // move past protocol name + } - if (n == this->buffer_.find('t', n)) { - // The device temperature ('t') response contains both °C and °F values: - // "t 72F 22C". - // ESPHome uses only °C, only parse °C value (move past 'F'). - n = this->buffer_.find('F', n); - if (n == std::string::npos) { - continue; - } - n += 1; // move past 'F' - } else { - n += strlen(PROTOCOL_NAMES[i]); // move past protocol name - } - - // parse value, starting at str position n - float data = strtof(this->buffer_.substr(n).c_str(), nullptr); - this->sensors_[i]->publish_state(data); - ESP_LOGD(TAG, "Received %s: %f", PROTOCOL_NAMES[i], this->sensors_[i]->get_raw_state()); - this->sensors_received_ |= (1 << i); + // parse value, starting at str position n + float data = strtof(line.c_str() + n, nullptr); + this->sensors_[i]->publish_state(data); + ESP_LOGD(TAG, "Received %s: %f", PROTOCOL_NAMES[i], this->sensors_[i]->get_raw_state()); + this->sensors_received_ |= (1 << i); + } + if (is_data_line) { + StringRef::size_type tc = line.find("TooCold"); + this->too_cold_ |= tc != std::string::npos; + if (this->too_cold_) { + ESP_LOGD(TAG, "Received TooCold"); } if (this->request_temperature_ && this->num_sensors_missing_() == 1) { this->write_str("T\n"); } } else { for (const auto *ignore : IGNORE_STRINGS) { - if (buffer_.starts_with(ignore)) { - ESP_LOGI(TAG, "Ignoring %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + if (line.starts_with(ignore)) { + ESP_LOGI(TAG, "Ignoring %s", line.c_str()); return; } } - ESP_LOGI(TAG, "Got unknown line: %s", this->buffer_.c_str()); + ESP_LOGI(TAG, "Got unknown line: %s", line.c_str()); } } diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.h b/esphome/components/hydreon_rgxx/hydreon_rgxx.h index a7bde6105c6..e0fa8fc2b4a 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.h +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.h @@ -2,10 +2,12 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/string_ref.h" #include "esphome/components/sensor/sensor.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::hydreon_rgxx { @@ -55,10 +57,9 @@ class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDev void set_disable_led(bool disable_led) { this->disable_led_ = disable_led; } protected: - void process_line_(); + /// line is NUL terminated + void process_line_(StringRef line); void schedule_reboot_(); - bool buffer_starts_with_(const std::string &prefix); - bool buffer_starts_with_(const char *prefix); int num_sensors_missing_(); sensor::Sensor *sensors_[NUM_SENSORS] = {nullptr}; @@ -70,7 +71,6 @@ class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDev int16_t boot_count_ = 0; int16_t no_response_count_ = 0; - std::string buffer_; RGModel model_ = RG9; RG15Resolution resolution_ = FORCE_HIGH; @@ -81,6 +81,7 @@ class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDev bool em_sat_ = false; bool request_temperature_ = false; bool disable_led_ = false; + LineReader<80> line_reader_; // bit field showing which sensors we have received data for int sensors_received_ = -1; diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index 8e269fef9a0..222a5308ae1 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -141,6 +141,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index e20c091b75c..efc742610d9 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -67,6 +67,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index e75e5e95dd3..ba07bc3d6d3 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -426,26 +426,20 @@ void Sim800LComponent::loop() { uint8_t byte; this->read_byte(&byte); - if (this->read_pos_ == SIM800L_READ_BUFFER_LENGTH) - this->read_pos_ = 0; + ESP_LOGVV(TAG, "Buffer pos: %u %d", this->line_reader_.length(), byte); // NOLINT - ESP_LOGVV(TAG, "Buffer pos: %u %d", this->read_pos_, byte); // NOLINT - - if (byte == ASCII_CR) - continue; if (byte >= 0x7F) byte = '?'; // need to be valid utf8 string for log functions. - this->read_buffer_[this->read_pos_] = byte; - - if (this->state_ == STATE_SENDING_SMS_2 && this->read_pos_ == 0 && byte == '>') - this->read_buffer_[++this->read_pos_] = ASCII_LF; - - if (this->read_buffer_[this->read_pos_] == ASCII_LF) { - this->read_buffer_[this->read_pos_] = 0; - this->read_pos_ = 0; - this->parse_cmd_(this->read_buffer_); + const char *line = this->line_reader_.feed(byte); + if (line == nullptr && this->state_ == STATE_SENDING_SMS_2 && byte == '>' && this->line_reader_.length() == 1) { + // The send prompt has no line end + line = this->line_reader_.partial(); + this->line_reader_.reset(); + } + if (line != nullptr) { + this->parse_cmd_(line); } else { - this->read_pos_++; + this->line_reader_.log_if_overflowed(TAG); } } if (state_ == STATE_INIT && this->registered_ && diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index bbe8e14cf7e..a6f9544781d 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -10,6 +10,7 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include "esphome/core/automation.h" @@ -95,8 +96,7 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent #endif std::string sender_; std::string message_; - char read_buffer_[SIM800L_READ_BUFFER_LENGTH]; - size_t read_pos_{0}; + LineReader line_reader_; uint8_t parse_index_{0}; uint8_t watch_dog_{0}; bool expect_ack_{false}; diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index dae98bc1897..fba10eab9ae 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -80,6 +80,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 7785bbca2d7..8a8f82b8504 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -12,8 +12,13 @@ void SMT100Component::update() { void SMT100Component::loop() { while (this->available() != 0) { - if (this->readline_(this->read(), this->readline_buffer_, MAX_LINE_LENGTH) > 0) { - char *token = strtok(this->readline_buffer_, ","); + const int readch = this->read(); + if (readch <= 0) + continue; + char *line = this->line_reader_.feed(readch); + this->line_reader_.log_if_overflowed(TAG); + if (line != nullptr && line[0] != '\0') { + char *token = strtok(line, ","); if (!token) continue; int counts = (int) strtol(token, nullptr, 10); @@ -67,26 +72,4 @@ void SMT100Component::dump_config() { LOG_UPDATE_INTERVAL(this); } -int SMT100Component::readline_(int readch, char *buffer, int len) { - int rpos; - - if (readch > 0) { - switch (readch) { - case '\n': // Ignore new-lines - break; - case '\r': // Return on CR - rpos = this->readline_pos_; - this->readline_pos_ = 0; // Reset position index ready for next time - return rpos; - default: - if (this->readline_pos_ < len - 1) { - buffer[this->readline_pos_++] = readch; - buffer[this->readline_pos_] = 0; - } - } - } - // No end of line has been found, so return -1. - return -1; -} - } // namespace esphome::smt100 diff --git a/esphome/components/smt100/smt100.h b/esphome/components/smt100/smt100.h index 55977a5caf6..12641121fe6 100644 --- a/esphome/components/smt100/smt100.h +++ b/esphome/components/smt100/smt100.h @@ -2,12 +2,14 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::smt100 { class SMT100Component final : public PollingComponent, public uart::UARTDevice { - static const uint16_t MAX_LINE_LENGTH = 31; + // 33 characters plus the NUL; the longest line, "15000,80.00,100.00,-20.00,12.00", is 31 + static constexpr uint16_t LINE_BUFFER_SIZE = 34; public: SMT100Component() = default; @@ -25,10 +27,7 @@ class SMT100Component final : public PollingComponent, public uart::UARTDevice { void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } protected: - int readline_(int readch, char *buffer, int len); - - char readline_buffer_[MAX_LINE_LENGTH]{}; - int readline_pos_{0}; + LineReader line_reader_; sensor::Sensor *counts_sensor_{nullptr}; sensor::Sensor *permittivity_sensor_{nullptr}; diff --git a/tests/components/apc_proteous/common.h b/tests/components/apc_proteous/common.h index a3baa5131de..1b595104f75 100644 --- a/tests/components/apc_proteous/common.h +++ b/tests/components/apc_proteous/common.h @@ -51,9 +51,9 @@ class TestableAPCProteousCover : public APCProteousCover { using APCProteousCover::MAX_RESPONSE_LEN; using APCProteousCover::command_retries_; using APCProteousCover::last_command_tx_; + using APCProteousCover::line_reader_; using APCProteousCover::pending_command_; using APCProteousCover::pending_command_time_; - using APCProteousCover::rx_len_; using APCProteousCover::target_position_; }; diff --git a/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp index 566701338b3..3e784f06bfc 100644 --- a/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp +++ b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp @@ -111,7 +111,7 @@ TEST_F(APCProteousCoverTest, MalformedFramesAreIgnored) { EXPECT_FLOAT_EQ(this->cover_.position, 0.5f); EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); EXPECT_EQ(this->publishes_, 0); - EXPECT_EQ(this->cover_.rx_len_, 0); + EXPECT_EQ(this->cover_.line_reader_.length(), 0); } TEST_F(APCProteousCoverTest, LineFeedsAreIgnored) { @@ -134,7 +134,7 @@ TEST_F(APCProteousCoverTest, UnterminatedNoiseIsDiscarded) { EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); this->feed_("0123456789ABCDEF0123456789ABCDEF"); - EXPECT_LE(this->cover_.rx_len_, TestableAPCProteousCover::MAX_RESPONSE_LEN); + EXPECT_LE(this->cover_.line_reader_.length(), TestableAPCProteousCover::MAX_RESPONSE_LEN); } TEST_F(APCProteousCoverTest, OpenSendsOpenCommand) { @@ -405,9 +405,9 @@ TEST_F(APCProteousCoverTest, UpdateHoldsOffPollingAfterCommand) { // A command echo left without a terminator must not merge with the next response. TEST_F(APCProteousCoverTest, UpdateDiscardsStaleEcho) { this->feed_("*6"); - EXPECT_NE(this->cover_.rx_len_, 0); + EXPECT_NE(this->cover_.line_reader_.length(), 0); this->cover_.update(); - EXPECT_EQ(this->cover_.rx_len_, 0); + EXPECT_EQ(this->cover_.line_reader_.length(), 0); this->feed_("?s=01\r"); EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_OPENING); } diff --git a/tests/components/dfrobot_sen0609/__init__.py b/tests/components/dfrobot_sen0609/__init__.py new file mode 100644 index 00000000000..5cf3c18e999 --- /dev/null +++ b/tests/components/dfrobot_sen0609/__init__.py @@ -0,0 +1,14 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # One empty config entry so to_code runs and sets the define that keeps + # core/line_reader.cpp in the test build. + manifest.multi_conf = False + + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LINE_READER") + + manifest.to_code = to_code diff --git a/tests/components/emontx/__init__.py b/tests/components/emontx/__init__.py new file mode 100644 index 00000000000..143c58c8699 --- /dev/null +++ b/tests/components/emontx/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Only the define that keeps core/line_reader.cpp in the test build + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LINE_READER") + + manifest.to_code = to_code From 885f2fe5d86853de93f70f1e6ad500b3d5cf7b12 Mon Sep 17 00:00:00 2001 From: HugoPeters1024 Date: Sat, 17 Oct 2026 10:04:01 +0200 Subject: [PATCH 494/651] [kamstrup_kmp] Fix Kamstrup KMP reads with echoing optical heads (#20897) Co-authored-by: J. Nick Koston --- esphome/components/kamstrup_kmp/kamstrup_kmp.cpp | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index fcf648c31f3..396f4514cfe 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -142,6 +142,9 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { if (data > -1) { if (data == 0x40) { // start of message buffer_len = 0; + } else if (buffer_len == 0) { + // Ignore request echoes until the response starts. + continue; } buffer[buffer_len++] = (uint8_t) data; if (data == 0x0D) { @@ -161,12 +164,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { return; } - // Validate message (prefix and suffix) - if (buffer[0] != 0x40) { - ESP_LOGE(TAG, "Received invalid message (prefix mismatch received 0x%02X, expected 0x40)", buffer[0]); - return; - } - + // Only bytes from the 0x40 start byte on are stored, so just the end of the message needs checking if (buffer[buffer_len - 1] != 0x0D) { ESP_LOGE(TAG, "Received invalid message (EOM mismatch received 0x%02X, expected 0x0D)", buffer[buffer_len - 1]); return; @@ -175,7 +173,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { // Decode uint8_t msg[20] = {0}; int msg_len = 0; - for (size_t i = 1; i < buffer_len - 1; i++) { + for (size_t i = 1; i < buffer_len - 1 && msg_len < (int) sizeof(msg); i++) { if (buffer[i] == 0x1B) { msg[msg_len++] = buffer[i + 1] ^ 0xFF; i++; @@ -184,8 +182,8 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { } } - // Validate CRC - if (crc16be(msg, msg_len - 2) != encode_uint16(msg[msg_len - 2], msg[msg_len - 1])) { + // Validate CRC; a frame shorter than the CRC itself has nothing to check + if (msg_len < 2 || crc16be(msg, msg_len - 2) != encode_uint16(msg[msg_len - 2], msg[msg_len - 1])) { ESP_LOGE(TAG, "Received invalid message (CRC mismatch)"); return; } From 06b585d3733d634427abb492099d1373a58598fa Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Sat, 17 Oct 2026 10:05:21 +0200 Subject: [PATCH 495/651] [i2s_audio] Fix speaker restart loop with full_duplex (#20984) --- .../components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 73ad924fad7..026a66c1cf1 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -456,6 +456,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream } return ESP_ERR_NOT_SUPPORTED; } + this->current_stream_info_ = audio_stream_info; // The speaker task will enable the channel after preloading. return this->acquire_full_duplex_channel_(I2S_EVENT_QUEUE_COUNT); } From eafcca827429681fcd1f0ce6916e391f37b48032 Mon Sep 17 00:00:00 2001 From: Nicolas Liaudat Date: Sat, 17 Oct 2026 10:47:28 +0200 Subject: [PATCH 496/651] [mics_5524] Add datasheet model, diagnostics and advanced wiring (2/2) (#20521) 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/mics_5524/__init__.py | 66 +- esphome/components/mics_5524/coefficients.py | 166 ++++- esphome/components/mics_5524/mics_5524.cpp | 255 ++++++-- esphome/components/mics_5524/mics_5524.h | 77 ++- esphome/components/mics_5524/mics_math.h | 101 ++- esphome/components/mics_5524/sensor.py | 406 +++++++++++- esphome/core/defines.h | 1 + .../mics_5524/config/mics_5524.yaml | 26 + .../mics_5524/test_mics_5524.py | 597 +++++++++++++++--- tests/components/mics_5524/common.h | 4 + tests/components/mics_5524/common.yaml | 7 +- .../mics_5524/sensor/mics_math_test.cpp | 87 +++ .../validate-datasheet.esp32-idf.yaml | 31 + 13 files changed, 1611 insertions(+), 213 deletions(-) create mode 100644 tests/components/mics_5524/validate-datasheet.esp32-idf.yaml diff --git a/esphome/components/mics_5524/__init__.py b/esphome/components/mics_5524/__init__.py index 1b1c4111470..0fdec90a944 100644 --- a/esphome/components/mics_5524/__init__.py +++ b/esphome/components/mics_5524/__init__.py @@ -1,19 +1,83 @@ -"""MiCS-5524 gas sensor platform: the DFRobot_MICS vendor model on a voltage sampler.""" +"""MiCS-5524 gas sensor platform with the DFRobot vendor model and the datasheet fit.""" import esphome.codegen as cg from esphome.components import sensor +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32C6, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +from esphome.const import PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2 CODEOWNERS = ["@nliaudat"] DOMAIN = "mics_5524" mics_5524_ns = cg.esphome_ns.namespace("mics_5524") +micsmath_ns = mics_5524_ns.namespace("micsmath") +ConversionModel = micsmath_ns.enum("ConversionModel") MiCS5524Component = mics_5524_ns.class_("MiCS5524Component", cg.PollingComponent) MiCS5524GasSensor = mics_5524_ns.class_("MiCS5524GasSensor", sensor.Sensor) +CONF_CONVERSION = "conversion" +CONF_COEFFICIENT_A = "coefficient_a" +CONF_COEFFICIENT_B = "coefficient_b" CONF_VOLTAGE_MULTIPLIER = "voltage_multiplier" +CONF_ADC_INPUT_MAX = "adc_input_max" +CONF_ADC_PIN_MAX = "adc_pin_max" +CONF_ADC_ATTENUATION = "adc_attenuation" +CONF_R1 = "r1" +CONF_R2 = "r2" CONF_VCC = "vcc" +CONF_RL = "rl" +CONF_R0 = "r0" CONF_AIR_REFERENCE = "air_reference" CONF_SAMPLE_COUNT = "sample_count" CONF_SAMPLE_INTERVAL = "sample_interval" +CONF_PERSIST = "persist" +CONF_MIN_PPM = "min_ppm" CONF_MAX_PPM = "max_ppm" +CONF_RATIO_SENSOR = "ratio_sensor" +CONF_RS_SENSOR = "rs_sensor" +CONF_VOLTAGE_SENSOR = "voltage_sensor" + +CONVERSION_MODELS = { + "dfrobot": ConversionModel.CONVERSION_MODEL_DFROBOT, + "datasheet": ConversionModel.CONVERSION_MODEL_DATASHEET, +} + +# Divider guard limits (input max, pin max) in volts by target platform. ESP32 and RP2: VDD and +# the absolute maximum VDD + 0.3 V; a bare ESP8266 A0 measures 0 - 1 V, boards with an onboard +# divider declare more. Any other platform declares both limits. +ADC_LIMITS_V = { + PLATFORM_ESP32: (3.3, 3.6), + PLATFORM_ESP8266: (1.0, 1.0), + PLATFORM_RP2: (3.3, 3.6), +} + +# Top of the recommended input range of each ESP32 ADC attenuation, in volts, from the ESP-IDF ADC +# calibration documentation; above it the reading flattens. The ESP32 row is the most conservative +# and stands in for variants without their own row. "auto" ends up at the widest range. +_ESP32_ATTENUATION_INPUT_MAX_V = { + VARIANT_ESP32: {"0db": 0.95, "2.5db": 1.25, "6db": 1.75, "12db": 2.45}, + VARIANT_ESP32S2: {"0db": 0.75, "2.5db": 1.05, "6db": 1.3, "12db": 2.5}, + VARIANT_ESP32S3: {"0db": 0.95, "2.5db": 1.25, "6db": 1.75, "12db": 3.1}, + VARIANT_ESP32C3: {"0db": 0.75, "2.5db": 1.05, "6db": 1.3, "12db": 2.5}, + VARIANT_ESP32C6: {"0db": 0.95, "2.5db": 1.25, "6db": 1.75, "12db": 3.3}, +} + + +def esp32_attenuation_input_max(variant: str, attenuation: str) -> float: + """Largest input the internal ADC measures at the attenuation, for the variant.""" + table = _ESP32_ATTENUATION_INPUT_MAX_V.get( + variant, _ESP32_ATTENUATION_INPUT_MAX_V[VARIANT_ESP32] + ) + if attenuation in ("11db", "auto"): + attenuation = "12db" + return table[attenuation] + + +# Guard margin (V): the 3.3333 V of a 10k/20k divider still passes adc_input_max: 3.33. +ADC_LIMIT_MARGIN_V = 0.01 diff --git a/esphome/components/mics_5524/coefficients.py b/esphome/components/mics_5524/coefficients.py index cb1b0551f75..2050e982e1a 100644 --- a/esphome/components/mics_5524/coefficients.py +++ b/esphome/components/mics_5524/coefficients.py @@ -1,4 +1,4 @@ -"""Gas table of the DFRobot_MICS vendor model; the formula lives in mics_math.h.""" +"""Gas tables of both MiCS-5524 conversion models; the formulas live in mics_math.h.""" from dataclasses import dataclass @@ -10,6 +10,9 @@ from esphome.const import ( CONF_METHANE, ) +CONVERSION_DFROBOT = "dfrobot" +CONVERSION_DATASHEET = "datasheet" + @dataclass(frozen=True) class VendorCurve: @@ -22,11 +25,158 @@ class VendorCurve: max_ppm: float # upper end of the measuring range -# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges. -GASES: dict[str, VendorCurve] = { - CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), - CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), - CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), - CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), - CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), +@dataclass(frozen=True) +class DatasheetCurve: + """Coefficients of the datasheet fit ppm = a * ratio^b and its detection range.""" + + a: float + b: float + min_ppm: float # bottom of the detection range, read as 0 below it + max_ppm: float + + +@dataclass(frozen=True) +class GasDefinition: + """One gas with the constants of both models.""" + + vendor: VendorCurve + datasheet: DatasheetCurve | None + + +# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges. The CO +# datasheet fit is read off the sensitivity graph of SGX data sheet 1084 rev 8: RS/R0 falls +# from about 3.5 at 1 ppm to about 0.011 at 1000 ppm, a slope of -0.83 per decade. R0 here is +# the resistance in clean air, where the sensor sits at the 1 ppm bottom of its detection +# range, so the ratio is 1 there and ppm = ratio^-1.2. The data sheet calls its own curve +# indicative: the sensitivity factor RS(air) / RS(60 ppm CO) spans 1.2 to 50 between units. +GASES: dict[str, GasDefinition] = { + CONF_CARBON_MONOXIDE: GasDefinition( + vendor=VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + datasheet=DatasheetCurve(1.0, -1.2, 1.0, 1000.0), + ), + CONF_HYDROGEN: GasDefinition( + vendor=VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), + datasheet=None, # no published fit + ), + CONF_ETHANOL: GasDefinition( + vendor=VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), + datasheet=None, + ), + CONF_AMMONIA: GasDefinition( + vendor=VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), + datasheet=None, + ), + CONF_METHANE: GasDefinition( + vendor=VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), + datasheet=None, + ), } + + +def datasheet_gases() -> list[str]: + """Labels of the gases that ship with datasheet coefficients.""" + return [ + definition.vendor.label + for definition in GASES.values() + if definition.datasheet is not None + ] + + +@dataclass(frozen=True) +class ResolvedGas: + """Resolved gas configuration handed to the C++ component.""" + + label: str + conversion: str + p0: float # threshold (vendor model) or a (datasheet model) + p1: float # gain (vendor model) or b (datasheet model) + min_ppm: float + max_ppm: float + + +def resolve_gas( + gas_key: str, + conversion: str, + *, + a: float | None = None, + b: float | None = None, + min_ppm: float | None = None, + max_ppm: float | None = None, +) -> ResolvedGas: + """Merge the built-in curve with the YAML overrides; ValueError on a bad combination.""" + if conversion not in (CONVERSION_DFROBOT, CONVERSION_DATASHEET): + raise ValueError(f"unknown conversion '{conversion}'") + if gas_key not in GASES: + raise ValueError( + f"unknown gas '{gas_key}', expected one of: {', '.join(GASES)}" + ) + + definition = GASES[gas_key] + curve = definition.vendor + + if conversion == CONVERSION_DFROBOT: + if a is not None or b is not None: + raise ValueError( + "'coefficient_a:'/'coefficient_b:' are the coefficients of " + f"conversion: {CONVERSION_DATASHEET} - remove them or switch the " + "conversion" + ) + return ResolvedGas( + label=curve.label, + conversion=conversion, + p0=curve.threshold, + p1=curve.gain, + # The vendor floor is where it stops reporting gas, so it is the default min_ppm. + min_ppm=float(curve.min_ppm if min_ppm is None else min_ppm), + max_ppm=float(curve.max_ppm if max_ppm is None else max_ppm), + ) + + fitted = definition.datasheet + # One coefficient with the shipped other one would mix two fits. + if (a is None) != (b is None): + raise ValueError( + "set both 'coefficient_a:' and 'coefficient_b:' together, or neither " + f"(only {', '.join(datasheet_gases())} ship with built-in " + f"'conversion: {CONVERSION_DATASHEET}' coefficients)" + ) + resolved_a = ( + float(a) if a is not None else (fitted.a if fitted is not None else None) + ) + resolved_b = ( + float(b) if b is not None else (fitted.b if fitted is not None else None) + ) + if resolved_a is None or resolved_b is None: + shipped = ", ".join(datasheet_gases()) + raise ValueError( + f"{curve.label} has no built-in 'conversion: {CONVERSION_DATASHEET}' " + f"coefficients (only {shipped}) - set both 'coefficient_a:' and " + "'coefficient_b:' in the configuration" + ) + if resolved_a <= 0.0: + raise ValueError( + f"'coefficient_a:' must be positive, got {resolved_a:g} - " + "a non-positive fit cannot produce a reading" + ) + # The resistance of a MOS sensor falls as a reducing gas rises; a flat or rising fit + # would read a saturated sensor (ratio 0) as no gas. + if resolved_b >= 0.0: + raise ValueError( + f"'coefficient_b:' must be negative, got {resolved_b:g} - " + "the sensor resistance falls as the gas concentration rises" + ) + if min_ppm is None: + # A user fit has no published floor; the shipped one reads 0 below its range. + min_ppm = fitted.min_ppm if a is None and fitted is not None else 0.0 + return ResolvedGas( + label=curve.label, + conversion=conversion, + p0=resolved_a, + p1=resolved_b, + min_ppm=float(min_ppm), + # A user fit without a published range falls back to the vendor range of the gas. + max_ppm=float( + (fitted.max_ppm if fitted is not None else curve.max_ppm) + if max_ppm is None + else max_ppm + ), + ) diff --git a/esphome/components/mics_5524/mics_5524.cpp b/esphome/components/mics_5524/mics_5524.cpp index 2023661b369..bc559209afe 100644 --- a/esphome/components/mics_5524/mics_5524.cpp +++ b/esphome/components/mics_5524/mics_5524.cpp @@ -10,31 +10,90 @@ namespace esphome::mics_5524 { ESPHOME_LOG_TAG(TAG, "mics_5524"); -void MiCS5524GasSensor::publish_ratio(float ratio) { - const float ppm = micsmath::ppm_from_vendor_curve(ratio, this->curve_); +/// Added to the conversion model, so a value stored for one model is never loaded by the other. +static constexpr uint32_t REFERENCE_PREFERENCE_VERSION = 0x00000010; + +/// Log strings of one conversion model. +struct ModelStrings { + const LogString *conversion; ///< YAML value of `conversion:` + const LogString *reference; ///< name of the clean-air reference + const LogString *key; ///< YAML key of the reference +}; + +static ModelStrings model_strings(micsmath::ConversionModel model) { + if (model == micsmath::CONVERSION_MODEL_DFROBOT) + return {LOG_STR("dfrobot"), LOG_STR("air reference"), LOG_STR("air_reference")}; + return {LOG_STR("datasheet"), LOG_STR("R0"), LOG_STR("r0")}; +} + +void MiCS5524GasSensor::publish_ratio(float ratio, micsmath::ConversionModel model) { + float ppm = micsmath::ppm_from_vendor_curve(ratio, {this->p0_, this->p1_, this->min_ppm_, this->max_ppm_}); +#ifdef USE_MICS_5524_DATASHEET + // One firmware can hold a hub of each model, so the check stays inside the guard + if (model == micsmath::CONVERSION_MODEL_DATASHEET) { + ppm = + micsmath::clamp_ppm(micsmath::ppm_from_power_law(this->p0_, this->p1_, ratio), this->min_ppm_, this->max_ppm_); + } +#endif ESP_LOGD(TAG, "'%s': ratio=%.4f -> %.1f ppm", this->get_name().c_str(), ratio, ppm); this->publish_state(ppm); } void MiCS5524Component::setup() { + if (this->persist_) { + // Written once per calibration, so flash rather than the RTC memory an ESP8266 loses at power off + this->reference_pref_ = global_preferences->make_preference( + this->preference_key_ ^ (REFERENCE_PREFERENCE_VERSION + this->conversion_), true); + } + + if (this->enable_pin_ != nullptr) { + // EN of the breakout is active low; `inverted: true` makes this write switch the module on. + this->enable_pin_->setup(); + this->enable_pin_->digital_write(true); + } + if (this->has_reference_()) { - ESP_LOGI(TAG, "using the air reference from the configuration (%.4f V)", this->reference_); + ESP_LOGI(TAG, "using the %s from the configuration (%.4f) - the sensor is not calibrated at boot", + LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + } else if (this->persist_ && this->load_reference_()) { + ESP_LOGI(TAG, "restored the %s from flash (%.4f)", LOG_STR_ARG(model_strings(this->conversion_).reference), + this->reference_); } if (this->warmup_time_ > 0) { + this->warming_up_ = true; this->stop_poller(); - this->set_timeout(this->warmup_time_, [this]() { this->start_poller(); }); + this->set_timeout(this->warmup_time_, [this]() { + this->warming_up_ = false; + ESP_LOGI(TAG, "warm-up finished, readings are published from now on"); + this->start_poller(); + // A calibration waiting for the warm-up starts when one at boot would have. + if (this->calibration_pending_) { + this->schedule_calibration_( + this->calibration_delay_ > this->warmup_time_ ? this->calibration_delay_ - this->warmup_time_ : 0); + } + }); ESP_LOGI(TAG, "warm-up of %" PRIu32 " s - no readings published before that", this->warmup_time_ / 1000); } - if (this->calibration_samples_ > 0) { - // Nothing is stored across reboots; the warm-up window starts here, so the delay covers it. - const uint32_t delay = std::max(this->calibration_delay_, this->warmup_time_); - this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); - ESP_LOGI(TAG, "air reference calibration in %" PRIu32 " s - the sensor must be in clean air", delay / 1000); + if (this->calibrate_at_boot_) { + // A fixed or restored reference wins. + if (this->has_reference_()) { + ESP_LOGI(TAG, "%s already known (%.4f) - automatic calibration skipped", + LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + } else { + const uint32_t boot_delay = std::max(this->calibration_delay_, this->warmup_time_); + this->calibration_pending_ = true; + // During a warm-up the warm-up timeout starts it, at the same boot_delay. + if (!this->warming_up_) + this->schedule_calibration_(boot_delay); + ESP_LOGI(TAG, "clean-air calibration of the %s in %" PRIu32 " s - the sensor must be in clean air", + LOG_STR_ARG(model_strings(this->conversion_).reference), boot_delay / 1000); + } } else if (!this->has_reference_()) { - ESP_LOGW(TAG, "no air reference available, the PPM values stay unknown\n" - " set 'air_reference:' or enable 'calibration:'"); + ESP_LOGW(TAG, + "no %s available, the PPM values stay unknown\n set it in the configuration or enable 'calibration:'", + LOG_STR_ARG(model_strings(this->conversion_).reference)); } // The loop only samples while a clean-air calibration runs; `update()` publishes. @@ -44,17 +103,25 @@ void MiCS5524Component::setup() { void MiCS5524Component::dump_config() { ESP_LOGCONFIG(TAG, "MiCS-5524:\n" - " VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n" + " Conversion: %s, VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n" " Warm-up: %" PRIu32 " s, calibration: %s, sample spacing: %" PRIu32 " ms", - this->vcc_, this->voltage_multiplier_, static_cast(this->samples_), this->warmup_time_ / 1000, - this->calibration_samples_ > 0 ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), + LOG_STR_ARG(model_strings(this->conversion_).conversion), this->vcc_, this->voltage_multiplier_, + static_cast(this->samples_), this->warmup_time_ / 1000, + this->calibrate_at_boot_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), this->sample_interval_); LOG_UPDATE_INTERVAL(this); - if (this->has_reference_()) { - ESP_LOGCONFIG(TAG, " Air reference (vcc - V_AO): %.4f V", this->reference_); - } else { - ESP_LOGCONFIG(TAG, " Air reference: not available yet"); + LOG_PIN(" Enable pin: ", this->enable_pin_); + if (!this->is_vendor_model_()) { + ESP_LOGCONFIG(TAG, " Load resistor: %.1f kOhm", this->rl_); } + if (this->has_reference_()) { + ESP_LOGCONFIG(TAG, " %s: %.4f", LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + } else { + ESP_LOGCONFIG(TAG, " %s: not available yet", LOG_STR_ARG(model_strings(this->conversion_).reference)); + } + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Ratio", this->ratio_sensor_); + LOG_SENSOR(" ", "RS", this->rs_sensor_); #ifdef MICS_5524_GAS_COUNT for (auto *gas : this->gases_) { LOG_SENSOR(" ", "Gas", gas); @@ -71,21 +138,19 @@ float MiCS5524Component::sample_voltage_() { return (sum / static_cast(this->samples_)) * this->voltage_multiplier_; } -void MiCS5524Component::publish_ratio_(float ratio) { -#ifdef MICS_5524_GAS_COUNT - for (auto *gas : this->gases_) { - gas->publish_ratio(ratio); - } +float MiCS5524Component::model_value_(float voltage) { + float value = micsmath::air_value_from_voltage(voltage, this->vcc_); +#ifdef USE_MICS_5524_DATASHEET + if (!this->is_vendor_model_()) + value = micsmath::rs_from_voltage(voltage, this->vcc_, this->rl_); #endif -} - -float MiCS5524Component::read_air_value_() { - const float voltage = this->sample_voltage_(); - const float x = micsmath::air_value_from_voltage(voltage, this->vcc_); - if (std::isfinite(x)) - return x; - // The status flag makes this log once per fault, not once per update. - if (!this->status_has_warning()) { + if (!std::isnan(value)) { + this->input_fault_ = false; + return value; + } + // Logged once per fault; the status flag cannot tell it from a failed calibration. + if (!this->input_fault_) { + this->input_fault_ = true; ESP_LOGW(TAG, "analog output reads %.4f V with vcc %.2f V\n check the AO wiring, 'vcc:' and 'voltage_multiplier:'", voltage, this->vcc_); } @@ -93,27 +158,76 @@ float MiCS5524Component::read_air_value_() { return NAN; } +void MiCS5524Component::publish_ratio_(float ratio) { +#ifdef MICS_5524_GAS_COUNT + for (auto *gas : this->gases_) { + gas->publish_ratio(ratio, this->conversion_); + } +#endif +} + +void MiCS5524Component::publish_diagnostics_(float voltage, float ratio, float rs) { + if (this->voltage_sensor_ != nullptr) + this->voltage_sensor_->publish_state(voltage); + if (this->ratio_sensor_ != nullptr) + this->ratio_sensor_->publish_state(ratio); + // Validation rejects `rs_sensor:` under the vendor model. + if (this->rs_sensor_ != nullptr) + this->rs_sensor_->publish_state(rs); +} + void MiCS5524Component::update() { + if (!this->has_reference_() && this->voltage_sensor_ == nullptr && this->ratio_sensor_ == nullptr && + this->rs_sensor_ == nullptr) + return; + + const float voltage = this->sample_voltage_(); + const float value = this->model_value_(voltage); + float ratio = NAN; + if (this->has_reference_()) { + ratio = this->is_vendor_model_() ? micsmath::ratio_from_air_value(value, this->reference_) + : micsmath::ratio_from_rs(value, this->reference_); + } + this->publish_diagnostics_(voltage, ratio, value); + + // A failed calibration keeps its warning until the retry succeeds. + if (!std::isnan(value) && !this->calibrating_ && !this->calibration_pending_) + this->status_clear_warning(); + // Every gas starts unknown and the reference is never cleared, so there is nothing to republish. if (!this->has_reference_()) return; - const float air_value = this->read_air_value_(); - if (std::isnan(air_value)) { - this->publish_ratio_(NAN); - return; - } - this->status_clear_warning(); - - const float ratio = micsmath::ratio_from_air_value(air_value, this->reference_); - ESP_LOGD(TAG, "V_AO=%.4f V, x=%.4f, ratio=%.4f", this->vcc_ - air_value, air_value, ratio); + ESP_LOGD(TAG, "V_AO=%.4f V, ratio=%.4f", voltage, ratio); this->publish_ratio_(ratio); } -void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval) { +void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval, bool persist, + bool at_boot) { this->calibration_delay_ = delay; this->calibration_samples_ = samples; this->sample_interval_ = sample_interval; + this->persist_ = persist; + this->calibrate_at_boot_ = at_boot; +} + +void MiCS5524Component::request_calibration() { + if (this->calibrating_) { + ESP_LOGW(TAG, "calibration already running"); + return; + } + if (this->calibration_pending_) { + ESP_LOGW(TAG, "calibration already requested"); + return; + } + this->calibration_pending_ = true; + if (this->warming_up_) { + // A request must never capture an unstable reading; the warm-up timeout starts it. + ESP_LOGI(TAG, "calibration requested - waiting for the warm-up window to end"); + return; + } + this->schedule_calibration_(0); + ESP_LOGI(TAG, "calibration requested - keep the sensor in clean air"); } void MiCS5524Component::loop() { @@ -131,7 +245,12 @@ void MiCS5524Component::loop() { this->process_calibration_(); } +void MiCS5524Component::schedule_calibration_(uint32_t delay) { + this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); +} + void MiCS5524Component::begin_calibration_() { + this->calibration_pending_ = false; this->calibrating_ = true; this->calibration_last_sample_ = App.get_loop_component_start_time(); this->calibration_count_ = 0; @@ -141,14 +260,15 @@ void MiCS5524Component::begin_calibration_() { this->calibration_max_ = -INFINITY; this->enable_loop(); - ESP_LOGI(TAG, "calibrating the air reference, %" PRIu32 " samples - keep the sensor in clean air", - this->calibration_samples_); + ESP_LOGI(TAG, "calibrating the %s, %" PRIu32 " samples - keep the sensor in clean air", + LOG_STR_ARG(model_strings(this->conversion_).reference), this->calibration_samples_); } void MiCS5524Component::process_calibration_() { this->calibration_attempts_++; - const float reference = this->read_air_value_(); + // The samples are averaged in the units of the active model. + const float reference = this->model_value_(this->sample_voltage_()); if (!std::isnan(reference)) { this->calibration_min_ = std::min(this->calibration_min_, reference); this->calibration_max_ = std::max(this->calibration_max_, reference); @@ -179,28 +299,59 @@ void MiCS5524Component::finish_calibration_() { // A sensor still settling, or not in clean air, varies too much to be the reference. if (!micsmath::calibration_is_stable(this->calibration_min_, this->calibration_max_, reference)) { - ESP_LOGW( - TAG, "clean-air samples spread %.4f V around %.4f V (%.4f - %.4f V)\n the sensor was not settled in clean air", - this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, this->calibration_max_); + ESP_LOGW(TAG, "clean-air samples spread %.4f around %.4f (%.4f - %.4f)\n the sensor was not settled in clean air", + this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, + this->calibration_max_); this->schedule_calibration_retry_(); return; } this->reference_ = reference; this->status_clear_warning(); + const bool stored = this->persist_ && this->save_reference_(); + const ModelStrings strings = model_strings(this->conversion_); ESP_LOGI(TAG, - "air reference = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)\n" - " hard-code 'air_reference: %.4f' to skip the calibration at boot", - this->reference_, valid, attempts, this->reference_); + "%s = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)%s\n" + " hard-code '%s: %.4f' to skip the calibration at boot", + LOG_STR_ARG(strings.reference), this->reference_, valid, attempts, + stored ? LOG_STR_LITERAL(", stored in flash") + : this->persist_ ? LOG_STR_LITERAL("") + : LOG_STR_LITERAL(", kept until reboot"), + LOG_STR_ARG(strings.key), this->reference_); } void MiCS5524Component::schedule_calibration_retry_() { this->status_set_warning(); + this->calibration_pending_ = true; // A zero delay would log the same failure about five times per second. const uint32_t delay = std::max(this->calibration_delay_, CALIBRATION_RETRY_MIN_MS); - this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); + this->schedule_calibration_(delay); ESP_LOGW(TAG, "retrying the calibration in %" PRIu32 " s", delay / 1000); } +bool MiCS5524Component::save_reference_() { + if (!this->reference_pref_.save(&this->reference_)) { + ESP_LOGW(TAG, "could not store the %s in flash", LOG_STR_ARG(model_strings(this->conversion_).reference)); + return false; + } + // Once per calibration, so write it out now rather than at the next preferences sync + global_preferences->sync(); + ESP_LOGD(TAG, "%s %.4f saved", LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + return true; +} + +bool MiCS5524Component::load_reference_() { + float stored = 0.0f; + if (!this->reference_pref_.load(&stored)) + return false; + if (!std::isfinite(stored) || stored <= 0.0f) { + ESP_LOGW(TAG, "ignoring the stored %s %.4f, calibrating again", + LOG_STR_ARG(model_strings(this->conversion_).reference), stored); + return false; + } + this->reference_ = stored; + return true; +} + } // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_5524.h b/esphome/components/mics_5524/mics_5524.h index de7263e5d78..6fcdd5baa9f 100644 --- a/esphome/components/mics_5524/mics_5524.h +++ b/esphome/components/mics_5524/mics_5524.h @@ -6,31 +6,37 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/voltage_sampler/voltage_sampler.h" #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" #include "mics_math.h" namespace esphome::mics_5524 { -/// One gas of a MiCS-5524: converts the measured ratio with the vendor curve of that gas. +/// One gas of a MiCS-5524: converts the measured ratio with the curve of that gas. class MiCS5524GasSensor : public sensor::Sensor { public: - /// The vendor constants from coefficients.py. - MiCS5524GasSensor(float threshold, float gain, float min_ppm, float max_ppm) - : curve_{threshold, gain, min_ppm, max_ppm} {} + /// `p0`/`p1` are threshold and gain (dfrobot) or a and b (datasheet), from coefficients.py. + MiCS5524GasSensor(float p0, float p1, float min_ppm, float max_ppm) + : p0_(p0), p1_(p1), min_ppm_(min_ppm), max_ppm_(max_ppm) {} - /// Publish the concentration for `ratio`; NAN publishes `unknown`. - void publish_ratio(float ratio); + /// Publish the concentration for `ratio` with the formula of `model`; NAN publishes `unknown`. + void publish_ratio(float ratio, micsmath::ConversionModel model); - float get_min_ppm() const { return this->curve_.min_ppm; } - float get_max_ppm() const { return this->curve_.max_ppm; } + float get_min_ppm() const { return this->min_ppm_; } + float get_max_ppm() const { return this->max_ppm_; } protected: - micsmath::VendorCurve curve_; + float p0_; + float p1_; + float min_ppm_; + float max_ppm_; }; -/// The MiCS-5524 breakout: one analog output, converted for every configured gas against -/// the clean-air reference x_air from `air_reference:` or a calibration at boot. +/// The MiCS-5524 breakout: one analog output, converted for every configured gas against the +/// clean-air reference, x_air (vendor) or R0 (datasheet), from the configuration, flash or a +/// calibration at boot. class MiCS5524Component : public PollingComponent { public: /// `source` must outlive this component, which code generation guarantees. @@ -45,34 +51,57 @@ class MiCS5524Component : public PollingComponent { void register_gas_sensor(MiCS5524GasSensor *gas) { this->gases_.push_back(gas); } #endif + void set_conversion(micsmath::ConversionModel conversion) { this->conversion_ = conversion; } void set_vcc(float vcc) { this->vcc_ = vcc; } + void set_rl(float rl) { this->rl_ = rl; } void set_voltage_multiplier(float multiplier) { this->voltage_multiplier_ = multiplier; } void set_samples(uint8_t samples) { this->samples_ = samples; } void set_warmup_time(uint32_t warmup) { this->warmup_time_ = warmup; } - /// Calibrate at boot: start after `delay` ms, average `samples` readings `sample_interval` ms apart. - void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval); - void set_air_reference(float air_reference) { this->reference_ = air_reference; } + /// Base of the flash key of the stored reference, a hash of the component ID and the front end. + void set_preference_key(uint32_t key) { this->preference_key_ = key; } + /// Driven active in setup(); EN of the breakout is active low, so `inverted: true` turns that on. + void set_enable_pin(GPIOPin *pin) { this->enable_pin_ = pin; } + /// Calibration parameters: start after `delay` ms, average `samples` readings `sample_interval` ms + /// apart; `persist` keeps the result in flash, `at_boot` runs one at boot. + void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval, bool persist, bool at_boot); + /// Clean-air reference from the configuration, x_air (dfrobot) or R0 (datasheet). + void set_reference(float reference) { this->reference_ = reference; } + /// Deferred until the warm-up window ends. + void request_calibration(); + SUB_SENSOR(voltage) + SUB_SENSOR(ratio) + // RS only exists in the datasheet model. + SUB_SENSOR(rs) protected: /// Averaged, scaled output voltage (V); the conversions run back to back. float sample_voltage_(); - /// x = vcc - V_AO, or NAN (and a status warning) when the voltage is unusable. - float read_air_value_(); + /// Value of the active model that the reference is taken from, x = VCC - V_AO (vendor) or + /// RS (datasheet), or NAN (and a status warning) when the voltage is unusable. + float model_value_(float voltage); void publish_ratio_(float ratio); + void publish_diagnostics_(float voltage, float ratio, float rs); + /// Start a calibration after `delay` ms; every start goes through here. + void schedule_calibration_(uint32_t delay); void begin_calibration_(); void process_calibration_(); void finish_calibration_(); void schedule_calibration_retry_(); + /// True when the reference reached the storage. + bool save_reference_(); + bool load_reference_(); + bool is_vendor_model_() const { return this->conversion_ == micsmath::CONVERSION_MODEL_DFROBOT; } bool has_reference_() const { return this->reference_ > 0.0f; } voltage_sampler::VoltageSampler *source_{nullptr}; float vcc_{5.0f}; + float rl_{10.0f}; float voltage_multiplier_{1.0f}; uint32_t warmup_time_{0}; + GPIOPin *enable_pin_{nullptr}; uint32_t calibration_delay_{0}; - /// 0 when calibration is disabled. uint32_t calibration_samples_{0}; /// Spacing of the clean-air calibration samples (ms); the ~16 ms loop tick is the floor. uint32_t sample_interval_{20}; @@ -89,16 +118,28 @@ class MiCS5524Component : public PollingComponent { float calibration_min_{0.0f}; float calibration_max_{0.0f}; - /// Clean-air reference x_air in volts, 0 while there is none. + /// Clean-air reference, x_air in volts or R0 in kOhm, 0 while there is none. float reference_{0.0f}; + uint32_t preference_key_{0}; + ESPPreferenceObject reference_pref_; + // Sized by code generation; the host unit tests run none, so the list compiles out there. #ifdef MICS_5524_GAS_COUNT StaticVector gases_; #endif + micsmath::ConversionModel conversion_{micsmath::CONVERSION_MODEL_DFROBOT}; uint8_t samples_{4}; + bool persist_{false}; + bool calibrate_at_boot_{false}; + /// Set from setup() until the warm-up timeout fires. + bool warming_up_{false}; bool calibrating_{false}; + /// A calibration waits for its start time; `calibrating_` takes over from there. + bool calibration_pending_{false}; + /// The out-of-range warning has been logged; the next usable reading clears it. + bool input_fault_{false}; }; } // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_math.h b/esphome/components/mics_5524/mics_math.h index e66def345db..3e56cc09be5 100644 --- a/esphome/components/mics_5524/mics_math.h +++ b/esphome/components/mics_5524/mics_math.h @@ -1,12 +1,21 @@ #pragma once -// Pure math of the MiCS-5524 vendor model; the per-gas constants live in coefficients.py. -// x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain. -// No ESPHome dependencies, so the host tests cover it. +// Pure math of the two MiCS-5524 conversion models; the per-gas constants live in coefficients.py. +// dfrobot: x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain. +// datasheet: RS = (VCC * RL) / V_ao - RL, ratio = RS / R0 (RS in clean air), ppm = a * ratio^b. +// Every helper returns NaN for input that cannot produce a reading. No ESPHome dependencies, so the +// host tests cover it. #include +#include namespace esphome::mics_5524::micsmath { +/// Generated through CONVERSION_MODELS in __init__.py. +enum ConversionModel : uint8_t { + CONVERSION_MODEL_DFROBOT = 0, + CONVERSION_MODEL_DATASHEET = 1, +}; + /// Per-gas constants of the vendor model. struct VendorCurve { float threshold; ///< ratio at or below which the gas counts as detected @@ -15,24 +24,21 @@ struct VendorCurve { float max_ppm; ///< upper end of the measuring range }; -/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below 0 V (a -/// disconnected pin) and above VCC, which only a wrong vcc or voltage_multiplier produces. +// ------------------------------------------------------------------ vendor model + +/// Share of VCC at or below which the vendor model reads the output as disconnected: clean air +/// sits a volt or two below VCC, so a reading near 0 V is a wiring fault, not gas-free air. +inline constexpr float VENDOR_DEAD_INPUT_FRACTION = 0.05f; + +/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below +/// VENDOR_DEAD_INPUT_FRACTION of VCC (a disconnected pin) and above VCC, which only a wrong vcc or +/// voltage_multiplier produces. inline float air_value_from_voltage(float voltage, float vcc) { - if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= 0.0f || voltage > vcc) + if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= VENDOR_DEAD_INPUT_FRACTION * vcc || voltage > vcc) return NAN; return vcc - voltage; } -/// Largest relative spread (max - min) / mean a clean-air calibration accepts. -inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f; - -/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean. -inline bool calibration_is_stable(float min_value, float max_value, float mean) { - if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f) - return false; - return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean; -} - /// ratio = x / x_air (NaN when either value is unusable). inline float ratio_from_air_value(float x, float x_air) { if (!std::isfinite(x) || x < 0.0f || !std::isfinite(x_air) || x_air <= 0.0f) @@ -56,4 +62,67 @@ inline float ppm_from_vendor_curve(float ratio, const VendorCurve &curve) { return ppm; } +// --------------------------------------------------------------- datasheet model + +/// Largest RS the datasheet model reads, as a multiple of RL. The datasheet puts the clean-air +/// resistance of a module below about 1.5 MOhm, 150 x the usual 10k RL, so an output voltage +/// below that of this resistance is a disconnected input. +inline constexpr float MAX_RS_OVER_RL = 200.0f; + +/// Smallest V_ao the datasheet model reads: the output of RS = MAX_RS_OVER_RL * RL. +inline float datasheet_dead_input_voltage(float vcc) { return vcc / (MAX_RS_OVER_RL + 1.0f); } + +/// RS = (VCC * RL) / V - RL in kOhm: 0 at VCC (a saturated sensor). NaN above VCC, which only a +/// wrong vcc or voltage_multiplier produces, and at or below datasheet_dead_input_voltage(). +inline float rs_from_voltage(float voltage, float vcc, float rl) { + if (!std::isfinite(voltage) || !std::isfinite(vcc) || !std::isfinite(rl) || rl <= 0.0f || voltage > vcc || + voltage <= datasheet_dead_input_voltage(vcc)) + return NAN; + return vcc * rl / voltage - rl; +} + +/// ratio = RS / R0 (NaN when either value is unusable); 0 is the saturated end. +inline float ratio_from_rs(float rs, float r0) { + if (!std::isfinite(rs) || rs < 0.0f || !std::isfinite(r0) || r0 <= 0.0f) + return NAN; + return rs / r0; +} + +/// ppm = a * ratio^b; NaN for a ratio or coefficient that cannot produce a reading. Ratio 0, the +/// saturated end, gives infinity on a falling curve, which clamp_ppm turns into max_ppm. +inline float ppm_from_power_law(float a, float b, float ratio) { + if (!std::isfinite(a) || !std::isfinite(b) || !std::isfinite(ratio) || ratio < 0.0f || a <= 0.0f) + return NAN; + + const float ppm = a * powf(ratio, b); + return std::isnan(ppm) ? NAN : ppm; +} + +/// Clamp to the configured range: below min_ppm counts as not detected and reads 0, 0 itself +/// never changes, and a reading past a finite max_ppm (infinity included) is max_ppm. NaN stays +/// NaN. +inline float clamp_ppm(float ppm, float min_ppm, float max_ppm) { + if (std::isnan(ppm)) + return NAN; + if (ppm <= 0.0f) + return 0.0f; + if (std::isfinite(min_ppm) && ppm < min_ppm) + return 0.0f; + if (std::isfinite(max_ppm) && ppm > max_ppm) + return max_ppm; + return std::isfinite(ppm) ? ppm : NAN; +} + +// ----------------------------------------------------------------- calibration + +/// Largest relative spread (max - min) / mean a clean-air calibration accepts. +inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f; + +/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean. +inline bool calibration_is_stable(float min_value, float max_value, float mean) { + if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f) + return false; + return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean; +} + } // namespace esphome::mics_5524::micsmath diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py index f2b05c8da2e..7b80572a115 100644 --- a/esphome/components/mics_5524/sensor.py +++ b/esphome/components/mics_5524/sensor.py @@ -2,35 +2,85 @@ import logging +from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor, voltage_sampler +from esphome.components.adc import ATTENUATION_MODES +from esphome.components.esp32 import get_esp32_variant import esphome.config_validation as cv from esphome.const import ( + CONF_ATTENUATION, CONF_CALIBRATION, CONF_DELAY, + CONF_ENABLE_PIN, CONF_ID, + CONF_INTERNAL, + CONF_INVERTED, + CONF_NAME, + CONF_PIN, + CONF_RAW, + CONF_UPDATE_INTERVAL, CONF_VOLTAGE, + CONF_VOLTAGE_DIVIDER, CONF_WARMUP_TIME, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, + UNIT_VOLT, ) +from esphome.core import CORE +from esphome.helpers import fnv1_hash from esphome.types import ConfigType from . import ( + ADC_LIMIT_MARGIN_V, + ADC_LIMITS_V, + CONF_ADC_ATTENUATION, + CONF_ADC_INPUT_MAX, + CONF_ADC_PIN_MAX, CONF_AIR_REFERENCE, + CONF_COEFFICIENT_A, + CONF_COEFFICIENT_B, + CONF_CONVERSION, CONF_MAX_PPM, + CONF_MIN_PPM, + CONF_PERSIST, + CONF_R0, + CONF_R1, + CONF_R2, + CONF_RATIO_SENSOR, + CONF_RL, + CONF_RS_SENSOR, CONF_SAMPLE_COUNT, CONF_SAMPLE_INTERVAL, CONF_VCC, CONF_VOLTAGE_MULTIPLIER, + CONF_VOLTAGE_SENSOR, + CONVERSION_MODELS, MiCS5524Component, MiCS5524GasSensor, + esp32_attenuation_input_max, +) +from .coefficients import ( + CONVERSION_DATASHEET, + CONVERSION_DFROBOT, + GASES, + ResolvedGas, + resolve_gas, ) -from .coefficients import GASES _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["sensor", "voltage_sampler"] + +def _auto_load(config: ConfigType | None) -> list[str]: + """Load adc only for a pin: source; it has no host build, so the unit tests cannot link it.""" + if config is not None and CONF_PIN in config: + return ["sensor", "voltage_sampler", "adc"] + return ["sensor", "voltage_sampler"] + + +AUTO_LOAD = _auto_load # The main loop runs every ~16 ms, so a shorter spacing cannot sample more often. MIN_SAMPLE_INTERVAL_MS = 16 @@ -40,10 +90,135 @@ MAX_SAMPLE_COUNT = 16 _request_gas_slot = cg.slot_counter("MICS_5524_GAS_COUNT") +# The generated adc entry lives in the validated config so the ID pass sees its ID. +_KEY_GENERATED_ADC = "_generated_adc" + +DIVIDER_SCHEMA = cv.Schema( + { + cv.Required(CONF_R1): cv.positive_not_null_float, + cv.Required(CONF_R2): cv.positive_not_null_float, + } +) + + +def _build_internal_adc(config: ConfigType) -> ConfigType: + """Validate the hidden ``adc`` entry of a ``pin:`` configuration.""" + from esphome.components.adc import sensor as adc_sensor + + adc_id = f"{config[CONF_ID].id}_adc" + adc_config: ConfigType = { + CONF_ID: adc_id, + CONF_PIN: config[CONF_PIN], + CONF_NAME: f"{adc_id} voltage", + CONF_INTERNAL: True, + CONF_RAW: False, + # sample_count of the component is the only averaging layer. + adc_sensor.CONF_SAMPLES: 1, + adc_sensor.CONF_SAMPLING_MODE: "avg", + CONF_UPDATE_INTERVAL: "never", + } + if (attenuation := config.get(CONF_ADC_ATTENUATION)) is not None: + adc_config[CONF_ATTENUATION] = attenuation + elif CORE.is_esp32: + # The 0 dB default (~1 V) would clip the 3.3 V the divider guard allows. + adc_config[CONF_ATTENUATION] = "12db" + + return adc_sensor.CONFIG_SCHEMA(adc_config) + + +def _voltage_multiplier(config: ConfigType) -> float: + """Output volts per sampler volt: from voltage_divider: when set, else voltage_multiplier:.""" + if (divider := config.get(CONF_VOLTAGE_DIVIDER)) is not None: + return (divider[CONF_R1] + divider[CONF_R2]) / divider[CONF_R2] + return float(config.get(CONF_VOLTAGE_MULTIPLIER, 1.0)) + + +def _preference_key(config: ConfigType) -> int: + """Flash key of the stored reference; a new vcc, scaling or rl must not load an old value.""" + parts = [ + config[CONF_ID].id, + f"{config[CONF_VCC]:g}", + f"{_voltage_multiplier(config):g}", + ] + if (rl := config.get(CONF_RL)) is not None: + parts.append(f"{rl:g}") + return fnv1_hash(":".join(parts)) + + +def _validate_adc_keys(config: ConfigType) -> ConfigType: + """Reject the ADC keys where they would be accepted and ignored.""" + if CONF_VOLTAGE_DIVIDER not in config and CONF_PIN not in config: + for key in (CONF_ADC_INPUT_MAX, CONF_ADC_PIN_MAX): + if key in config: + raise cv.Invalid( + f"'{key}:' only checks a 'pin:' source or a 'voltage_divider:', " + "so it has no effect here", + path=[key], + ) + if CONF_ADC_ATTENUATION in config and CONF_PIN not in config: + raise cv.Invalid( + "'adc_attenuation:' only applies to the ADC of a 'pin:' source - set " + "'attenuation:' on the adc sensor behind 'voltage:' instead", + path=[CONF_ADC_ATTENUATION], + ) + return config + + +def _validate_adc_range(config: ConfigType) -> ConfigType: + """Reject wiring that puts the supply, the largest possible reading, past the sampler limits. + + A ``pin:`` source is always checked; a ``voltage:`` source only through its ``voltage_divider:``, + since the scaling of an external sampler is not known here. + """ + divider = config.get(CONF_VOLTAGE_DIVIDER) + if divider is None and CONF_PIN not in config: + return config + + supply = config[CONF_VCC] + pin_voltage = supply / _voltage_multiplier(config) + if (limits := ADC_LIMITS_V.get(CORE.target_platform)) is None: + if CONF_ADC_INPUT_MAX not in config or CONF_ADC_PIN_MAX not in config: + raise cv.Invalid( + f"no built-in ADC limits for {CORE.target_platform}: declare " + "'adc_input_max:' and 'adc_pin_max:' so the wiring can be checked" + ) + limits = (config[CONF_ADC_INPUT_MAX], config[CONF_ADC_PIN_MAX]) + default_input_max, default_pin_max = limits + if CONF_PIN in config and CORE.is_esp32: + # The attenuation picks the measuring range of the internal ADC + default_input_max = esp32_attenuation_input_max( + get_esp32_variant(), config.get(CONF_ADC_ATTENUATION, "12db") + ) + pin_max = config.get(CONF_ADC_PIN_MAX, default_pin_max) + input_max = config.get(CONF_ADC_INPUT_MAX, default_input_max) + if divider is not None: + wiring = ( + f"the divider brings {supply:g} V down to {pin_voltage:.4g} V on the pin" + ) + remedy = "raise r1/r2" + elif CONF_VOLTAGE_MULTIPLIER in config: + wiring = f"{supply:g} V scaled by 1/{config[CONF_VOLTAGE_MULTIPLIER]:g} is {pin_voltage:.4g} V on the pin" + remedy = "raise 'voltage_multiplier:' to match the wiring" + else: + wiring = f"the {supply:g} V supply reaches the pin unscaled" + remedy = ( + "add a 'voltage_divider:' (or 'voltage_multiplier:' for one already wired)" + ) + if pin_voltage > pin_max + ADC_LIMIT_MARGIN_V: + raise cv.Invalid( + f"{wiring}, above the {pin_max:g} V the ADC tolerates - {remedy}, or " + "declare 'adc_pin_max:' for a sampler that takes more" + ) + if pin_voltage > input_max + ADC_LIMIT_MARGIN_V: + raise cv.Invalid( + f"{wiring}, above the {input_max:g} V measuring range of the sampler - {remedy}, " + "or declare 'adc_input_max:' for a sampler with a wider range" + ) + return config + def _gas_schema(gas_key: str) -> cv.Schema: """Schema of one gas sub-sensor.""" - curve = GASES[gas_key] return sensor.sensor_schema( MiCS5524GasSensor, unit_of_measurement=UNIT_PARTS_PER_MILLION, @@ -52,10 +227,13 @@ def _gas_schema(gas_key: str) -> cv.Schema: icon="mdi:molecule", ).extend( { - # A ceiling above the vendor range could never be reached. - cv.Optional(CONF_MAX_PPM, default=curve.max_ppm): cv.float_range( - min=0, min_included=False, max=curve.max_ppm - ) + # No default: each conversion model has its own ceiling, resolved in resolve_gas. + cv.Optional(CONF_MAX_PPM): cv.positive_not_null_float, + cv.Optional(CONF_MIN_PPM): cv.positive_float, + cv.Inclusive( + CONF_COEFFICIENT_A, "coefficients" + ): cv.positive_not_null_float, + cv.Inclusive(CONF_COEFFICIENT_B, "coefficients"): cv.float_, } ) @@ -70,13 +248,35 @@ CALIBRATION_SCHEMA = cv.Schema( cv.positive_time_period_milliseconds, cv.Range(min=cv.TimePeriod(milliseconds=MIN_SAMPLE_INTERVAL_MS)), ), + cv.Optional(CONF_PERSIST, default=True): cv.boolean, } ) +def _resolve_gas(gas_key: str, config: ConfigType) -> ResolvedGas: + """Resolve one configured gas against the conversion model of its hub.""" + gas = config[gas_key] + return resolve_gas( + gas_key, + config[CONF_CONVERSION], + a=gas.get(CONF_COEFFICIENT_A), + b=gas.get(CONF_COEFFICIENT_B), + min_ppm=gas.get(CONF_MIN_PPM), + max_ppm=gas.get(CONF_MAX_PPM), + ) + + def _validate_config(config: ConfigType) -> ConfigType: - """Reject an air reference at or above the supply; warn when there is none.""" + """Check the reference keys, the conversion and every gas against each other.""" + conversion = config[CONF_CONVERSION] + vendor_model = conversion == CONVERSION_DFROBOT + if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: + if not vendor_model: + raise cv.Invalid( + f"'air_reference:' belongs to 'conversion: {CONVERSION_DFROBOT}', use " + f"'r0:' with 'conversion: {CONVERSION_DATASHEET}'" + ) # The reference is vcc - V_AO in clean air, so a larger one reads 0 ppm forever. if air_reference >= config[CONF_VCC]: raise cv.Invalid( @@ -84,11 +284,73 @@ def _validate_config(config: ConfigType) -> ConfigType: f"({config[CONF_VCC]:g} V): it is how far the analog output sits " "below the supply in clean air" ) - elif CONF_CALIBRATION not in config: + elif CONF_CALIBRATION not in config and CONF_R0 not in config: _LOGGER.warning( - "no air reference configured - set 'air_reference:' or 'calibration:', " - "the PPM values stay unknown until then" + "no clean-air reference configured - set '%s' or 'calibration:', or every " + "PPM value stays unknown until the sensor has been calibrated in clean air", + CONF_AIR_REFERENCE if vendor_model else CONF_R0, ) + + if vendor_model: + for key, reason in ( + (CONF_R0, f"use 'air_reference:' with 'conversion: {CONVERSION_DFROBOT}'"), + ( + CONF_RL, + ( + f"'conversion: {CONVERSION_DFROBOT}' derives everything from the " + "clean-air reference" + ), + ), + ( + CONF_RS_SENSOR, + "the vendor model never computes RS, so the sensor would stay unknown", + ), + ): + if key in config: + raise cv.Invalid( + f"'{key}:' belongs to 'conversion: {CONVERSION_DATASHEET}' - {reason}" + ) + + for gas_key, definition in GASES.items(): + if gas_key not in config: + continue + try: + resolved = _resolve_gas(gas_key, config) + except ValueError as err: + raise cv.Invalid(str(err)) from err + if resolved.max_ppm <= resolved.min_ppm: + raise cv.Invalid( + f"max_ppm ({resolved.max_ppm:g}) must be greater than min_ppm " + f"({resolved.min_ppm:g})" + ) + # Only the vendor model has a documented range to check against. + if vendor_model and resolved.max_ppm > ( + vendor_max_ppm := definition.vendor.max_ppm + ): + raise cv.Invalid( + f"max_ppm ({resolved.max_ppm:g}) is above the {resolved.label} measuring " + f"range of {vendor_max_ppm:g} ppm, so the conversion can never reach it" + ) + return config + + +def _validate_enable_pin(config: ConfigType) -> ConfigType: + """Warn about an EN pin without inverted: true, which keeps the active-low module asleep.""" + if (enable := config.get(CONF_ENABLE_PIN)) is not None and not enable.get( + CONF_INVERTED, False + ): + _LOGGER.warning( + "'enable_pin:' is driven active high, but the EN input of the DFRobot " + "MiCS-5524 breakout is active low: without 'inverted: true' the module stays " + "asleep and every reading stays at 0 V" + ) + return config + + +def _build_pin_source(config: ConfigType) -> ConfigType: + """Build the hidden adc entry inside the validated config so the ID pass sees its ID.""" + if CONF_PIN in config: + config[_KEY_GENERATED_ADC] = _build_internal_adc(config) return config @@ -96,12 +358,24 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(MiCS5524Component), - cv.Required(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler), - cv.Optional( - CONF_VOLTAGE_MULTIPLIER, default=1.0 - ): cv.positive_not_null_float, + cv.Optional(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler), + # Validated by the hidden adc entry built below. + cv.Optional(CONF_PIN): cv.valid, + cv.Optional(CONF_VOLTAGE_DIVIDER): DIVIDER_SCHEMA, + cv.Optional(CONF_ADC_INPUT_MAX): cv.positive_not_null_float, + cv.Optional(CONF_ADC_PIN_MAX): cv.positive_not_null_float, + cv.Optional(CONF_ADC_ATTENUATION): cv.one_of( + *ATTENUATION_MODES, lower=True + ), + cv.Optional(CONF_CONVERSION, default=CONVERSION_DFROBOT): cv.enum( + CONVERSION_MODELS, lower=True + ), + cv.Optional(CONF_VOLTAGE_MULTIPLIER): cv.positive_not_null_float, cv.Optional(CONF_VCC, default=5.0): cv.positive_not_null_float, + cv.Optional(CONF_RL): cv.positive_not_null_float, cv.Optional(CONF_AIR_REFERENCE): cv.positive_not_null_float, + cv.Optional(CONF_R0): cv.positive_not_null_float, + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_SAMPLE_COUNT, default=4): cv.int_range( min=1, max=MAX_SAMPLE_COUNT ), @@ -109,45 +383,111 @@ CONFIG_SCHEMA = cv.All( CONF_WARMUP_TIME, default="3min" ): cv.positive_time_period_milliseconds, cv.Optional(CONF_CALIBRATION): CALIBRATION_SCHEMA, + cv.Optional(CONF_VOLTAGE_SENSOR): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RATIO_SENSOR): sensor.sensor_schema( + accuracy_decimals=4, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RS_SENSOR): sensor.sensor_schema( + unit_of_measurement="kΩ", + accuracy_decimals=2, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), **{cv.Optional(gas_key): _gas_schema(gas_key) for gas_key in GASES}, } ).extend(cv.polling_component_schema("30s")), + cv.has_exactly_one_key(CONF_VOLTAGE, CONF_PIN), + cv.has_at_most_one_key(CONF_VOLTAGE_DIVIDER, CONF_VOLTAGE_MULTIPLIER), cv.has_at_least_one_key(*GASES), - cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_CALIBRATION), + cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_R0, CONF_CALIBRATION), + _validate_adc_keys, + _validate_adc_range, _validate_config, + _validate_enable_pin, + _build_pin_source, ) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], await cg.get_variable(config[CONF_VOLTAGE])) + source = ( + await cg.get_variable(config[CONF_VOLTAGE]) + if CONF_VOLTAGE in config + else await _create_internal_adc(config) + ) + var = cg.new_Pvariable(config[CONF_ID], source) await cg.register_component(var, config) + cg.add(var.set_conversion(config[CONF_CONVERSION])) + if config[CONF_CONVERSION] == CONVERSION_DATASHEET: + # Only a build with a datasheet model links the power law and its libm + cg.add_define("USE_MICS_5524_DATASHEET") cg.add(var.set_vcc(config[CONF_VCC])) - cg.add(var.set_voltage_multiplier(config[CONF_VOLTAGE_MULTIPLIER])) + if (rl := config.get(CONF_RL)) is not None: + cg.add(var.set_rl(rl)) + cg.add(var.set_voltage_multiplier(_voltage_multiplier(config))) cg.add(var.set_samples(config[CONF_SAMPLE_COUNT])) cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) + cg.add(var.set_preference_key(_preference_key(config))) - if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: - cg.add(var.set_air_reference(air_reference)) - elif (calibration := config.get(CONF_CALIBRATION)) is not None: - cg.add( - var.set_calibration( - calibration[CONF_DELAY], - calibration[CONF_SAMPLE_COUNT], - calibration[CONF_SAMPLE_INTERVAL], - ) + if (enable := config.get(CONF_ENABLE_PIN)) is not None: + cg.add(var.set_enable_pin(await cg.gpio_pin_expression(enable))) + if (reference := config.get(CONF_AIR_REFERENCE, config.get(CONF_R0))) is not None: + cg.add(var.set_reference(reference)) + # A request on a hub without `calibration:` uses the schema defaults, never persisted. + calibration = config.get(CONF_CALIBRATION) + at_boot = calibration is not None + if calibration is None: + calibration = CALIBRATION_SCHEMA({}) + cg.add( + var.set_calibration( + calibration[CONF_DELAY], + calibration[CONF_SAMPLE_COUNT], + calibration[CONF_SAMPLE_INTERVAL], + at_boot and calibration[CONF_PERSIST], + at_boot, ) + ) sensors = sensor.sub_sensors(config) - for gas_key, curve in GASES.items(): - if (gas_config := config.get(gas_key)) is None: + await sensors(CONF_VOLTAGE_SENSOR, var.set_voltage_sensor) + await sensors(CONF_RATIO_SENSOR, var.set_ratio_sensor) + await sensors(CONF_RS_SENSOR, var.set_rs_sensor) + + for gas_key in GASES: + if gas_key not in config: continue + resolved = _resolve_gas(gas_key, config) _request_gas_slot(str(var)) await sensors( gas_key, var.register_gas_sensor, - curve.threshold, - curve.gain, - curve.min_ppm, - gas_config[CONF_MAX_PPM], + resolved.p0, + resolved.p1, + resolved.min_ppm, + resolved.max_ppm, ) + + +async def _create_internal_adc(config: ConfigType) -> cg.MockObj: + """Create the ``adc`` sensor of a ``pin:`` configuration and return it.""" + from esphome.components.adc import sensor as adc_sensor + + adc_config = config[_KEY_GENERATED_ADC] + await adc_sensor.to_code(adc_config) + return await cg.get_variable(adc_config[CONF_ID]) + + +CALIBRATION_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(MiCS5524Component)} +) + +automation.register_apply_action( + "mics_5524.request_calibration", + CALIBRATION_ACTION_SCHEMA, + automation.ApplyCall("request_calibration()"), +) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 756485e0157..97677334828 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 +#define USE_MICS_5524_DATASHEET #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 #define NFC_TAG_LISTENER_COUNT 1 diff --git a/tests/component_tests/mics_5524/config/mics_5524.yaml b/tests/component_tests/mics_5524/config/mics_5524.yaml index 7538fb973e1..3a5f2459dcf 100644 --- a/tests/component_tests/mics_5524/config/mics_5524.yaml +++ b/tests/component_tests/mics_5524/config/mics_5524.yaml @@ -35,3 +35,29 @@ sensor: hydrogen: id: mics_5524_h2 name: MiCS-5524 H2 + + # The datasheet model on the ADC this component owns: the 10k/20k divider puts 3.33 V on + # the pin, so it needs adc_input_max: 3.34 and the 12 dB attenuation it gets by default. + - platform: mics_5524 + id: mics_5524_pin + pin: GPIO32 + voltage_divider: + r1: 10000 + r2: 20000 + adc_input_max: 3.34 + conversion: datasheet + r0: 10.0 + # Diagnostics of this reading, declared by the hub itself. + ratio_sensor: + id: mics_5524_ratio + name: MiCS-5524 ratio + rs_sensor: + id: mics_5524_rs + name: MiCS-5524 RS + voltage_sensor: + id: mics_5524_volts + name: MiCS-5524 volts + carbon_monoxide: + id: mics_5524_co_ds + name: MiCS-5524 CO (datasheet) + max_ppm: 2000.0 diff --git a/tests/component_tests/mics_5524/test_mics_5524.py b/tests/component_tests/mics_5524/test_mics_5524.py index 53dc2f49708..1ab5d9ae1a3 100644 --- a/tests/component_tests/mics_5524/test_mics_5524.py +++ b/tests/component_tests/mics_5524/test_mics_5524.py @@ -2,19 +2,49 @@ from collections.abc import Callable from pathlib import Path +import re import pytest from esphome import config_validation as cv -from esphome.components.mics_5524.coefficients import GASES, VendorCurve -from esphome.components.mics_5524.sensor import CONFIG_SCHEMA +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mics_5524.coefficients import ( + GASES, + DatasheetCurve, + GasDefinition, + VendorCurve, + datasheet_gases, +) +from esphome.components.mics_5524.sensor import CONFIG_SCHEMA, _voltage_multiplier from esphome.const import ( CONF_AMMONIA, CONF_CARBON_MONOXIDE, CONF_ETHANOL, CONF_HYDROGEN, CONF_METHANE, + PlatformFramework, ) +from esphome.helpers import fnv1_hash +from tests.component_tests.types import SetCoreConfigCallable + + +def _gas_arguments(main_cpp: str, variable: str) -> list[str]: + """Arguments of the constructor call that creates one gas sub-sensor.""" + match = re.search( + rf"new\({variable}\) mics_5524::MiCS5524GasSensor\((.*?)\);", + main_cpp, + ) + assert match is not None, f"{variable} was not constructed" + return [argument.strip() for argument in match.group(1).split(",")] + + +def _gas_constants(arguments: list[str]) -> list[float]: + """The numeric arguments of a constructor call, read as floats. + + Comparing numbers rather than their text keeps the test about the values and not + about how code generation happens to format them. + """ + return [float(argument.removesuffix("f")) for argument in arguments] def test_voltage_source_is_a_constructor_argument( @@ -37,21 +67,19 @@ def test_every_gas_becomes_a_sub_sensor_of_its_hub( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The curve is passed to the constructor and the sub-sensor to its own hub.""" + """The constants are passed to the constructor and the sub-sensor to its own hub.""" main_cpp = generate_main(component_config_path("mics_5524.yaml")) - # Threshold, gain, minimum and maximum ppm of the gas. - assert ( - "new(mics_5524_co) mics_5524::MiCS5524GasSensor(0.425f, " - "0.000405f, 1.0f, 500.0f);" in main_cpp - ) + # The vendor threshold and gain and the measuring range; the model lives on the hub. + co = _gas_arguments(main_cpp, "mics_5524_co") + assert _gas_constants(co) == [0.425, 0.000405, 1.0, 500.0] assert "mics_5524_boot->register_gas_sensor(mics_5524_co);" in main_cpp + # A gas without `max_ppm:` keeps the measuring range of the vendor. - assert ( - "new(mics_5524_ch4) mics_5524::MiCS5524GasSensor(0.786f, " - "2.3e-05f, 1000.0f, 25000.0f);" in main_cpp - ) + ch4 = _gas_arguments(main_cpp, "mics_5524_ch4") + assert _gas_constants(ch4) == [0.786, 0.000023, 1000.0, 25000.0] assert "mics_5524_boot->register_gas_sensor(mics_5524_ch4);" in main_cpp + # The gas belongs to the sensor it is configured under, not to the first one. assert "mics_5524_fixed->register_gas_sensor(mics_5524_h2);" in main_cpp assert "mics_5524_boot->register_gas_sensor(mics_5524_h2);" not in main_cpp @@ -64,106 +92,184 @@ def test_shared_options_reach_the_hub( """The analog front end is configured once per physical sensor.""" main_cpp = generate_main(component_config_path("mics_5524.yaml")) + assert ( + "mics_5524_boot->set_conversion(mics_5524::micsmath::CONVERSION_MODEL_DFROBOT);" + in main_cpp + ) assert "mics_5524_boot->set_vcc(5.0f);" in main_cpp + # `rl:` is not configured, so the C++ default stands. + assert "mics_5524_boot->set_rl(" not in main_cpp assert "mics_5524_boot->set_voltage_multiplier(1.5f);" in main_cpp assert "mics_5524_boot->set_samples(3);" in main_cpp assert "mics_5524_boot->set_warmup_time(180000);" in main_cpp - assert "mics_5524_boot->set_calibration(5000, 3, 20);" in main_cpp - assert "mics_5524_fixed->set_air_reference(3.6f);" in main_cpp + # The stored reference is keyed by the component ID and the analog front end, so a + # new vcc, scaling or rl never loads a value captured with the old one. + assert ( + f"mics_5524_boot->set_preference_key({fnv1_hash('mics_5524_boot:5:1.5')}UL);" + in main_cpp + ) + assert ( + f"mics_5524_pin->set_preference_key({fnv1_hash('mics_5524_pin:5:1.5')}UL);" + in main_cpp + ) + assert "mics_5524_boot->set_calibration(5000, 3, 20, true, true);" in main_cpp + assert "mics_5524_fixed->set_reference(3.6f);" in main_cpp # `air_reference:` and `calibration:` exclude each other, so the sensor with the - # hard-coded reference is never calibrated at boot. - assert "mics_5524_fixed->set_calibration(" not in main_cpp + # hard-coded reference only gets the defaults for a requested calibration. + assert "mics_5524_fixed->set_calibration(60000, 10, 20, false, false);" in main_cpp + + +def test_datasheet_conversion_uses_the_shipped_coefficients( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """One conversion model per sensor, and the coefficients of CO are the shipped ones.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + # a = 1.0, b = -1.2, the 1 ppm floor of the data sheet and the configured 2000 ppm ceiling. + co = _gas_arguments(main_cpp, "mics_5524_co_ds") + assert _gas_constants(co) == [1.0, -1.2, 1.0, 2000.0] + assert "mics_5524_pin->register_gas_sensor(mics_5524_co_ds);" in main_cpp + assert ( + "mics_5524_pin->set_conversion(mics_5524::micsmath::CONVERSION_MODEL_DATASHEET);" + in main_cpp + ) + assert "mics_5524_pin->set_reference(10.0f);" in main_cpp + + +def test_diagnostics_reach_the_hub( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The ratio, RS and voltage of every reading can be published to their own sensor.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert "mics_5524_pin->set_ratio_sensor(mics_5524_ratio);" in main_cpp + assert "mics_5524_pin->set_rs_sensor(mics_5524_rs);" in main_cpp + assert "mics_5524_pin->set_voltage_sensor(mics_5524_volts);" in main_cpp + + +def test_pin_creates_an_internal_adc( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A `pin:` configuration owns its ADC, and the divider is part of the reading.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert "new(mics_5524_pin_adc) adc::ADCSensor();" in main_cpp + # Without `adc_attenuation:` an ESP32 gets 12 dB, since the 0 dB default clips at about 1 V. + assert ( + "mics_5524_pin_adc->set_attenuation(adc::ADC_ATTEN_DB_12_COMPAT);" in main_cpp + ) + assert ( + "new(mics_5524_pin) mics_5524::MiCS5524Component(mics_5524_pin_adc);" + in main_cpp + ) + # 10k/20k under the 5 V of the breakout: 3.33 V on the pin, x1.5 in the reading. + assert "mics_5524_pin->set_voltage_multiplier(1.5f);" in main_cpp + + +def _hub(air_reference: float | None = 3.6, **overrides: object) -> dict: + """A minimal valid hub configuration with one hydrogen sub-sensor.""" + config: dict = { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "vcc": 5.0, + "hydrogen": {"name": "MiCS-5524 H2"}, + } + if air_reference is not None: + config["air_reference"] = air_reference + for key, value in overrides.items(): + if value is None: + config.pop(key, None) + else: + config[key] = value + return config def test_air_reference_and_calibration_exclude_each_other() -> None: """Two references for one sensor would silently pick one, so it is an error.""" with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): - CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, - "air_reference": 3.6, - "calibration": {"delay": "5s"}, - } - ) + CONFIG_SCHEMA(_hub(calibration={"delay": "5s"})) @pytest.mark.parametrize("air_reference", [5.0, 6.0]) def test_air_reference_at_or_above_vcc_is_rejected(air_reference: float) -> None: - """A reference at or above the supply would report every gas as 0 ppm forever.""" with pytest.raises(cv.Invalid, match="must be below vcc"): - CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "vcc": 5.0, - "hydrogen": {"name": "MiCS-5524 H2 (reference above vcc)"}, - "air_reference": air_reference, - } - ) + CONFIG_SCHEMA(_hub(air_reference=air_reference)) def test_air_reference_below_vcc_is_accepted() -> None: - config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "vcc": 5.0, - "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, - "air_reference": 3.6, - } - ) - - assert config["air_reference"] == 3.6 + assert CONFIG_SCHEMA(_hub())["air_reference"] == 3.6 def test_at_least_one_gas_is_required() -> None: """A sensor without a gas has nothing to publish.""" with pytest.raises(cv.Invalid, match="Must contain at least one of"): - CONFIG_SCHEMA({"id": "mics_5524", "voltage": "mics_5524_adc", "vcc": 5.0}) - - -def test_max_ppm_above_the_vendor_range_is_rejected() -> None: - """A ceiling the conversion can never reach is a configuration error.""" - with pytest.raises(cv.Invalid, match="value must be at most 1000.0"): CONFIG_SCHEMA( { "id": "mics_5524", "voltage": "mics_5524_adc", - "carbon_monoxide": { - "name": "MiCS-5524 CO (rejected)", - "max_ppm": 2000.0, - }, + "vcc": 5.0, "air_reference": 3.6, } ) +def test_the_sampler_is_given_or_owned() -> None: + """One source only: an existing sampler, or a pin the component reads itself.""" + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA( + {"id": "mics_5524", "vcc": 5.0, "hydrogen": {}, "air_reference": 3.6} + ) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_hub(pin=34)) + + +def test_max_ppm_above_the_vendor_range_is_rejected() -> None: + """A ceiling the conversion can never reach is a configuration error.""" + with pytest.raises(cv.Invalid, match="above the CO measuring range"): + CONFIG_SCHEMA( + _hub(carbon_monoxide={"name": "MiCS-5524 CO (rejected)", "max_ppm": 2000.0}) + ) + + def test_max_ppm_at_the_vendor_range_is_accepted() -> None: config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "carbon_monoxide": {"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0}, - "air_reference": 3.6, - } + _hub(carbon_monoxide={"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0}) ) assert config["carbon_monoxide"]["max_ppm"] == 1000.0 -def test_max_ppm_defaults_to_the_vendor_range_of_the_gas() -> None: - config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "carbon_monoxide": {"name": "MiCS-5524 CO (default)"}, - "air_reference": 3.6, - } - ) +def test_max_ppm_defaults_to_the_vendor_range_of_the_gas( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """An omitted ceiling still reports the vendor range: it is resolved per model.""" + from esphome.components.mics_5524.coefficients import resolve_gas - assert config["carbon_monoxide"]["max_ppm"] == 1000.0 + resolved = resolve_gas("carbon_monoxide", "dfrobot", max_ppm=None) + assert resolved.max_ppm == 1000.0 + + # End to end: CH4 of the boot sensor has no max_ppm: and keeps 25000 ppm. + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + ch4 = _gas_arguments(main_cpp, "mics_5524_ch4") + assert _gas_constants(ch4)[-1] == 25000.0 + + +def test_max_ppm_must_stay_above_min_ppm() -> None: + with pytest.raises(cv.Invalid, match="must be greater than min_ppm"): + CONFIG_SCHEMA( + _hub( + carbon_monoxide={ + "name": "MiCS-5524 CO (narrow)", + "min_ppm": 100.0, + "max_ppm": 50.0, + } + ) + ) def test_calibration_cannot_sample_faster_than_the_loop() -> None: @@ -173,6 +279,7 @@ def test_calibration_cannot_sample_faster_than_the_loop() -> None: { "id": "mics_5524", "voltage": "mics_5524_adc", + "vcc": 5.0, "hydrogen": {"name": "MiCS-5524 H2 (tick)"}, "calibration": {"sample_interval": "10ms"}, } @@ -180,26 +287,348 @@ def test_calibration_cannot_sample_faster_than_the_loop() -> None: def test_voltage_multiplier_defaults_to_one() -> None: + config = CONFIG_SCHEMA(_hub()) + + assert _voltage_multiplier(config) == 1.0 + + +def test_voltage_divider_derives_the_multiplier( + set_core_config: SetCoreConfigCallable, +) -> None: + """The divider the user wired is turned into the multiplier of every reading.""" + set_core_config(PlatformFramework.ESP32_IDF) config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "hydrogen": {"name": "MiCS-5524 H2 (default)"}, - "air_reference": 3.6, - } + _hub(voltage_divider={"r1": 10000.0, "r2": 20000.0}, adc_input_max=3.34) ) - assert config["voltage_multiplier"] == 1.0 + assert _voltage_multiplier(config) == 1.5 + + +def test_voltage_divider_rejects_an_explicit_multiplier( + set_core_config: SetCoreConfigCallable, +) -> None: + """The divider already says what the multiplier is, so the two would contradict.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + voltage_multiplier=1.5, + adc_input_max=3.34, + ) + ) + + +def test_voltage_divider_above_the_adc_limit_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A 10k/20k divider on a 5 V sensor puts 3.33 V on a classic ESP32 ADC.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="above the 3.3 V measuring range"): + CONFIG_SCHEMA(_hub(voltage_divider={"r1": 10000.0, "r2": 20000.0})) + + +def test_pin_source_must_be_scaled_into_the_adc_range( + set_core_config: SetCoreConfigCallable, +) -> None: + """A 5 V sensor straight on an ESP32 pin clips; a divider or multiplier that fits passes.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + with pytest.raises(cv.Invalid, match="5 V supply reaches the pin unscaled"): + CONFIG_SCHEMA(_hub(voltage=None, pin=34)) + # Each hub needs its own entity names + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_multiplier=1.5, + adc_input_max=3.34, + hydrogen={"name": "H2 behind a multiplier"}, + ) + ) + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + hydrogen={"name": "H2 behind a divider"}, + ) + ) + + +def test_pin_source_range_follows_the_attenuation( + set_core_config: SetCoreConfigCallable, +) -> None: + """At 6 dB a classic ESP32 measures up to 1.75 V, so 3.33 V on the pin is rejected.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + with pytest.raises(cv.Invalid, match="above the 1.75 V measuring range"): + CONFIG_SCHEMA( + _hub(voltage=None, pin=34, voltage_multiplier=1.5, adc_attenuation="6db") + ) + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_multiplier=3.0, + adc_attenuation="6db", + hydrogen={"name": "H2 at 6 dB"}, + ) + ) + # auto picks the widest range, 2.45 V on a classic ESP32 + with pytest.raises(cv.Invalid, match="above the 2.45 V measuring range"): + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_multiplier=1.5, + adc_attenuation="auto", + hydrogen={"name": "H2 auto"}, + ) + ) + + +def test_min_ppm_zero_disables_the_floor() -> None: + config = CONFIG_SCHEMA(_hub(hydrogen={"name": "MiCS-5524 H2", "min_ppm": 0.0})) + assert config["hydrogen"]["min_ppm"] == 0.0 + + +def test_voltage_divider_limits_follow_the_esp8266_adc( + set_core_config: SetCoreConfigCallable, +) -> None: + """A bare ESP8266 A0 reads 0 - 1 V, so the ESP32 limits must not apply there.""" + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="above the 1 V"): + CONFIG_SCHEMA(_hub(voltage_divider={"r1": 10000.0, "r2": 20000.0})) + config = CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + adc_pin_max=3.6, + hydrogen={"name": "MiCS-5524 H2 (declared limits)"}, + ) + ) + assert config["voltage_divider"]["r1"] == 10000.0 + + +def test_voltage_divider_below_the_declared_limit_is_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """A sampler with a wider range declares it, and the same divider then passes.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + adc_pin_max=3.4, + ) + ) + + assert config["voltage_divider"]["r1"] == 10000.0 + + +def test_voltage_divider_on_an_unknown_platform_needs_declared_limits( + set_core_config: SetCoreConfigCallable, +) -> None: + """No built-in ADC limits exist for LibreTiny, so the guard cannot run without them.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + with pytest.raises(cv.Invalid, match="no built-in ADC limits for bk72xx"): + CONFIG_SCHEMA(_hub(voltage_divider={"r1": 10000.0, "r2": 20000.0})) + config = CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + adc_pin_max=3.6, + hydrogen={"name": "MiCS-5524 H2 (libretiny)"}, + ) + ) + assert config["voltage_divider"]["r1"] == 10000.0 + + +@pytest.mark.parametrize("key", ["adc_input_max", "adc_pin_max"]) +def test_adc_limits_without_a_divider_are_rejected(key: str) -> None: + """The limits only check a divider, so without one they would be silently ignored.""" + with pytest.raises( + cv.Invalid, + match=f"'{key}:' only checks a 'pin:' source or a 'voltage_divider:'", + ): + CONFIG_SCHEMA(_hub(**{key: 3.34})) + + +def test_adc_attenuation_needs_an_owned_pin() -> None: + """The attenuation configures the hidden ADC of pin:, not an external sampler.""" + with pytest.raises( + cv.Invalid, match="'adc_attenuation:' only applies to the ADC of" + ): + CONFIG_SCHEMA(_hub(adc_attenuation="12db")) + + +def test_datasheet_conversion_is_not_capped_by_the_vendor_range() -> None: + """The datasheet fit has no published range, so a wider one may be configured.""" + config = CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen=None, + carbon_monoxide={"name": "MiCS-5524 CO (datasheet)", "max_ppm": 2000.0}, + ) + ) + + assert config["carbon_monoxide"]["max_ppm"] == 2000.0 + + +def test_datasheet_needs_coefficients_for_gases_without_a_shipped_fit() -> None: + with pytest.raises( + cv.Invalid, match="no built-in 'conversion: datasheet' coefficients" + ): + CONFIG_SCHEMA(_hub(air_reference=None, conversion="datasheet", r0=10.0)) + + +def test_datasheet_accepts_explicit_coefficients() -> None: + config = CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen={ + "name": "MiCS-5524 H2 (fit)", + "coefficient_a": 12.0, + "coefficient_b": -0.8, + }, + ) + ) + + assert config["hydrogen"]["coefficient_a"] == 12.0 + + +def test_coefficients_are_rejected_by_the_vendor_model() -> None: + # A pair under the vendor model is rejected by the model check ... + with pytest.raises(cv.Invalid, match="remove them or switch the conversion"): + CONFIG_SCHEMA( + _hub( + hydrogen={ + "name": "MiCS-5524 H2 (stray)", + "coefficient_a": 12.0, + "coefficient_b": -0.8, + } + ) + ) + # ... while half a pair is already rejected by the schema itself. + with pytest.raises(cv.Invalid, match="coefficients"): + CONFIG_SCHEMA( + _hub(hydrogen={"name": "MiCS-5524 H2 (half)", "coefficient_a": 12.0}) + ) + + +def test_datasheet_rejects_half_a_fit() -> None: + """One coefficient with the shipped value of the other mixes two fits.""" + with pytest.raises(cv.Invalid, match="coefficients"): + CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen=None, + carbon_monoxide={ + "name": "MiCS-5524 CO (half)", + "coefficient_a": 12.0, + }, + ) + ) + + +def test_datasheet_rejects_a_flat_or_rising_fit() -> None: + """A fit whose reading does not rise as the resistance falls reads saturation as no gas.""" + for b in (0.0, 0.8): + with pytest.raises(cv.Invalid, match="'coefficient_b:' must be negative"): + CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen={ + "name": f"MiCS-5524 H2 (fit b={b})", + "coefficient_a": 12.0, + "coefficient_b": b, + }, + ) + ) + + +def test_datasheet_min_ppm_defaults_to_the_shipped_floor_only() -> None: + """The shipped CO fit reads 0 below the 1 ppm of the data sheet; a user fit has no floor.""" + from esphome.components.mics_5524.coefficients import resolve_gas + + assert resolve_gas("carbon_monoxide", "datasheet").min_ppm == 1.0 + assert resolve_gas("carbon_monoxide", "datasheet", a=2.0, b=-1.0).min_ppm == 0.0 + assert resolve_gas("carbon_monoxide", "datasheet", min_ppm=5.0).min_ppm == 5.0 + + +def test_datasheet_rejects_a_non_positive_coefficient() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen={ + "name": "MiCS-5524 H2 (bad fit)", + "coefficient_a": 0.0, + "coefficient_b": -0.8, + }, + ) + ) + + +def test_rs_sensor_is_rejected_by_the_vendor_model() -> None: + """RS only exists in the datasheet model, so the vendor one cannot publish it.""" + with pytest.raises(cv.Invalid, match="'rs_sensor:' belongs to"): + CONFIG_SCHEMA(_hub(rs_sensor={"name": "MiCS-5524 RS"})) + + +def test_reference_keys_belong_to_one_model() -> None: + with pytest.raises(cv.Invalid, match="'r0:' belongs to 'conversion: datasheet'"): + CONFIG_SCHEMA(_hub(air_reference=None, r0=10.0)) + + with pytest.raises( + cv.Invalid, match="'air_reference:' belongs to 'conversion: dfrobot'" + ): + CONFIG_SCHEMA( + _hub( + air_reference=3.6, + conversion="datasheet", + hydrogen={"name": "MiCS-5524 H2 (wrong model)"}, + ) + ) def test_vendor_table_is_the_datasheet_one() -> None: """The whole table is pinned, not only the gases a config file happens to use.""" expected = { - CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), - CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), - CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), - CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), - CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), + CONF_CARBON_MONOXIDE: GasDefinition( + vendor=VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + datasheet=DatasheetCurve(1.0, -1.2, 1.0, 1000.0), + ), + CONF_HYDROGEN: GasDefinition( + VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), None + ), + CONF_ETHANOL: GasDefinition( + VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), None + ), + CONF_AMMONIA: GasDefinition(VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), None), + CONF_METHANE: GasDefinition( + VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), None + ), } assert expected == GASES + + +def test_only_co_ships_with_datasheet_coefficients() -> None: + assert datasheet_gases() == ["CO"] diff --git a/tests/components/mics_5524/common.h b/tests/components/mics_5524/common.h index 7f46ba9bea3..d80ba354b4d 100644 --- a/tests/components/mics_5524/common.h +++ b/tests/components/mics_5524/common.h @@ -10,8 +10,12 @@ namespace esphome::mics_5524::testing { using micsmath::air_value_from_voltage; using micsmath::calibration_is_stable; +using micsmath::clamp_ppm; +using micsmath::ppm_from_power_law; using micsmath::ppm_from_vendor_curve; using micsmath::ratio_from_air_value; +using micsmath::ratio_from_rs; +using micsmath::rs_from_voltage; // Independent copy of the DFRobot table; the Python tests pin coefficients.py itself. constexpr micsmath::VendorCurve CO{0.425f, 0.000405f, 1.0f, 1000.0f}; diff --git a/tests/components/mics_5524/common.yaml b/tests/components/mics_5524/common.yaml index eb5f69b14ee..86a9c764203 100644 --- a/tests/components/mics_5524/common.yaml +++ b/tests/components/mics_5524/common.yaml @@ -6,7 +6,7 @@ sensor: - platform: mics_5524 id: mics_5524_module - # 10k/20k divider: the 5 V breakout's 3.33 V stays under the ADC limit. + # A 10k/20k divider brings the 5 V output down to 3.33 V on the pin; x1.5 scales it back. voltage: mics_5524_adc voltage_multiplier: 1.5 sample_count: 3 @@ -15,6 +15,11 @@ sensor: delay: 5s sample_count: 3 sample_interval: 20ms + # rs_sensor: only exists in the datasheet model. + voltage_sensor: + name: MiCS-5524 volts + ratio_sensor: + name: MiCS-5524 ratio carbon_monoxide: name: MiCS-5524 CO max_ppm: 500.0 diff --git a/tests/components/mics_5524/sensor/mics_math_test.cpp b/tests/components/mics_5524/sensor/mics_math_test.cpp index 946b4fa2e2e..2f58170774d 100644 --- a/tests/components/mics_5524/sensor/mics_math_test.cpp +++ b/tests/components/mics_5524/sensor/mics_math_test.cpp @@ -51,6 +51,9 @@ TEST(MicsMath, AirValueIsTheVoltageBelowVcc) { // A pin at or below 0 V is disconnected, not a reading of VCC. EXPECT_TRUE(std::isnan(air_value_from_voltage(0.0f, 5.0f))); EXPECT_TRUE(std::isnan(air_value_from_voltage(-0.1f, 5.0f))); + // So is one at or below 5 % of VCC: clean air sits volts above that. + EXPECT_TRUE(std::isnan(air_value_from_voltage(0.25f, 5.0f))); + EXPECT_FLOAT_EQ(4.74f, air_value_from_voltage(0.26f, 5.0f)); // The two ends of the vendor range, and the unusable case, one by one. EXPECT_FLOAT_EQ(1.0f, ratio_from_air_value(air_value_from_voltage(4.0f, 5.0f), 1.0f)); EXPECT_FLOAT_EQ(0.0f, ratio_from_air_value(air_value_from_voltage(5.0f, 5.0f), 1.0f)); @@ -90,4 +93,88 @@ TEST(MicsMath, SaturatedSensorEndToEnd) { EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(ratio, CO)); } +TEST(MicsMath, DatasheetResistanceComesFromTheDivider) { + // RS = (VCC * RL) / V_AO - RL: half of the supply across a 10k load resistor. + EXPECT_FLOAT_EQ(10.0f, rs_from_voltage(2.5f, 5.0f, 10.0f)); + EXPECT_FLOAT_EQ(90.0f, rs_from_voltage(0.5f, 5.0f, 10.0f)); + // A pin at the supply is RS = 0, the saturated end; above it would be a negative + // resistance, which only a wrong `vcc:` produces. + EXPECT_FLOAT_EQ(0.0f, rs_from_voltage(5.0f, 5.0f, 10.0f)); + EXPECT_TRUE(std::isnan(rs_from_voltage(5.5f, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(NAN, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(2.5f, 5.0f, 0.0f))); +} + +TEST(MicsMath, DatasheetDeadInputReadsUnknown) { + // The floor is the output of RS = 200 x RL, 5 V / 201 = 24.9 mV: a pin at or below it is + // disconnected, not a module with a huge clean-air resistance. + EXPECT_NEAR(0.02488f, micsmath::datasheet_dead_input_voltage(5.0f), 0.00001f); + EXPECT_TRUE(std::isnan(rs_from_voltage(0.0f, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(0.02f, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(0.0248f, 5.0f, 10.0f))); + // Just above the floor a 1.5 MOhm module, the top of the datasheet range, still reads. + EXPECT_NEAR(1656.7f, rs_from_voltage(0.03f, 5.0f, 10.0f), 0.1f); + // The floor scales with RL, so a 1k load resistor reads up to 200 kOhm. + EXPECT_NEAR(199.0f, rs_from_voltage(0.025f, 5.0f, 1.0f), 0.1f); +} + +TEST(MicsMath, SaturatedDatasheetSensorReadsTheTopOfTheRange) { + // V_AO at VCC -> RS = 0 -> ratio 0 -> infinity on the falling curve -> max_ppm, like the + // vendor model, rather than unknown with a wiring warning. + const float ratio = ratio_from_rs(rs_from_voltage(5.0f, 5.0f, 10.0f), 10.0f); + EXPECT_FLOAT_EQ(0.0f, ratio); + EXPECT_TRUE(std::isinf(ppm_from_power_law(1.0f, -1.2f, ratio))); + EXPECT_FLOAT_EQ(1000.0f, clamp_ppm(ppm_from_power_law(1.0f, -1.2f, ratio), 1.0f, 1000.0f)); + // Without a ceiling there is nothing to report. + EXPECT_TRUE(std::isnan(clamp_ppm(INFINITY, 0.0f, INFINITY))); +} + +TEST(MicsMath, DatasheetRatioNeedsAReference) { + EXPECT_FLOAT_EQ(1.0f, ratio_from_rs(10.0f, 10.0f)); + EXPECT_FLOAT_EQ(0.5f, ratio_from_rs(5.0f, 10.0f)); + // Without a calibrated R0 there is no ratio to report. + EXPECT_TRUE(std::isnan(ratio_from_rs(10.0f, 0.0f))); + EXPECT_TRUE(std::isnan(ratio_from_rs(-1.0f, 10.0f))); + EXPECT_TRUE(std::isnan(ratio_from_rs(NAN, 10.0f))); +} + +TEST(MicsMath, DatasheetCurveFollowsTheShippedCoefficients) { + // The CO fit shipped in `coefficients.py`, a = 1.0 and b = -1.2, follows the data sheet + // graph with R0 at clean air: 1 ppm at RS/R0 = 1, 60 ppm at about 0.033 (a sensitivity + // factor of 30, inside the 1.2 - 50 the data sheet gives) and 1000 ppm at about 0.0032. + EXPECT_FLOAT_EQ(1.0f, ppm_from_power_law(1.0f, -1.2f, 1.0f)); + EXPECT_NEAR(60.0f, ppm_from_power_law(1.0f, -1.2f, 0.0329f), 0.5f); + EXPECT_NEAR(1000.0f, ppm_from_power_law(1.0f, -1.2f, 0.00316f), 5.0f); + // A ratio outside what the fit can express is unusable, not a huge number. + EXPECT_TRUE(std::isnan(ppm_from_power_law(1.0f, -1.2f, -0.1f))); + EXPECT_TRUE(std::isnan(ppm_from_power_law(1.0f, -1.2f, NAN))); + // A non-positive fit cannot produce a reading either. + EXPECT_TRUE(std::isnan(ppm_from_power_law(0.0f, -1.2f, 0.5f))); + EXPECT_TRUE(std::isnan(ppm_from_power_law(-1.0f, -1.2f, 0.5f))); +} + +TEST(MicsMath, ClampingKeepsZeroAndCapsTheRange) { + // 0 ppm means "no gas detected" in both models and is never changed. A reading + // below `min_ppm` counts as not detected and reports 0, the vendor behaviour. + EXPECT_FLOAT_EQ(0.0f, clamp_ppm(0.0f, 5.0f, 100.0f)); + EXPECT_FLOAT_EQ(0.0f, clamp_ppm(2.0f, 5.0f, 100.0f)); + EXPECT_FLOAT_EQ(100.0f, clamp_ppm(200.0f, 5.0f, 100.0f)); + EXPECT_FLOAT_EQ(6.3f, clamp_ppm(6.3f, 5.0f, 100.0f)); + EXPECT_TRUE(std::isnan(clamp_ppm(NAN, 5.0f, 100.0f))); +} + +TEST(MicsMath, DatasheetModelEndToEnd) { + // 2.5 V across a 10k load resistor is RS = 10 kOhm, which is also R0 when the sensor + // is in clean air: the reading is then the 1 ppm floor of the data sheet. + const float rs = rs_from_voltage(2.5f, 5.0f, 10.0f); + const float ratio = ratio_from_rs(rs, 10.0f); + + EXPECT_FLOAT_EQ(1.0f, ratio); + EXPECT_FLOAT_EQ(1.0f, clamp_ppm(ppm_from_power_law(1.0f, -1.2f, ratio), 1.0f, 1000.0f)); + // Twice the resistance is cleaner than the reference air, below the floor, so no gas. + EXPECT_FLOAT_EQ(0.0f, clamp_ppm(ppm_from_power_law(1.0f, -1.2f, ratio_from_rs(20.0f, 10.0f)), 1.0f, 1000.0f)); + // Half the resistance is ratio 0.5: 2^1.2 = 2.3 ppm. + EXPECT_NEAR(2.30f, ppm_from_power_law(1.0f, -1.2f, ratio_from_rs(5.0f, 10.0f)), 0.01f); +} + } // namespace esphome::mics_5524::testing diff --git a/tests/components/mics_5524/validate-datasheet.esp32-idf.yaml b/tests/components/mics_5524/validate-datasheet.esp32-idf.yaml new file mode 100644 index 00000000000..4aad3b6ee50 --- /dev/null +++ b/tests/components/mics_5524/validate-datasheet.esp32-idf.yaml @@ -0,0 +1,31 @@ +# Config-only test: the datasheet model with the wiring the component owns (pin:, voltage_divider:). +sensor: + - platform: mics_5524 + id: mics_5524_dat + pin: GPIO34 + voltage_divider: + r1: 10000 + r2: 20000 + adc_input_max: 3.34 + adc_attenuation: 12db + conversion: datasheet + r0: 10.0 + rl: 10.0 + # The EN input of the breakout is active low, so the pin is inverted. + enable_pin: + number: GPIO25 + inverted: true + ratio_sensor: + name: MiCS-5524 ratio + rs_sensor: + name: MiCS-5524 RS + voltage_sensor: + name: MiCS-5524 volts + carbon_monoxide: + id: mics_5524_dat_co + name: MiCS-5524 CO (datasheet) + max_ppm: 2000.0 + hydrogen: + name: MiCS-5524 H2 (fit) + coefficient_a: 6.3 + coefficient_b: -1.1 From f8d31c3e541e209fb739354449afd4f7bafd1f67 Mon Sep 17 00:00:00 2001 From: Tom Zaspel <40226087+tzabbi@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:47:50 +0200 Subject: [PATCH 497/651] [dfrobot_sen0465] Add DFRobot SEN0465-SEN0476 electrochemical gas sensors (#20880) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + .../dfrobot_sen0465_base/__init__.py | 244 ++++++++++++++++ .../dfrobot_sen0465_base.cpp | 276 ++++++++++++++++++ .../dfrobot_sen0465_base.h | 110 +++++++ .../dfrobot_sen0465_i2c/__init__.py | 2 + .../dfrobot_sen0465_i2c.cpp | 37 +++ .../dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h | 19 ++ .../components/dfrobot_sen0465_i2c/sensor.py | 26 ++ .../dfrobot_sen0465_uart/__init__.py | 2 + .../dfrobot_sen0465_uart.cpp | 62 ++++ .../dfrobot_sen0465_uart.h | 24 ++ .../components/dfrobot_sen0465_uart/sensor.py | 38 +++ esphome/core/defines.h | 2 + .../dfrobot_sen0465_i2c/common.yaml | 24 ++ .../dfrobot_sen0465_i2c/test.esp32-idf.yaml | 3 + .../dfrobot_sen0465_i2c/test.esp8266-ard.yaml | 3 + .../dfrobot_sen0465_uart/common.yaml | 14 + .../dfrobot_sen0465_uart/test.esp32-idf.yaml | 3 + .../test.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_dfrobot_sen0465.yaml | 94 ++++++ .../test_uart_mock_dfrobot_sen0465.py | 106 +++++++ 21 files changed, 1095 insertions(+) create mode 100644 esphome/components/dfrobot_sen0465_base/__init__.py create mode 100644 esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp create mode 100644 esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h create mode 100644 esphome/components/dfrobot_sen0465_i2c/__init__.py create mode 100644 esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp create mode 100644 esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h create mode 100644 esphome/components/dfrobot_sen0465_i2c/sensor.py create mode 100644 esphome/components/dfrobot_sen0465_uart/__init__.py create mode 100644 esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp create mode 100644 esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h create mode 100644 esphome/components/dfrobot_sen0465_uart/sensor.py create mode 100644 tests/components/dfrobot_sen0465_i2c/common.yaml create mode 100644 tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/dfrobot_sen0465_uart/common.yaml create mode 100644 tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml create mode 100644 tests/integration/test_uart_mock_dfrobot_sen0465.py diff --git a/CODEOWNERS b/CODEOWNERS index 55f58dbe696..c9ff7c6bb32 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -157,6 +157,9 @@ esphome/components/delonghi/* @grob6000 esphome/components/dew_point/* @CFlix esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber +esphome/components/dfrobot_sen0465_base/* @tzabbi +esphome/components/dfrobot_sen0465_i2c/* @tzabbi +esphome/components/dfrobot_sen0465_uart/* @tzabbi esphome/components/dfrobot_sen0609/* @remcom esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 diff --git a/esphome/components/dfrobot_sen0465_base/__init__.py b/esphome/components/dfrobot_sen0465_base/__init__.py new file mode 100644 index 00000000000..cb82e894e79 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_base/__init__.py @@ -0,0 +1,244 @@ +from collections.abc import Callable +from dataclasses import dataclass + +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MODEL, + CONF_TEMPERATURE, + CONF_TEMPERATURE_COMPENSATION, + CONF_THRESHOLD, + CONF_TYPE, + DEVICE_CLASS_CARBON_MONOXIDE, + DEVICE_CLASS_TEMPERATURE, + ICON_GAS_CYLINDER, + ICON_MOLECULE_CO, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, +) +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType + +CODEOWNERS = ["@tzabbi"] +DOMAIN = "dfrobot_sen0465_base" + +CONF_ALARM = "alarm" +CONF_CONCENTRATION = "concentration" + +dfrobot_sen0465_base_ns = cg.esphome_ns.namespace("dfrobot_sen0465_base") +DFRobotSEN0465Component = dfrobot_sen0465_base_ns.class_( + "DFRobotSEN0465Component", cg.PollingComponent +) +AlarmType = dfrobot_sen0465_base_ns.enum("AlarmType", is_class=True) +ALARM_TYPES = { + "LOW": AlarmType.ALARM_TYPE_LOW, + "HIGH": AlarmType.ALARM_TYPE_HIGH, +} + +# One piece of the temperature compensation from the DFRobot_MultiGasSensor library: +# for low < T <= high, compensated = raw / (a * T + b) - (c * T + d) +CompensationRange = tuple[int, int, float, float, float, float] + + +@dataclass(frozen=True) +class Model: + """One gas probe of the series.""" + + gas: str + gas_type: int # Gas type code the sensor reports + unit: str + # Also the resolution of the alarm threshold the sensor expects + accuracy_decimals: int + compensation: tuple[CompensationRange, ...] = () + device_class: str = cv.UNDEFINED + icon: str = ICON_GAS_CYLINDER + + +MODELS: dict[str, Model] = { + "SEN0465": Model("O2", 0x05, UNIT_PERCENT, 1), + "SEN0466": Model( + "CO", + 0x04, + UNIT_PARTS_PER_MILLION, + 0, + ( + (-20, 20, 0.005, 0.9, 0, 0), + (20, 40, 0.005, 0.9, 0.3, -6), + ), + device_class=DEVICE_CLASS_CARBON_MONOXIDE, + icon=ICON_MOLECULE_CO, + ), + "SEN0467": Model( + "H2S", + 0x03, + UNIT_PARTS_PER_MILLION, + 0, + ( + (-20, 20, 0.005, 0.92, 0, 0), + (20, 60, 0.015, -0.3, 0, 0), + ), + ), + "SEN0468": Model( + "Cl2", + 0x31, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0.015, 1.1, -0.0025, 0), + (0, 20, 0, 1.1, 0.005, 0), + (20, 40, 0, 1.1, -0.005, 0.3), + ), + ), + "SEN0469": Model( + "NH3", + 0x02, + UNIT_PARTS_PER_MILLION, + 0, + ( + (-20, 0, 0.006, 0.95, -0.006, 0.25), + (0, 20, 0.006, 0.95, -0.012, 0.25), + (20, 40, 0.005, 1.08, -0.1, 2), + ), + ), + "SEN0470": Model( + "SO2", + 0x2B, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-40, 40, 0.006, 0.95, 0, 0), + (40, 60, 0.006, 0.95, 0.05, -2), + ), + ), + "SEN0471": Model( + "NO2", + 0x2C, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0.005, 0.9, -0.0025, 0.005), + (0, 20, 0.005, 0.9, 0.005, 0.005), + (20, 40, 0.005, 0.9, 0.0025, 0.1), + ), + ), + "SEN0472": Model( + "O3", + 0x2A, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0.015, 1.1, 0, 0.05), + (0, 20, 0, 1.1, 0.01, 0), + (20, 40, 0, 1.1, -0.005, 0.3), + ), + ), + # The library's H2 compensation divides by (0.74 * T + 0.007) below 20 °C, which gives + # nonsense results, so it is not offered. + "SEN0473": Model("H2", 0x06, UNIT_PARTS_PER_MILLION, 0), + "SEN0474": Model( + "HCl", + 0x2E, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0, 1, -0.0075, -0.1), + (0, 20, 0, 1, 0, -0.1), + (20, 50, 0, 1, -0.01, 0.1), + ), + ), + "SEN0475": Model( + "HF", + 0x33, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0, 1, -0.0025, 0), + (0, 20, 0, 1, 0, -0.1), + (20, 40, 0, 1, 0.0375, -0.85), + ), + ), + "SEN0476": Model( + "PH3", + 0x45, + UNIT_PARTS_PER_MILLION, + 1, + ((-20, 40, 0.005, 0.9, 0, 0),), + ), +} + +# Sizes the C++ range list; the largest table entry sets it +COMPENSATION_RANGES = max(len(model.compensation) for model in MODELS.values()) + + +def _model_schema( + model: Model, component_class: MockObjClass, bus_schema: cv.Schema +) -> cv.Schema: + schema = { + cv.GenerateID(): cv.declare_id(component_class), + cv.Optional(CONF_CONCENTRATION): sensor.sensor_schema( + unit_of_measurement=model.unit, + icon=model.icon, + accuracy_decimals=model.accuracy_decimals, + device_class=model.device_class, + 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, + ), + } + if model.compensation: + schema[cv.Optional(CONF_TEMPERATURE_COMPENSATION, default=False)] = cv.boolean + # The sensor sends the threshold as a 16-bit count of its last decimal place + max_threshold = 0xFFFF / 10**model.accuracy_decimals + schema[cv.Optional(CONF_ALARM)] = cv.Schema( + { + cv.Required(CONF_THRESHOLD): cv.float_range(min=0, max=max_threshold), + cv.Optional(CONF_TYPE, default="HIGH"): cv.enum(ALARM_TYPES, upper=True), + } + ) + return ( + cv.Schema(schema).extend(cv.polling_component_schema("60s")).extend(bus_schema) + ) + + +def dfrobot_sen0465_schema( + component_class: MockObjClass, bus_schema: cv.Schema +) -> Callable[[ConfigType], ConfigType]: + """Return the config schema for one bus variant, with one schema per model.""" + return cv.typed_schema( + { + name: _model_schema(model, component_class, bus_schema) + for name, model in MODELS.items() + }, + key=CONF_MODEL, + upper=True, + ) + + +async def to_code_base(config: ConfigType) -> MockObj: + model = MODELS[config[CONF_MODEL]] + var = cg.new_Pvariable(config[CONF_ID], model.gas_type, model.gas) + await cg.register_component(var, config) + + if config.get(CONF_TEMPERATURE_COMPENSATION): + cg.add_define("USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION") + cg.add_define("DFROBOT_SEN0465_COMPENSATION_RANGES", COMPENSATION_RANGES) + for low, high, a, b, c, d in model.compensation: + cg.add(var.add_compensation_range(low, high, a, b, c, d)) + + if (alarm := config.get(CONF_ALARM)) is not None: + decimals = model.accuracy_decimals + threshold = round(alarm[CONF_THRESHOLD] * 10**decimals) + cg.add(var.set_alarm(alarm[CONF_TYPE], threshold, decimals)) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_CONCENTRATION, var.set_concentration_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + return var diff --git a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp new file mode 100644 index 00000000000..78267d5ff9e --- /dev/null +++ b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp @@ -0,0 +1,276 @@ +#include "dfrobot_sen0465_base.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::dfrobot_sen0465_base { + +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0465"); + +// Protocol from https://github.com/DFRobot/DFRobot_MultiGasSensor +// Request: FF 01 command argument 00 00 00 00 checksum +// Response: FF command conc_high conc_low gas_type decimals temp_high temp_low checksum +static constexpr uint8_t FRAME_ADDRESS = 0x01; + +static constexpr uint8_t NO_COMMAND = 0x00; +static constexpr uint8_t CMD_CHANGE_ACQUIRE_MODE = 0x78; +static constexpr uint8_t CMD_GET_ALL_DATA = 0x88; +static constexpr uint8_t CMD_SET_THRESHOLD_ALARM = 0x89; +static constexpr uint8_t ACQUIRE_MODE_PASSIVE = 0x04; +static constexpr uint8_t ALARM_SWITCH_OFF = 0x00; +static constexpr uint8_t ALARM_SWITCH_ON = 0x01; +static constexpr uint8_t ALARM_LOW_THRESHOLD = 0x00; +static constexpr uint8_t ALARM_HIGH_THRESHOLD = 0x01; +// Byte 2 of the answer to a setting command is 1 when the sensor accepted it +static constexpr uint8_t RESULT_OK = 0x01; + +// The DFRobot examples wait this long after changing the acquire mode +static constexpr uint32_t MODE_CHANGE_DELAY_MS = 1000; +static constexpr uint32_t RETRY_DELAY_MS = 1000; +static constexpr uint8_t MAX_ATTEMPTS = 3; + +// Like FucCheckSum() in the DFRobot library, the checksum covers bytes 1 to 6 only, not byte 7 +static uint8_t frame_checksum(const uint8_t *frame) { + // Two's complement of the byte sum between the head and the checksum + return static_cast(~sum16_bytes(frame + 1, FRAME_SIZE - 3) + 1); +} + +static const LogString *step_name(SetupStep step) { + return step == SetupStep::SETUP_STEP_ACQUIRE_MODE ? LOG_STR("Passive mode") : LOG_STR("Alarm setting"); +} + +void DFRobotSEN0465Component::setup() { + // Answer only when asked, instead of sending a reading every second + this->step_ = SetupStep::SETUP_STEP_ACQUIRE_MODE; + if (!this->send_step_request_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +void DFRobotSEN0465Component::update() { + // The first reading is requested when the setup steps are done + if (this->step_ != SetupStep::SETUP_STEP_DONE) + return; + // A new request would replace the wait for the answer to the previous one + if (this->pending_command_ != NO_COMMAND) { + ESP_LOGV(TAG, "Previous request still waiting for an answer, skipping update"); + return; + } + if (!this->send_command_(CMD_GET_ALL_DATA)) { + ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(); + } +} + +void DFRobotSEN0465Component::dump_config() { + ESP_LOGCONFIG(TAG, + "DFRobot SEN0465 series gas sensor:\n" + " Gas: %s (type 0x%02X)", + this->gas_name_, this->gas_type_); +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + ESP_LOGCONFIG(TAG, " Temperature compensation: %s", YESNO(!this->compensation_.empty())); +#endif + if (this->alarm_type_ == AlarmType::ALARM_TYPE_OFF) { + ESP_LOGCONFIG(TAG, " Alarm: off"); + } else { + float scale = 1.0f; + for (uint8_t i = 0; i < this->alarm_decimals_; i++) + scale *= 10.0f; + const float threshold = this->alarm_threshold_ / scale; + ESP_LOGCONFIG(TAG, " Alarm: %s %.*f", + this->alarm_type_ == AlarmType::ALARM_TYPE_HIGH ? LOG_STR_LITERAL("above") : LOG_STR_LITERAL("below"), + this->alarm_decimals_, threshold); + } + if (this->setup_incomplete_) { + ESP_LOGCONFIG(TAG, " Setup incomplete: yes"); + } + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Concentration", this->concentration_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +} + +bool DFRobotSEN0465Component::send_command_(uint8_t command, uint8_t arg0, uint8_t arg1, uint8_t arg2, uint8_t arg3) { + uint8_t frame[FRAME_SIZE] = {FRAME_HEAD, FRAME_ADDRESS, command, arg0, arg1, arg2, arg3}; + frame[FRAME_SIZE - 1] = frame_checksum(frame); + if (!this->write_frame(frame)) + return false; + this->pending_command_ = command; + this->read_response(); + return true; +} + +bool DFRobotSEN0465Component::send_step_request_() { + if (this->step_ == SetupStep::SETUP_STEP_ACQUIRE_MODE) + return this->send_command_(CMD_CHANGE_ACQUIRE_MODE, ACQUIRE_MODE_PASSIVE); + if (this->alarm_type_ == AlarmType::ALARM_TYPE_OFF) { + // Switch the alarm off explicitly, the sensor may still have one from an earlier configuration + return this->send_command_(CMD_SET_THRESHOLD_ALARM, ALARM_SWITCH_OFF); + } + return this->send_command_( + CMD_SET_THRESHOLD_ALARM, ALARM_SWITCH_ON, static_cast(this->alarm_threshold_ >> 8), + static_cast(this->alarm_threshold_), + this->alarm_type_ == AlarmType::ALARM_TYPE_HIGH ? ALARM_HIGH_THRESHOLD : ALARM_LOW_THRESHOLD); +} + +void DFRobotSEN0465Component::retry_step_request_() { + if (!this->send_step_request_()) + this->step_failed_(LOG_STR("request not sent")); +} + +void DFRobotSEN0465Component::step_failed_(const LogString *reason) { + this->attempts_++; + ESP_LOGW(TAG, "%s failed: %s (attempt %u of %u)", LOG_STR_ARG(step_name(this->step_)), LOG_STR_ARG(reason), + this->attempts_, MAX_ATTEMPTS); + if (this->attempts_ < MAX_ATTEMPTS) { + this->set_timeout(STEP_TIMEOUT_ID, RETRY_DELAY_MS, [this]() { this->retry_step_request_(); }); + return; + } + // Carry on: readings may still work, but the warning stays because the sensor is not set up as configured + this->setup_incomplete_ = true; + this->status_set_warning(); + this->next_step_(); +} + +void DFRobotSEN0465Component::next_step_() { + this->attempts_ = 0; + if (this->step_ == SetupStep::SETUP_STEP_ACQUIRE_MODE) { + this->step_ = SetupStep::SETUP_STEP_ALARM; + this->set_timeout(STEP_TIMEOUT_ID, MODE_CHANGE_DELAY_MS, [this]() { this->retry_step_request_(); }); + return; + } + this->step_ = SetupStep::SETUP_STEP_DONE; + this->update(); +} + +void DFRobotSEN0465Component::handle_no_response_() { + this->pending_command_ = NO_COMMAND; + if (this->step_ != SetupStep::SETUP_STEP_DONE) { + this->step_failed_(LOG_STR("no response")); + return; + } + ESP_LOGW(TAG, "No response from the sensor"); + this->fail_reading_(); +} + +void DFRobotSEN0465Component::fail_reading_() { + // A frozen gas reading would hide a rising concentration, so the entities go unknown + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); + this->status_set_warning(); +} + +void DFRobotSEN0465Component::handle_response_(const uint8_t *frame) { + // Reject a late answer to an earlier request or a reading sent before passive mode was set + const bool expected = frame[0] == FRAME_HEAD && frame[1] == this->pending_command_; + this->pending_command_ = NO_COMMAND; + + if (this->step_ == SetupStep::SETUP_STEP_DONE) { + if (!expected) { + char hex[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGW(TAG, "Unexpected response: %s", format_hex_pretty_to(hex, frame, FRAME_SIZE)); + this->fail_reading_(); + return; + } + this->handle_data_(frame); + return; + } + + ESP_LOGD_HEX(TAG, frame, FRAME_SIZE, FRAME_SIZE, "%s response: %s", LOG_STR_ARG(step_name(this->step_))); + if (!expected) { + this->step_failed_(LOG_STR("unexpected response")); + return; + } + // Like the DFRobot library, the answer to the mode change is accepted without a checksum check + if (this->step_ == SetupStep::SETUP_STEP_ALARM && frame[FRAME_SIZE - 1] != frame_checksum(frame)) { + this->step_failed_(LOG_STR("invalid checksum")); + return; + } + if (frame[2] != RESULT_OK) { + this->step_failed_(LOG_STR("not confirmed")); + return; + } + ESP_LOGD(TAG, "%s confirmed", LOG_STR_ARG(step_name(this->step_))); + this->next_step_(); +} + +void DFRobotSEN0465Component::handle_data_(const uint8_t *frame) { + if (frame[FRAME_SIZE - 1] != frame_checksum(frame)) { + char hex[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGW(TAG, "Invalid checksum: %s", format_hex_pretty_to(hex, frame, FRAME_SIZE)); + this->fail_reading_(); + return; + } + + // On-board 10 kOhm NTC (B = 3380) read by a 10-bit ADC at 3 V, as converted by the DFRobot library + float temperature = NAN; + const uint16_t temperature_raw = encode_uint16(frame[6], frame[7]); + if (temperature_raw > 0 && temperature_raw < 1024) { + const float voltage = 3.0f * temperature_raw / 1024.0f; + const float resistance = voltage * 10000.0f / (3.0f - voltage); + temperature = 1.0f / (1.0f / 298.15f + std::log(resistance / 10000.0f) / 3380.13f) - 273.15f; + } else { + // 0 is a disconnected thermistor, 1024 and above a bad frame + ESP_LOGW(TAG, "Temperature reading %u is out of range", temperature_raw); + } + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + + if (frame[4] != this->gas_type_) { + ESP_LOGW(TAG, "Probe reports gas type 0x%02X, expected %s (0x%02X); check the configured model", frame[4], + this->gas_name_, this->gas_type_); + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(NAN); + this->status_set_warning(); + return; + } + + float concentration = encode_uint16(frame[2], frame[3]); + switch (frame[5]) { + case 0: + break; + case 1: + concentration *= 0.1f; + break; + case 2: + concentration *= 0.01f; + break; + default: + ESP_LOGW(TAG, "Invalid number of decimal places: %u", frame[5]); + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(NAN); + this->status_set_warning(); + return; + } + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + if (!this->compensation_.empty()) + concentration = this->compensate_(concentration, temperature); +#endif + if (concentration < 0.0f) + concentration = 0.0f; + + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(concentration); + if (std::isnan(concentration) || std::isnan(temperature) || this->setup_incomplete_) { + this->status_set_warning(); + } else { + this->status_clear_warning(); + } +} + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION +float DFRobotSEN0465Component::compensate_(float concentration, float temperature) const { + for (const auto &range : this->compensation_) { + if (temperature > range.low && temperature <= range.high) + return concentration / (range.a * temperature + range.b) - (range.c * temperature + range.d); + } + ESP_LOGW(TAG, "Temperature %.1f C is outside the compensation range", temperature); + return NAN; +} +#endif + +} // namespace esphome::dfrobot_sen0465_base diff --git a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h new file mode 100644 index 00000000000..cc589b7f134 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h @@ -0,0 +1,110 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::dfrobot_sen0465_base { + +static constexpr uint8_t FRAME_SIZE = 9; +static constexpr uint8_t FRAME_HEAD = 0xFF; + +// Scheduler ids, shared with the bus variants +static constexpr uint32_t STEP_TIMEOUT_ID = 0; +static constexpr uint32_t RESPONSE_TIMEOUT_ID = 1; + +enum class AlarmType : uint8_t { + ALARM_TYPE_OFF, + ALARM_TYPE_LOW, + ALARM_TYPE_HIGH, +}; + +/// Settings sent once after boot, before the first reading +enum class SetupStep : uint8_t { + SETUP_STEP_ACQUIRE_MODE, + SETUP_STEP_ALARM, + SETUP_STEP_DONE, +}; + +/// DFRobot "Factory Calibrated Electrochemical Gas Sensor" series (SEN0465 to SEN0476). +/// The bus variants only send request frames and deliver the answers. +class DFRobotSEN0465Component : public PollingComponent { + SUB_SENSOR(concentration) + SUB_SENSOR(temperature) + + public: + /// @param gas_type gas type code the configured probe reports + /// @param gas_name name of that gas, used in log messages + DFRobotSEN0465Component(uint8_t gas_type, const char *gas_name) : gas_name_(gas_name), gas_type_(gas_type) {} + + void setup() override; + void update() override; + void dump_config() override; + + /// @param threshold alarm threshold as sent to the sensor, in units of the last decimal place + /// @param decimals decimal places of the threshold, used for logging + void set_alarm(AlarmType type, uint16_t threshold, uint8_t decimals) { + this->alarm_type_ = type; + this->alarm_threshold_ = threshold; + this->alarm_decimals_ = decimals; + } + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + /// For low < T <= high: compensated = raw / (a * T + b) - (c * T + d) + void add_compensation_range(int8_t low, int8_t high, float a, float b, float c, float d) { + this->compensation_.push_back({low, high, a, b, c, d}); + } +#endif + + protected: + /// Sends one complete request frame. + virtual bool write_frame(const uint8_t *frame) = 0; + /// Waits for the answer to a request, then calls either handle_response_() or handle_no_response_(), once. + virtual void read_response() = 0; + + void handle_response_(const uint8_t *frame); + void handle_no_response_(); + /// Publish unknown on both sensors and set the warning when a reading cannot be used + void fail_reading_(); + + /// Sends the request of the current step. + bool send_step_request_(); + /// Sends the request again from a scheduled callback; a failed write counts as a failed attempt. + void retry_step_request_(); + void step_failed_(const LogString *reason); + void next_step_(); + void handle_data_(const uint8_t *frame); + /// Sends a request and waits for its answer; returns false when the request could not be sent. + bool send_command_(uint8_t command, uint8_t arg0 = 0, uint8_t arg1 = 0, uint8_t arg2 = 0, uint8_t arg3 = 0); + + const char *gas_name_; + uint16_t alarm_threshold_{0}; + uint8_t gas_type_; + AlarmType alarm_type_{AlarmType::ALARM_TYPE_OFF}; + uint8_t alarm_decimals_{0}; + SetupStep step_{SetupStep::SETUP_STEP_ACQUIRE_MODE}; + uint8_t attempts_{0}; + /// Command whose answer is awaited, 0 when no request is outstanding + uint8_t pending_command_{0}; + /// A setup step gave up, so the alarm or the mode may not be what was configured + bool setup_incomplete_{false}; + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + struct CompensationRange { + int8_t low; + int8_t high; + float a; + float b; + float c; + float d; + }; + + /// Returns NAN when the temperature is outside every range + float compensate_(float concentration, float temperature) const; + + StaticVector compensation_; +#endif +}; + +} // namespace esphome::dfrobot_sen0465_base diff --git a/esphome/components/dfrobot_sen0465_i2c/__init__.py b/esphome/components/dfrobot_sen0465_i2c/__init__.py new file mode 100644 index 00000000000..898f8f2d5f0 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tzabbi"] +DOMAIN = "dfrobot_sen0465_i2c" diff --git a/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp new file mode 100644 index 00000000000..aa6312a7ebb --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp @@ -0,0 +1,37 @@ +#include "dfrobot_sen0465_i2c.h" +#include "esphome/core/log.h" + +namespace esphome::dfrobot_sen0465_i2c { + +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0465_i2c"); + +using dfrobot_sen0465_base::FRAME_SIZE; +using dfrobot_sen0465_base::RESPONSE_TIMEOUT_ID; + +static constexpr uint8_t DATA_REGISTER = 0x00; +// The DFRobot library waits 10 ms, but on an ESP32-C6 reading the answer to the alarm command +// 10 ms after sending it timed out +static constexpr uint32_t RESPONSE_DELAY_MS = 100; + +void DFRobotSEN0465I2CComponent::dump_config() { + DFRobotSEN0465Component::dump_config(); + LOG_I2C_DEVICE(this); +} + +bool DFRobotSEN0465I2CComponent::write_frame(const uint8_t *frame) { + return this->write_register(DATA_REGISTER, frame, FRAME_SIZE) == i2c::ERROR_OK; +} + +void DFRobotSEN0465I2CComponent::read_response() { + this->set_timeout(RESPONSE_TIMEOUT_ID, RESPONSE_DELAY_MS, [this]() { + uint8_t frame[FRAME_SIZE]; + // Like the DFRobot library, end the register write with a STOP before reading + if (this->write(&DATA_REGISTER, 1) != i2c::ERROR_OK || this->read(frame, FRAME_SIZE) != i2c::ERROR_OK) { + this->handle_no_response_(); + return; + } + this->handle_response_(frame); + }); +} + +} // namespace esphome::dfrobot_sen0465_i2c diff --git a/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h new file mode 100644 index 00000000000..505cc1aef41 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::dfrobot_sen0465_i2c { + +class DFRobotSEN0465I2CComponent final : public dfrobot_sen0465_base::DFRobotSEN0465Component, public i2c::I2CDevice { + public: + using DFRobotSEN0465Component::DFRobotSEN0465Component; + + void dump_config() override; + + protected: + bool write_frame(const uint8_t *frame) override; + void read_response() override; +}; + +} // namespace esphome::dfrobot_sen0465_i2c diff --git a/esphome/components/dfrobot_sen0465_i2c/sensor.py b/esphome/components/dfrobot_sen0465_i2c/sensor.py new file mode 100644 index 00000000000..85ec983b479 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/sensor.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.dfrobot_sen0465_base import ( + DFRobotSEN0465Component, + dfrobot_sen0465_schema, + to_code_base, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["dfrobot_sen0465_base"] +DEPENDENCIES = ["i2c"] + +dfrobot_sen0465_i2c_ns = cg.esphome_ns.namespace("dfrobot_sen0465_i2c") +DFRobotSEN0465I2CComponent = dfrobot_sen0465_i2c_ns.class_( + "DFRobotSEN0465I2CComponent", DFRobotSEN0465Component, i2c.I2CDevice +) + +# 0x77 is the factory setting of the A0/A1 switches according to the DFRobot examples +CONFIG_SCHEMA = dfrobot_sen0465_schema( + DFRobotSEN0465I2CComponent, i2c.i2c_device_schema(0x77) +) + + +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/dfrobot_sen0465_uart/__init__.py b/esphome/components/dfrobot_sen0465_uart/__init__.py new file mode 100644 index 00000000000..e0598c87c8e --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tzabbi"] +DOMAIN = "dfrobot_sen0465_uart" diff --git a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp new file mode 100644 index 00000000000..ed1c6733abb --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp @@ -0,0 +1,62 @@ +#include "dfrobot_sen0465_uart.h" + +#include + +namespace esphome::dfrobot_sen0465_uart { + +using dfrobot_sen0465_base::FRAME_HEAD; +using dfrobot_sen0465_base::FRAME_SIZE; +using dfrobot_sen0465_base::RESPONSE_TIMEOUT_ID; + +// The DFRobot library waits up to this long for an answer +static constexpr uint32_t RESPONSE_TIMEOUT_MS = 3000; + +void DFRobotSEN0465UARTComponent::setup() { + // loop() only runs while an answer is expected + this->disable_loop(); + DFRobotSEN0465Component::setup(); +} + +void DFRobotSEN0465UARTComponent::loop() { + // The tail of a reading the probe sent on its own may precede the answer; start at the next frame head. + uint8_t byte; + while (this->rx_count_ == 0 && this->peek_byte(&byte) && byte != FRAME_HEAD) + this->read_byte(&byte); + const size_t available = this->available(); + if (available == 0) + return; + const size_t len = std::min(available, FRAME_SIZE - this->rx_count_); + if (!this->read_array(this->rx_buffer_ + this->rx_count_, len)) + return; + this->rx_count_ += len; + if (this->rx_count_ < FRAME_SIZE) + return; + + this->rx_count_ = 0; + this->cancel_timeout(RESPONSE_TIMEOUT_ID); + this->disable_loop(); + this->handle_response_(this->rx_buffer_); +} + +bool DFRobotSEN0465UARTComponent::write_frame(const uint8_t *frame) { + // Drop anything left over, such as the answer to the mode change or readings sent before passive mode was set + uint8_t byte; + while (this->available() > 0) { + if (!this->read_byte(&byte)) + break; + } + this->rx_count_ = 0; + this->write_array(frame, FRAME_SIZE); + return true; +} + +void DFRobotSEN0465UARTComponent::read_response() { + this->enable_loop(); + this->set_timeout(RESPONSE_TIMEOUT_ID, RESPONSE_TIMEOUT_MS, [this]() { + this->disable_loop(); + this->rx_count_ = 0; + this->handle_no_response_(); + }); +} + +} // namespace esphome::dfrobot_sen0465_uart diff --git a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h new file mode 100644 index 00000000000..e30f1c3353c --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::dfrobot_sen0465_uart { + +class DFRobotSEN0465UARTComponent final : public dfrobot_sen0465_base::DFRobotSEN0465Component, + public uart::UARTDevice { + public: + using DFRobotSEN0465Component::DFRobotSEN0465Component; + + void setup() override; + void loop() override; + + protected: + bool write_frame(const uint8_t *frame) override; + void read_response() override; + + uint8_t rx_buffer_[dfrobot_sen0465_base::FRAME_SIZE]; + uint8_t rx_count_{0}; +}; + +} // namespace esphome::dfrobot_sen0465_uart diff --git a/esphome/components/dfrobot_sen0465_uart/sensor.py b/esphome/components/dfrobot_sen0465_uart/sensor.py new file mode 100644 index 00000000000..36eae742f7d --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/sensor.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +from esphome.components import uart +from esphome.components.dfrobot_sen0465_base import ( + DFRobotSEN0465Component, + dfrobot_sen0465_schema, + to_code_base, +) +from esphome.types import ConfigType + +from . import DOMAIN + +AUTO_LOAD = ["dfrobot_sen0465_base"] +DEPENDENCIES = ["uart"] + +dfrobot_sen0465_uart_ns = cg.esphome_ns.namespace("dfrobot_sen0465_uart") +DFRobotSEN0465UARTComponent = dfrobot_sen0465_uart_ns.class_( + "DFRobotSEN0465UARTComponent", DFRobotSEN0465Component, uart.UARTDevice +) + +CONFIG_SCHEMA = dfrobot_sen0465_schema( + DFRobotSEN0465UARTComponent, uart.UART_DEVICE_SCHEMA +) + +# The sensor's baud rate is fixed +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=9600, + require_tx=True, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await uart.register_uart_device(var, config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 97677334828..e1e30d1b783 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -70,6 +70,8 @@ #define USE_DEEP_SLEEP #define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES +#define USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION +#define DFROBOT_SEN0465_COMPENSATION_RANGES 3 #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_EMONTX_VERSION diff --git a/tests/components/dfrobot_sen0465_i2c/common.yaml b/tests/components/dfrobot_sen0465_i2c/common.yaml new file mode 100644 index 00000000000..1106210a67f --- /dev/null +++ b/tests/components/dfrobot_sen0465_i2c/common.yaml @@ -0,0 +1,24 @@ +sensor: + - platform: dfrobot_sen0465_i2c + id: dfrobot_sen0466_i2c_co + i2c_id: i2c_bus + model: SEN0466 + address: 0x77 + temperature_compensation: true + alarm: + threshold: 50 + update_interval: 30s + concentration: + name: CO Concentration + temperature: + name: CO Sensor Temperature + - platform: dfrobot_sen0465_i2c + id: dfrobot_sen0465_i2c_o2 + i2c_id: i2c_bus + model: SEN0465 + address: 0x76 + alarm: + threshold: 19.5 + type: low + concentration: + name: O2 Concentration diff --git a/tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml b/tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml new file mode 100644 index 00000000000..e02720c246d --- /dev/null +++ b/tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + dfrobot_sen0465_i2c: !include common.yaml diff --git a/tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml b/tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml new file mode 100644 index 00000000000..bfb82a5c696 --- /dev/null +++ b/tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + dfrobot_sen0465_i2c: !include common.yaml diff --git a/tests/components/dfrobot_sen0465_uart/common.yaml b/tests/components/dfrobot_sen0465_uart/common.yaml new file mode 100644 index 00000000000..fdd12be8f96 --- /dev/null +++ b/tests/components/dfrobot_sen0465_uart/common.yaml @@ -0,0 +1,14 @@ +sensor: + - platform: dfrobot_sen0465_uart + id: dfrobot_sen0466_uart_co + uart_id: uart_bus + model: SEN0466 + temperature_compensation: true + alarm: + threshold: 35 + type: high + update_interval: 30s + concentration: + name: CO Concentration + temperature: + name: CO Sensor Temperature diff --git a/tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml b/tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml new file mode 100644 index 00000000000..98ff3920a96 --- /dev/null +++ b/tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + dfrobot_sen0465_uart: !include common.yaml diff --git a/tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml b/tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml new file mode 100644 index 00000000000..863aa9e7455 --- /dev/null +++ b/tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + dfrobot_sen0465_uart: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml b/tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml new file mode 100644 index 00000000000..3e40b1c017f --- /dev/null +++ b/tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml @@ -0,0 +1,94 @@ +esphome: + name: uart-mock-dfrobot-sen0465 + +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 DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +# A model of a SEN0466 (CO) probe. The first mode change is refused, the first alarm request gets a late +# answer to the mode change instead, and the first data request gets no answer, so the component has to +# retry, reject the wrong answer and wait out the timeout without sending a new request +uart_mock: + id: mock_uart + baud_rate: 9600 + on_tx: + - then: + - lambda: |- + if (data.size() != 9 || data[0] != 0xFF || data[1] != 0x01) + return; + static int mode_requests = 0; + static int alarm_requests = 0; + static int data_requests = 0; + static uint32_t mode_confirmed_ms = 0; + static bool alarm_set = false; + std::vector reply(9, 0); + reply[0] = 0xFF; + reply[1] = data[2]; + switch (data[2]) { + case 0x78: + if (data[3] == 0x04 && ++mode_requests >= 2) { + reply[2] = 0x01; + mode_confirmed_ms = millis(); + } + break; + case 0x89: + ESP_LOGI("sim", "Alarm switch %u threshold %u type %u, %u ms after the mode change", data[3], + (data[4] << 8) | data[5], data[6], millis() - mode_confirmed_ms); + reply[2] = 0x01; + if (++alarm_requests == 1) { + reply[1] = 0x78; // FF 78 01 00 00 00 00 00 87: valid checksum and result, wrong command + break; + } + alarm_set = true; + break; + case 0x88: + if (!alarm_set) + ESP_LOGE("sim", "Data requested before the setup was done"); + if (++data_requests == 1) { + ESP_LOGI("sim", "Data request left unanswered"); + return; + } + if (data_requests == 2) + ESP_LOGI("sim", "Data request answered"); + reply[2] = 0x00; // 20 ppm + reply[3] = 0x14; + reply[4] = 0x04; // CO + reply[5] = 0x00; // no decimal places + reply[6] = 0x01; // NTC ADC 511, a non-zero byte 7 that the checksum must leave out + reply[7] = 0xFF; + break; + default: + return; + } + // The checksum covers bytes 1 to 6 only, as in the DFRobot library + uint8_t sum = 0; + for (int i = 1; i < 7; i++) + sum += reply[i]; + reply[8] = static_cast(~sum + 1); + id(mock_uart).inject_to_rx_buffer(reply); + +sensor: + - platform: dfrobot_sen0465_uart + uart_id: mock_uart + model: SEN0466 + temperature_compensation: true + update_interval: 2s + alarm: + threshold: 35 + type: high + concentration: + name: CO + temperature: + name: Probe Temperature diff --git a/tests/integration/test_uart_mock_dfrobot_sen0465.py b/tests/integration/test_uart_mock_dfrobot_sen0465.py new file mode 100644 index 00000000000..12fd139fad5 --- /dev/null +++ b/tests/integration/test_uart_mock_dfrobot_sen0465.py @@ -0,0 +1,106 @@ +"""Integration test for the DFRobot SEN0465 series over UART against a model of a SEN0466 probe. + +Checks the boot sequence (passive mode with one retry, the wait after it, the alarm setting with a +late answer to the mode change that must be rejected), that no new request is sent while one is +unanswered, and that a reading is decoded and temperature compensated. +""" + +from __future__ import annotations + +import asyncio +import math +from pathlib import Path +import re + +from aioesphomeapi import EntityState, SensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +ALARM_RE = re.compile( + r"Alarm switch (\d+) threshold (\d+) type (\d+), (\d+) ms after the mode change" +) + + +@pytest.mark.asyncio +async def test_uart_mock_dfrobot_sen0465( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Boot sets passive mode and the alarm before the first reading.""" + 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() + alarm_set: asyncio.Future[re.Match[str]] = loop.create_future() + lines: list[str] = [] + + def line_callback(line: str) -> None: + lines.append(line) + if (match := ALARM_RE.search(line)) and not alarm_set.done(): + alarm_set.set_result(match) + + states: dict[int, EntityState] = {} + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + concentration = require_entity(entities, "co", SensorInfo) + temperature = require_entity(entities, "probe_temperature", SensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity: SensorInfo) -> float | None: + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + try: + async with asyncio.timeout(15.0): + # The unanswered request publishes NAN first; wait for the decoded reading + while (value := state_of(concentration)) is None or math.isnan(value): + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"No reading; log: {lines[-20:]}") + + match = await asyncio.wait_for(alarm_set, timeout=1.0) + switch, threshold, alarm_type, delay_ms = (int(x) for x in match.groups()) + assert (switch, threshold, alarm_type) == (1, 35, 1) + assert delay_ms >= 1000, ( + "the alarm was set before the wait after the mode change" + ) + + assert any( + "Passive mode failed: not confirmed (attempt 1 of 3)" in x for x in lines + ) + assert any("Passive mode confirmed" in x for x in lines) + assert any( + "Alarm setting failed: unexpected response (attempt 1 of 3)" in x + for x in lines + ) + assert any("Alarm setting confirmed" in x for x in lines) + assert not any("Data requested before the setup was done" in x for x in lines) + + # The unanswered request blocks new ones until its timeout, which is longer than the interval + unanswered = next( + i for i, x in enumerate(lines) if "Data request left unanswered" in x + ) + answered = next(i for i, x in enumerate(lines) if "Data request answered" in x) + between = lines[unanswered:answered] + assert any("Previous request still waiting for an answer" in x for x in between) + assert any("No response from the sensor" in x for x in between) + + # NTC ADC 511 converted like the DFRobot library, then compensated with the CO formula + voltage = 3 * 511 / 1024 + resistance = voltage * 10000 / (3 - voltage) + temp = 1 / (1 / 298.15 + math.log(resistance / 10000) / 3380.13) - 273.15 + assert state_of(temperature) == pytest.approx(temp, abs=0.05) + expected = 20 / (0.005 * temp + 0.9) - (0.3 * temp - 6) + assert state_of(concentration) == pytest.approx(expected, abs=0.05) From 1b4721d4451c7d62dca91e571d4f494b440a66e6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:23:12 +1100 Subject: [PATCH 498/651] [core] Script improvements including `choose` (#20547) --- esphome/automation.py | 129 +++++++++- esphome/components/script/__init__.py | 105 +++++++-- esphome/components/script/script.h | 37 ++- esphome/core/defines.h | 1 + tests/components/script/__init__.py | 9 + tests/components/script/common.yaml | 33 +++ .../components/script/test_script_on_stop.cpp | 77 ++++++ .../fixtures/script_on_stop_and_choose.yaml | 145 ++++++++++++ .../test_script_on_stop_and_choose.py | 117 +++++++++ tests/unit_tests/components/test_script.py | 160 +++++++++++++ tests/unit_tests/test_automation.py | 222 ++++++++++++++++++ 11 files changed, 1013 insertions(+), 22 deletions(-) create mode 100644 tests/components/script/__init__.py create mode 100644 tests/components/script/test_script_on_stop.cpp create mode 100644 tests/integration/fixtures/script_on_stop_and_choose.yaml create mode 100644 tests/integration/test_script_on_stop_and_choose.py create mode 100644 tests/unit_tests/components/test_script.py diff --git a/esphome/automation.py b/esphome/automation.py index 32f423d2a34..91cbf0daab1 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_COUNT, CONF_ELSE, CONF_ID, + CONF_IF, CONF_THEN, CONF_TIME, CONF_TIMEOUT, @@ -34,6 +35,9 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, SafeExpType from esphome.util import Registry +CONF_CHOOSE = "choose" +CONF_CHOICE_ID = "choice_id" + def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj: """Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr.""" @@ -840,10 +844,39 @@ async def delay_action_to_code( return var +def _wrap_bare_if_condition(value: Any) -> Any: + """Allow a single condition directly under `if:` in place of `condition: {...}`.""" + if not isinstance(value, dict): + return value + # `any` and `all` are also registered conditions, but here they are the if action's own keys + bare = [ + key + for key in value + if key in CONDITION_REGISTRY and key not in (CONF_ANY, CONF_ALL) + ] + if not bare: + return value + if len(bare) > 1: + raise cv.Invalid( + f"Only one condition may be given directly under 'if', found {', '.join(bare)}. " + f"Use '{CONF_CONDITION}', '{CONF_ALL}' or '{CONF_ANY}' to combine conditions" + ) + if other := [k for k in (CONF_CONDITION, CONF_ANY, CONF_ALL) if k in value]: + raise cv.Invalid( + f"'{bare[0]}' given directly under 'if' cannot be used with '{other[0]}'. " + f"Move it into '{other[0]}'" + ) + key = bare[0] + result = {k: v for k, v in value.items() if k != key} + result[CONF_CONDITION] = {key: value[key]} + return result + + @register_action( "if", IfAction, cv.All( + _wrap_bare_if_condition, { cv.Exclusive( CONF_CONDITION, CONF_CONDITION @@ -870,14 +903,104 @@ async def if_action_to_code( cond_conf = next(el for el in config if el in (CONF_ANY, CONF_ALL, CONF_CONDITION)) condition = await build_condition(config[cond_conf], template_arg, args) var = cg.new_Pvariable(action_id, if_template_arg, condition) + then_actions = else_actions = None if CONF_THEN in config: - for action in await build_action_list(config[CONF_THEN], template_arg, args): + then_actions = await build_action_list(config[CONF_THEN], template_arg, args) + if has_else: + else_actions = await build_action_list(config[CONF_ELSE], template_arg, args) + _add_if_branches(var, then_actions, else_actions) + return var + + +def _add_if_branches( + var: MockObj, + then_actions: list[MockObj] | None, + else_actions: list[MockObj] | None, +) -> None: + """Add the branches of an IfAction one action at a time; None leaves a branch out.""" + if then_actions is not None: + for action in then_actions: cg.add(var.add_then_action(action)) cg.add(var.finish_then()) - if has_else: - for action in await build_action_list(config[CONF_ELSE], template_arg, args): + if else_actions is not None: + for action in else_actions: cg.add(var.add_else_action(action)) cg.add(var.finish_else()) + + +def _validate_choice(choice: ConfigType) -> ConfigType: + """A choice is either `if` with `then`, or `else` on its own.""" + if CONF_ELSE in choice: + for key in (CONF_IF, CONF_THEN): + if key in choice: + raise cv.Invalid(f"'{key}' cannot be used with 'else'", path=[key]) + return choice + for key in (CONF_IF, CONF_THEN): + if key not in choice: + raise cv.Invalid(f"'{key}' is required", path=[key]) + return choice + + +def _validate_choose_else(choices: list[ConfigType]) -> list[ConfigType]: + """`else` may only be the last choice, and not the only one.""" + for index, choice in enumerate(choices[:-1]): + if CONF_ELSE in choice: + raise cv.Invalid( + "Only the last choice may be 'else'", path=[index, CONF_ELSE] + ) + if len(choices) == 1 and CONF_ELSE in choices[0]: + raise cv.Invalid( + "At least one choice must have 'if' and 'then'", path=[0, CONF_ELSE] + ) + return choices + + +@register_action( + CONF_CHOOSE, + IfAction, + cv.All( + cv.ensure_list( + cv.All( + cv.Schema( + { + cv.GenerateID(CONF_CHOICE_ID): cv.declare_id(IfAction), + cv.Optional(CONF_IF): validate_potentially_and_condition, + cv.Optional(CONF_THEN): validate_action_list, + cv.Optional(CONF_ELSE): validate_action_list, + } + ), + _validate_choice, + ) + ), + cv.Length(min=1), + _validate_choose_else, + ), + synchronous=True, +) +async def choose_action_to_code( + config: list[ConfigType], + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + # Built as a chain of if/else actions: each choice's else branch holds the next choice, + # and the last choice's else branch holds the `else` actions, if any. + else_actions: list[MockObj] | None = None + if CONF_ELSE in config[-1]: + else_actions = await build_action_list( + config[-1][CONF_ELSE], template_arg, args + ) + config = config[:-1] + var: MockObj | None = None + for index in reversed(range(len(config))): + choice = config[index] + if_template_arg = cg.TemplateArguments(else_actions is not None, *template_arg) + condition = await build_condition(choice[CONF_IF], template_arg, args) + choice_id = action_id if index == 0 else choice[CONF_CHOICE_ID] + var = cg.new_Pvariable(choice_id, if_template_arg, condition) + then_actions = await build_action_list(choice[CONF_THEN], template_arg, args) + _add_if_branches(var, then_actions, else_actions) + else_actions = [var] return var diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 48a208b7f7f..da149944f21 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -1,9 +1,13 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import CONF_ON_STOP import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_RESTART from esphome.core import CORE, EsphomeError +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "script" @@ -20,6 +24,7 @@ CONF_SINGLE = "single" CONF_QUEUED = "queued" CONF_PARALLEL = "parallel" CONF_MAX_RUNS = "max_runs" +CONF_SCRIPT_STOP = "script.stop" SCRIPT_MODES = { CONF_SINGLE: SingleScript, @@ -109,22 +114,72 @@ def validate_parameter_type(value): raise cv.Invalid("Parameter type contains invalid characters") -CONFIG_SCHEMA = automation.validate_automation( - { - # Don't declare id as cv.declare_id yet, because the ID type - # depends on the mode. Will be checked later with assign_declare_id - cv.Required(CONF_ID): cv.string_strict, - cv.Optional(CONF_MODE, default=CONF_SINGLE): cv.one_of( - *SCRIPT_MODES, lower=True - ), - cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100), - cv.Optional(CONF_PARAMETERS, default={}): cv.Schema( - { - validate_parameter_name: validate_parameter_type, - } - ), - }, - extra_validators=cv.All(check_max_runs, assign_declare_id), +def _fill_script_stop_ids(value: Any, script_id: str) -> Any: + """Give `script.stop` actions without an id in a raw action tree the id `script_id`. + + Containers are copied only along changed paths so untouched YAML nodes keep their source locations. + """ + if isinstance(value, list): + result = [ + {CONF_SCRIPT_STOP: {CONF_ID: script_id}} + if item == CONF_SCRIPT_STOP + else _fill_script_stop_ids(item, script_id) + for item in value + ] + if all(new is old for new, old in zip(result, value, strict=True)): + return value + return result + if not isinstance(value, dict): + return value + result = value + for key, item in value.items(): + if key == CONF_SCRIPT_STOP and ( + item is None or (isinstance(item, dict) and CONF_ID not in item) + ): + new_item = {**(item or {}), CONF_ID: script_id} + else: + new_item = _fill_script_stop_ids(item, script_id) + if new_item is not item: + if result is value: + result = value.copy() + result[key] = new_item + return result + + +def _fill_script_stop_ids_in_script(item: Any) -> Any: + if isinstance(item, dict) and isinstance(script_id := item.get(CONF_ID), str): + return _fill_script_stop_ids(item, script_id) + return item + + +def default_script_stop_ids(value: Any) -> Any: + """Make `script.stop` with no id, used inside a script, stop that script.""" + if isinstance(value, list): + return [_fill_script_stop_ids_in_script(item) for item in value] + # A single script may be given as a mapping instead of a list + return _fill_script_stop_ids_in_script(value) + + +CONFIG_SCHEMA = cv.All( + default_script_stop_ids, + automation.validate_automation( + { + # Don't declare id as cv.declare_id yet, because the ID type + # depends on the mode. Will be checked later with assign_declare_id + cv.Required(CONF_ID): cv.string_strict, + cv.Optional(CONF_MODE, default=CONF_SINGLE): cv.one_of( + *SCRIPT_MODES, lower=True + ), + cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100), + cv.Optional(CONF_PARAMETERS, default={}): cv.Schema( + { + validate_parameter_name: validate_parameter_type, + } + ), + cv.Optional(CONF_ON_STOP): automation.validate_automation(single=True), + }, + extra_validators=cv.All(check_max_runs, assign_declare_id), + ), ) @@ -145,8 +200,12 @@ async def to_code(config): triggers.append((trigger, func_args, conf)) + if any(CONF_ON_STOP in conf for conf in config): + cg.add_define("USE_SCRIPT_ON_STOP") for trigger, func_args, conf in triggers: await automation.build_automation(trigger, func_args, conf) + if (stop_conf := conf.get(CONF_ON_STOP)) is not None: + await automation.build_automation(trigger.get_stop_trigger(), [], stop_conf) @automation.register_action( @@ -205,9 +264,19 @@ async def script_execute_action_to_code(config, action_id, template_arg, args): return var +def _require_script_stop_id(config: ConfigType) -> ConfigType: + # Inside a script the id has already been filled in by default_script_stop_ids + if CONF_ID not in config: + raise cv.Invalid( + "'script.stop' without an id can only be used inside a script", + path=[CONF_ID], + ) + return config + + automation.register_apply_action( - "script.stop", - maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), + CONF_SCRIPT_STOP, + maybe_simple_id({cv.Optional(CONF_ID): cv.use_id(Script)}, _require_script_stop_id), automation.ApplyCall("stop()"), ) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index efd75d27e0e..c84077025a3 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -33,8 +33,18 @@ template class Script : public ScriptLogger, public Triggeris_action_running(); } +#ifdef USE_SCRIPT_ON_STOP + /// Stop all instances of this script. Fires the on_stop trigger once if any instance was running. + virtual void stop() { + if (this->is_action_running()) + this->stop_running_(); + } + + Trigger<> *get_stop_trigger() { return &this->stop_trigger_; } +#else /// Stop all instances of this script. virtual void stop() { this->stop_action(); } +#endif // execute this script using a tuple that contains the arguments void execute_tuple(const std::tuple &tuple) { @@ -57,7 +67,18 @@ template class Script : public ScriptLogger, public Triggertrigger(x...); } + // Stop the running actions early and fire the on_stop trigger. + void stop_running_() { + this->stop_action(); +#ifdef USE_SCRIPT_ON_STOP + this->stop_trigger_.trigger(); +#endif + } + const LogString *name_{nullptr}; +#ifdef USE_SCRIPT_ON_STOP + Trigger<> stop_trigger_; +#endif }; /** A script type for which only a single instance at a time is allowed. @@ -88,7 +109,12 @@ template class RestartScript : public Script { void execute(Ts... x) override { if (this->is_action_running()) { this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), LOG_STR_ARG(this->name_)); - this->stop_action(); + this->stop_running_(); +#ifdef USE_SCRIPT_ON_STOP + // on_stop may have executed this script again; only the newest run may continue + if (this->is_action_running()) + this->stop_action(); +#endif } this->run_actions_(x...); @@ -145,12 +171,21 @@ template class QueueingScript : public Script, public Com } void stop() override { +#ifdef USE_SCRIPT_ON_STOP + // Queued runs are unfinished work too, even between two runs when nothing is running + const bool stopping = this->is_action_running() || this->num_queued_ != 0; +#endif // Clear all queued items to free memory immediately // Resetting the array automatically destroys all unique_ptrs and their contents this->var_queue_.reset(); this->num_queued_ = 0; this->queue_front_ = 0; +#ifdef USE_SCRIPT_ON_STOP + if (stopping) + this->stop_running_(); +#else Script::stop(); +#endif } void loop() override { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e1e30d1b783..3555b3cd1c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -266,6 +266,7 @@ #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 +#define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M #define USE_SENSOR diff --git a/tests/components/script/__init__.py b/tests/components/script/__init__.py new file mode 100644 index 00000000000..ef5a1a33501 --- /dev/null +++ b/tests/components/script/__init__.py @@ -0,0 +1,9 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config): + cg.add_define("USE_SCRIPT_ON_STOP") + + manifest.to_code = to_code_testing diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index f8396e9648f..6a71c2cdef4 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -22,6 +22,25 @@ esphome: - script.is_running: my_script then: - logger.log: my_script is running + - if: + any: + - script.is_running: my_script + - script.is_running: my_script_restart + then: + - logger.log: a script is running + - script.execute: my_script_stops_itself + - choose: + - if: + script.is_running: my_script + then: + - logger.log: first choice + - if: + - lambda: return true; + - script.is_running: my_script_restart + then: + - logger.log: second choice + - else: + - logger.log: default choice globals: - id: nested_total @@ -44,8 +63,22 @@ script: - lambda: 'ESP_LOGD("main", "Hello World!");' - id: my_script_restart mode: restart + on_stop: + - logger.log: my_script_restart stopped then: - lambda: 'ESP_LOGD("main", "Hello World!");' + - id: my_script_stops_itself + on_stop: + then: + - logger.log: my_script_stops_itself stopped + then: + - delay: 1s + - if: + lambda: return true; + then: + - script.stop + - script.stop: + - logger.log: never reached - id: my_script_with_array_params parameters: ints: int[] diff --git a/tests/components/script/test_script_on_stop.cpp b/tests/components/script/test_script_on_stop.cpp new file mode 100644 index 00000000000..5fdda64bc15 --- /dev/null +++ b/tests/components/script/test_script_on_stop.cpp @@ -0,0 +1,77 @@ +#include + +#include "esphome/components/script/script.h" + +namespace esphome::script::testing { + +class CountAction : public Action<> { + public: + explicit CountAction(int *count) : count_(count) {} + + protected: + void play() override { (*this->count_)++; } + int *count_; +}; + +// Stays running until release() is called, like a delay that never expires on its own. +class HoldAction : public Action<> { + public: + void play_complex() override { + this->num_running_++; + this->plays++; + } + void release() { this->play_next_(); } + int plays{0}; + + protected: + void play() override {} +}; + +class QueueingScriptOnStopTest : public ::testing::Test { + protected: + void SetUp() override { + this->script_.set_max_runs(3); + this->automation_.add_action(&this->hold_); + this->stop_automation_.add_action(&this->count_); + } + + QueueingScript<> script_; + Automation<> automation_{&this->script_}; + HoldAction hold_; + int on_stop_calls_{0}; + Automation<> stop_automation_{this->script_.get_stop_trigger()}; + CountAction count_{&this->on_stop_calls_}; +}; + +TEST_F(QueueingScriptOnStopTest, StopWhileRunningFiresOnStopOnceAndDropsQueuedRun) { + this->script_.execute(); + this->script_.execute(); + ASSERT_EQ(this->hold_.plays, 1); + + this->script_.stop(); + EXPECT_EQ(this->on_stop_calls_, 1); + + this->script_.loop(); + EXPECT_EQ(this->hold_.plays, 1); +} + +// After one run ends, the next queued run only starts on the following loop() call +TEST_F(QueueingScriptOnStopTest, StopBetweenQueuedRunsFiresOnStop) { + this->script_.execute(); + this->script_.execute(); + this->hold_.release(); + ASSERT_FALSE(this->script_.is_running()); + + this->script_.stop(); + EXPECT_EQ(this->on_stop_calls_, 1); + + this->script_.loop(); + EXPECT_EQ(this->hold_.plays, 1); +} + +TEST_F(QueueingScriptOnStopTest, StopWhenIdleDoesNotFireOnStop) { + this->script_.stop(); + EXPECT_EQ(this->on_stop_calls_, 0); +} + +} // namespace esphome::script::testing diff --git a/tests/integration/fixtures/script_on_stop_and_choose.yaml b/tests/integration/fixtures/script_on_stop_and_choose.yaml new file mode 100644 index 00000000000..d7edbd90d3f --- /dev/null +++ b/tests/integration/fixtures/script_on_stop_and_choose.yaml @@ -0,0 +1,145 @@ +esphome: + name: test-script-on-stop-choose + +host: + +api: + actions: + - action: run_choose + variables: + value: int + then: + - choose: + - if: + lambda: return value < 10; + then: + - logger.log: + format: "CHOOSE small %d" + args: [value] + - if: + lambda: return value < 100; + then: + - delay: 20ms + - logger.log: + format: "CHOOSE medium %d" + args: [value] + # Also true for smaller values, but only the first true choice runs + - if: + lambda: return value < 1000; + then: + - logger.log: + format: "CHOOSE large %d" + args: [value] + - else: + - logger.log: + format: "CHOOSE default %d" + args: [value] + - logger.log: + format: "CHOOSE done %d" + args: [value] + - action: run_self_stop + then: + - script.execute: self_stop + - action: run_external_stop + then: + - script.execute: long_script + - delay: 50ms + - script.stop: long_script + - action: run_idle_stop + then: + - script.stop: long_script + - logger.log: "IDLE stop done" + - action: run_restart + then: + - script.execute: restart_script + - script.execute: restart_script + - action: run_normal + then: + - script.execute: normal_script + - action: run_reenter + then: + - script.execute: reenter_script + - delay: 20ms + - script.execute: reenter_script + - action: run_parallel_stop + then: + - script.execute: parallel_script + - script.execute: parallel_script + - delay: 20ms + - script.stop: parallel_script + - logger.log: "PARALLEL stop done" + - action: run_queued_stop + then: + - script.execute: queued_script + - script.execute: queued_script + - delay: 20ms + - script.stop: queued_script + - delay: 300ms + - logger.log: "QUEUED stop done" + +logger: + level: DEBUG + +script: + - id: self_stop + on_stop: + - logger.log: "SELF on_stop" + then: + - logger.log: "SELF start" + - delay: 20ms + - if: + condition: + lambda: return true; + then: + - script.stop + - logger.log: "SELF not reached" + + - id: long_script + on_stop: + - logger.log: "LONG on_stop" + then: + - delay: 10s + - logger.log: "LONG finished" + + - id: restart_script + mode: restart + on_stop: + - logger.log: "RESTART on_stop" + then: + - logger.log: "RESTART start" + - delay: 50ms + - logger.log: "RESTART finished" + + - id: normal_script + on_stop: + - logger.log: "NORMAL on_stop" + then: + - logger.log: "NORMAL finished" + + # on_stop executes the same restart script again; only one run may continue + - id: reenter_script + mode: restart + on_stop: + - logger.log: "REENTER on_stop" + - script.execute: reenter_script + then: + - logger.log: "REENTER start" + - delay: 100ms + - logger.log: "REENTER finished" + + - id: parallel_script + mode: parallel + on_stop: + - logger.log: "PARALLEL on_stop" + then: + - delay: 10s + - logger.log: "PARALLEL finished" + + - id: queued_script + mode: queued + on_stop: + - logger.log: "QUEUED on_stop" + then: + - logger.log: "QUEUED start" + - delay: 100ms + - logger.log: "QUEUED finished" diff --git a/tests/integration/test_script_on_stop_and_choose.py b/tests/integration/test_script_on_stop_and_choose.py new file mode 100644 index 00000000000..07631450df3 --- /dev/null +++ b/tests/integration/test_script_on_stop_and_choose.py @@ -0,0 +1,117 @@ +"""Integration test for the script on_stop trigger, script.stop without an id, and the choose action.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import UserService +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_on_stop_and_choose( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test on_stop, self-stopping scripts and choose.""" + lines: list[str] = [] + waiters: list[tuple[str, asyncio.Future[None]]] = [] + + def check_output(line: str) -> None: + lines.append(line) + for text, future in waiters: + if text in line and not future.done(): + future.set_result(None) + + def expect(text: str) -> asyncio.Future[None]: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + waiters.append((text, future)) + return future + + def count(text: str) -> int: + return sum(text in line for line in lines) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service: dict[str, UserService] = {s.name: s for s in services} + + # choose runs only the first choice whose condition is true, or the else + # choice if none is, then continues + for value, label in ( + (5, "small"), + (50, "medium"), + (500, "large"), + (5000, "default"), + ): + done = expect(f"CHOOSE done {value}") + await client.execute_service(service["run_choose"], {"value": value}) + await asyncio.wait_for(done, timeout=5.0) + assert count(f"CHOOSE {label} {value}") == 1 + assert count("CHOOSE small") == 1 + assert count("CHOOSE medium") == 1 + assert count("CHOOSE large") == 1 + assert count("CHOOSE default") == 1 + + # A bare script.stop inside a script stops that script and fires on_stop + stopped = expect("SELF on_stop") + await client.execute_service(service["run_self_stop"], {}) + await asyncio.wait_for(stopped, timeout=5.0) + + # Stopping a running script from outside fires on_stop + stopped = expect("LONG on_stop") + await client.execute_service(service["run_external_stop"], {}) + await asyncio.wait_for(stopped, timeout=5.0) + + # Stopping a script that is not running does not fire on_stop + idle_done = expect("IDLE stop done") + await client.execute_service(service["run_idle_stop"], {}) + await asyncio.wait_for(idle_done, timeout=5.0) + + # Restarting a running script stops the old run early + finished = expect("RESTART finished") + await client.execute_service(service["run_restart"], {}) + await asyncio.wait_for(finished, timeout=5.0) + + # A script that finishes normally does not fire on_stop + finished = expect("NORMAL finished") + await client.execute_service(service["run_normal"], {}) + await asyncio.wait_for(finished, timeout=5.0) + + # A restart script whose on_stop executes it again ends up with one run + finished = expect("REENTER finished") + await client.execute_service(service["run_reenter"], {}) + await asyncio.wait_for(finished, timeout=5.0) + + # Stopping several parallel runs fires on_stop once + done = expect("PARALLEL stop done") + await client.execute_service(service["run_parallel_stop"], {}) + await asyncio.wait_for(done, timeout=5.0) + + # Stopping a queued script fires on_stop once and drops the queued run + done = expect("QUEUED stop done") + await client.execute_service(service["run_queued_stop"], {}) + await asyncio.wait_for(done, timeout=5.0) + await asyncio.sleep(0.2) + + assert count("SELF not reached") == 0 + assert count("SELF on_stop") == 1 + assert count("LONG finished") == 0 + assert count("LONG on_stop") == 1 + assert count("RESTART start") == 2 + assert count("RESTART on_stop") == 1 + assert count("RESTART finished") == 1 + assert count("NORMAL on_stop") == 0 + assert count("REENTER on_stop") == 1 + assert count("REENTER start") == 3 + assert count("REENTER finished") == 1 + assert count("PARALLEL on_stop") == 1 + assert count("PARALLEL finished") == 0 + assert count("QUEUED on_stop") == 1 + assert count("QUEUED start") == 1 + assert count("QUEUED finished") == 0 diff --git a/tests/unit_tests/components/test_script.py b/tests/unit_tests/components/test_script.py new file mode 100644 index 00000000000..10c6d0a7a38 --- /dev/null +++ b/tests/unit_tests/components/test_script.py @@ -0,0 +1,160 @@ +"""Tests for the script component's config validation.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from esphome import automation +from esphome.components.const import CONF_ON_STOP +from esphome.components.script import ( + CONF_SCRIPT_STOP, + SingleScript, + default_script_stop_ids, + to_code, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_THEN +from esphome.core import ID +from esphome.cpp_generator import MockObj + + +def test_bare_script_stop_defaults_to_enclosing_script() -> None: + config = [ + { + CONF_ID: "my_script", + CONF_THEN: [ + CONF_SCRIPT_STOP, + {CONF_SCRIPT_STOP: None}, + {CONF_SCRIPT_STOP: {}}, + {CONF_SCRIPT_STOP: "other_script"}, + {CONF_SCRIPT_STOP: {CONF_ID: "other_script"}}, + {"if": {"condition": "x", CONF_THEN: [CONF_SCRIPT_STOP]}}, + ], + "on_stop": [{CONF_SCRIPT_STOP: None}], + }, + {CONF_ID: "second", CONF_THEN: [CONF_SCRIPT_STOP]}, + ] + + result = default_script_stop_ids(config) + + assert result[0][CONF_THEN] == [ + {CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}, + {CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}, + {CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}, + {CONF_SCRIPT_STOP: "other_script"}, + {CONF_SCRIPT_STOP: {CONF_ID: "other_script"}}, + { + "if": { + "condition": "x", + CONF_THEN: [{CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}], + } + }, + ] + assert result[0]["on_stop"] == [{CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}] + assert result[1][CONF_THEN] == [{CONF_SCRIPT_STOP: {CONF_ID: "second"}}] + # The input is not modified + assert config[1][CONF_THEN] == [CONF_SCRIPT_STOP] + + +def test_unchanged_script_config_is_not_copied() -> None: + then = [{"logger.log": "hello"}] + config = [{CONF_ID: "my_script", CONF_THEN: then}] + + result = default_script_stop_ids(config) + + assert result[0] is config[0] + assert result[0][CONF_THEN] is then + + +def test_script_stop_without_id_outside_script_is_invalid() -> None: + with pytest.raises(cv.Invalid, match="can only be used inside a script"): + automation.validate_action(CONF_SCRIPT_STOP) + + +def test_script_stop_with_id_is_valid() -> None: + result = automation.validate_action({CONF_SCRIPT_STOP: "my_script"}) + + assert result[CONF_SCRIPT_STOP][CONF_ID].id == "my_script" + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("my_script", id="not_a_list_or_mapping"), + pytest.param({CONF_THEN: [CONF_SCRIPT_STOP]}, id="mapping_without_id"), + pytest.param(["my_script", {CONF_THEN: [CONF_SCRIPT_STOP]}], id="no_id"), + ], +) +def test_script_stop_ids_left_alone_without_script_id(value: Any) -> None: + assert default_script_stop_ids(value) == value + + +def test_bare_script_stop_in_single_script_mapping() -> None: + config = {CONF_ID: "my_script", CONF_THEN: [CONF_SCRIPT_STOP]} + + result = default_script_stop_ids(config) + + assert result[CONF_THEN] == [{CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}] + + +@pytest.mark.asyncio +async def test_on_stop_builds_stop_trigger_automation() -> None: + stop_conf = {CONF_THEN: []} + config = [ + { + CONF_ID: ID("my_script", is_declaration=True, type=SingleScript), + CONF_MODE: "single", + CONF_PARAMETERS: {}, + CONF_THEN: [], + CONF_ON_STOP: stop_conf, + }, + { + CONF_ID: ID("other_script", is_declaration=True, type=SingleScript), + CONF_MODE: "single", + CONF_PARAMETERS: {}, + CONF_THEN: [], + }, + ] + with ( + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add"), + patch("esphome.codegen.add_define") as add_define, + patch( + "esphome.automation.build_automation", new_callable=AsyncMock + ) as build_automation, + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + await to_code(config) + + add_define.assert_called_once_with("USE_SCRIPT_ON_STOP") + + assert [ + (str(c.args[0]), *c.args[1:]) for c in build_automation.await_args_list + ] == [ + ("my_script", [], config[0]), + ("my_script->get_stop_trigger()", [], stop_conf), + ("other_script", [], config[1]), + ] + + +@pytest.mark.asyncio +async def test_no_on_stop_define_without_on_stop() -> None: + config = [ + { + CONF_ID: ID("my_script", is_declaration=True, type=SingleScript), + CONF_MODE: "single", + CONF_PARAMETERS: {}, + CONF_THEN: [], + } + ] + with ( + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add"), + patch("esphome.codegen.add_define") as add_define, + patch("esphome.automation.build_automation", new_callable=AsyncMock), + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + await to_code(config) + + add_define.assert_not_called() diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index b56d0a2920e..6f4a1ba83a8 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -8,6 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome.automation import ( + CONF_CHOICE_ID, + CONF_CHOOSE, ApplyAction, ApplyCall, ApplyCondition, @@ -25,7 +27,9 @@ from esphome.automation import ( build_parent_callback_automation, build_trigger_automations, build_trigger_callback, + choose_action_to_code, has_non_synchronous_actions, + if_action_to_code, literal_with_length, maybe_simple_id, parent_ref, @@ -40,14 +44,19 @@ from esphome.automation import ( register_simple_condition, string_ref_literal, templatable_bytes, + validate_action, validate_no_deferring_actions, validate_synchronous_automation, ) import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_ALL, + CONF_ANY, CONF_AUTOMATION_ID, + CONF_CONDITION, CONF_COUNT, + CONF_ELSE, CONF_ID, CONF_THEN, CONF_TYPE_ID, @@ -1557,6 +1566,219 @@ async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None ) +def test_if_accepts_bare_condition() -> None: + """A single condition directly under `if:` is the same as `condition: {...}`.""" + bare = validate_action({"if": {"or": [], CONF_THEN: [{"delay": "1s"}]}}) + wrapped = validate_action( + {"if": {CONF_CONDITION: {"or": []}, CONF_THEN: [{"delay": "1s"}]}} + ) + assert bare["if"][CONF_CONDITION] == wrapped["if"][CONF_CONDITION] + assert "or" not in bare["if"] + + +@pytest.mark.parametrize("key", [CONF_ANY, CONF_ALL]) +def test_if_any_all_are_not_bare_conditions(key: str) -> None: + """`any:` and `all:` under `if:` are the if action's own keys, not a bare condition.""" + result = validate_action({"if": {key: [{"or": []}], CONF_THEN: [{"delay": "1s"}]}}) + assert key in result["if"] + assert CONF_CONDITION not in result["if"] + + +@pytest.mark.parametrize( + ("value", "match"), + [ + pytest.param( + {"or": [], "and": [], CONF_THEN: []}, + "Only one condition", + id="two_bare", + ), + pytest.param( + {"or": [], CONF_CONDITION: {"and": []}, CONF_THEN: []}, + "cannot be used with 'condition'", + id="bare_and_wrapped", + ), + pytest.param( + {"or": [], CONF_ANY: [{"and": []}], CONF_THEN: []}, + "cannot be used with 'any'", + id="bare_and_any", + ), + ], +) +def test_if_rejects_ambiguous_bare_condition( + value: dict[str, object], match: str +) -> None: + with pytest.raises(cv.Invalid, match=match): + validate_action({"if": value}) + + +def test_if_with_non_dict_value_is_invalid() -> None: + with pytest.raises(cv.Invalid, match="expected a dictionary"): + validate_action({"if": "not a dict"}) + + +CHOICE = {"if": {"and": []}, CONF_THEN: [{"delay": "1s"}]} + + +@pytest.mark.parametrize( + ("value", "match"), + [ + pytest.param([], "at least 1", id="empty"), + pytest.param([{CONF_THEN: [{"delay": "1s"}]}], "'if' is required", id="no_if"), + pytest.param([{"if": {"and": []}}], "'then' is required", id="no_then"), + pytest.param( + [{CONF_ELSE: [{"delay": "1s"}]}], + "At least one choice must have 'if' and 'then'", + id="only_else", + ), + pytest.param( + [{CONF_ELSE: [{"delay": "1s"}]}, CHOICE], + "Only the last choice may be 'else'", + id="else_not_last", + ), + pytest.param( + [CHOICE, {"if": {"and": []}, CONF_ELSE: []}], + "'if' cannot be used with 'else'", + id="else_with_if", + ), + pytest.param( + [CHOICE, {CONF_THEN: [], CONF_ELSE: []}], + "'then' cannot be used with 'else'", + id="else_with_then", + ), + ], +) +def test_choose_invalid(value: list[ConfigType], match: str) -> None: + with pytest.raises(cv.Invalid, match=match): + validate_action({CONF_CHOOSE: value}) + + +def test_choose_valid() -> None: + result = validate_action( + { + CONF_CHOOSE: [ + CHOICE, + {"if": [{"or": []}, {"and": []}], CONF_THEN: [{"delay": "2s"}]}, + {CONF_ELSE: [{"delay": "3s"}]}, + ] + } + ) + + choices = result[CONF_CHOOSE] + assert len(choices) == 3 + assert "and" in choices[1]["if"] + assert CONF_ELSE in choices[2] + assert all(CONF_CHOICE_ID in choice for choice in choices) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("has_else", [True, False]) +async def test_choose_builds_if_else_chain(has_else: bool) -> None: + """Each choice is an IfAction whose else branch holds the next choice, then the else actions.""" + config: list[ConfigType] = [ + {"if": "cond_a", CONF_THEN: "then_a", CONF_CHOICE_ID: ID("choice_a")}, + {"if": "cond_b", CONF_THEN: "then_b", CONF_CHOICE_ID: ID("choice_b")}, + ] + if has_else: + config.append({CONF_ELSE: "else_c", CONF_CHOICE_ID: ID("choice_c")}) + action_id = ID("action") + with ( + patch( + "esphome.automation.build_condition", + new_callable=AsyncMock, + side_effect=lambda conf, *_: f"built_{conf}", + ), + patch( + "esphome.automation.build_action_list", + new_callable=AsyncMock, + side_effect=lambda conf, *_: [MockObj(f"built_{conf}")], + ), + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add") as add, + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + result = await choose_action_to_code(config, action_id, TEMPLATE_ARG, []) + + # Built from the last choice back; the first choice takes the action's own id + created = [ + (c.args[0].id, str(c.args[1]), c.args[2]) for c in new_pvariable.call_args_list + ] + assert created == [ + ("choice_b", f"<{str(has_else).lower()}>", "built_cond_b"), + ("action", "", "built_cond_a"), + ] + assert str(result) == "action" + statements = [str(c.args[0]) for c in add.call_args_list] + expected_b_else = ( + ["choice_b->add_else_action(built_else_c)", "choice_b->finish_else()"] + if has_else + else [] + ) + assert statements == [ + "choice_b->add_then_action(built_then_b)", + "choice_b->finish_then()", + *expected_b_else, + "action->add_then_action(built_then_a)", + "action->finish_then()", + "action->add_else_action(choice_b)", + "action->finish_else()", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("config", "template_arg", "expected"), + [ + pytest.param( + {CONF_CONDITION: "cond", CONF_THEN: "then", CONF_ELSE: "else"}, + "", + [ + "action->add_then_action(built_then)", + "action->finish_then()", + "action->add_else_action(built_else)", + "action->finish_else()", + ], + id="then_and_else", + ), + pytest.param( + {CONF_ANY: "cond", CONF_ELSE: "else"}, + "", + ["action->add_else_action(built_else)", "action->finish_else()"], + id="only_else", + ), + pytest.param( + {CONF_ALL: "cond", CONF_THEN: "then"}, + "", + ["action->add_then_action(built_then)", "action->finish_then()"], + id="only_then", + ), + ], +) +async def test_if_adds_branches_one_action_at_a_time( + config: ConfigType, template_arg: str, expected: list[str] +) -> None: + with ( + patch( + "esphome.automation.build_condition", + new_callable=AsyncMock, + side_effect=lambda conf, *_: f"built_{conf}", + ), + patch( + "esphome.automation.build_action_list", + new_callable=AsyncMock, + side_effect=lambda conf, *_: [MockObj(f"built_{conf}")], + ), + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add") as add, + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + await if_action_to_code(config, ID("action"), TEMPLATE_ARG, []) + + new_pvariable.assert_called_once() + assert str(new_pvariable.call_args.args[1]) == template_arg + assert new_pvariable.call_args.args[2] == "built_cond" + assert [str(c.args[0]) for c in add.call_args_list] == expected + + def test_shadow_iteration_leaves_other_names() -> None: args = [(cg.std_string, "prefix"), (cg.uint32, "iteration_1")] assert _shadow_iteration(args) == args From c0a693dfce2253565aaef43786aaa5277b85726e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 03:18:24 -1000 Subject: [PATCH 499/651] [components] Use xor_bytes and sum16_bytes for checksums (#20922) --- esphome/components/apc1/apc1.cpp | 13 ++---------- esphome/components/dfplayer/dfplayer.cpp | 5 +---- esphome/components/ld2420/ld2420.cpp | 9 ++------- esphome/components/ld6002b/ld6002b.cpp | 8 ++------ esphome/components/pmsa003i/pmsa003i.cpp | 5 +---- esphome/components/pmsx003/pmsx003.cpp | 10 ++-------- esphome/components/rdm6300/rdm6300.cpp | 4 +--- .../seeed_mr60fda2/seeed_mr60fda2.cpp | 20 ++----------------- .../components/shelly_dimmer/stm32flash.cpp | 14 ++++--------- esphome/components/toshiba/toshiba.cpp | 9 ++------- .../components/zwave_proxy/zwave_proxy.cpp | 10 ++-------- 11 files changed, 21 insertions(+), 86 deletions(-) diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp index beca9d85815..dee4bd8d73b 100644 --- a/esphome/components/apc1/apc1.cpp +++ b/esphome/components/apc1/apc1.cpp @@ -28,15 +28,6 @@ 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(); @@ -157,7 +148,7 @@ void APC1Component::loop() { 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); + uint16_t calculated_checksum = sum16_bytes(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); @@ -343,7 +334,7 @@ void APC1Component::send_command_(APC1Command cmd, uint16_t data) { 0, 0, }; - uint16_t checksum = apc1_checksum(buf.data(), 5); + uint16_t checksum = sum16_bytes(buf.data(), 5); buf[5] = static_cast((checksum >> 8) & 0xFF); buf[6] = static_cast(checksum & 0xFF); this->write_array(buf); diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index c45e895b385..4b031274d26 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -124,10 +124,7 @@ void DFPlayer::play_folder(uint16_t folder, uint16_t file) { void DFPlayer::send_cmd_(uint8_t cmd, uint16_t argument) { uint8_t buffer[10]{0x7e, 0xff, 0x06, cmd, 0x01, (uint8_t) (argument >> 8), (uint8_t) argument, 0x00, 0x00, 0xef}; - uint16_t checksum = 0; - for (uint8_t i = 1; i < 7; i++) - checksum += buffer[i]; - checksum = -checksum; + const uint16_t checksum = static_cast(-sum16_bytes(buffer + 1, 6)); buffer[7] = checksum >> 8; buffer[8] = (uint8_t) checksum; diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index dec7e42de47..e81f117a1b3 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -161,13 +161,8 @@ template uint8_t find_uint8(const StringToUint8 (&arr)[N], const std:: return 0xFF; // Not found } -static uint8_t calc_checksum(void *data, size_t size) { - uint8_t checksum = 0; - uint8_t *data_bytes = (uint8_t *) data; - for (size_t i = 0; i < size; i++) { - checksum ^= data_bytes[i]; // XOR operation - } - return checksum; +static uint8_t calc_checksum(const void *data, size_t size) { + return xor_bytes(static_cast(data), size); } static int32_t get_firmware_int(const char *version_string) { diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 93a2ea341f4..42461228cef 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -1256,12 +1256,8 @@ void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t this->write_byte(static_cast(~header_xor)); if (len > 0 && data != nullptr) { - uint8_t data_xor = 0; - for (uint8_t i = 0; i < len; i++) { - this->write_byte(data[i]); - data_xor ^= data[i]; - } - this->write_byte(static_cast(~data_xor)); + this->write_array(data, len); + this->write_byte(static_cast(~xor_bytes(data, len))); } const uint32_t now = millis(); if (track) { diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 6885795e8a7..5ab0516785d 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -124,10 +124,7 @@ bool PMSA003IComponent::read_data_(PM25AQIData *data) { } // Calculate checksum - uint16_t checksum = 0; - for (uint8_t i = 0; i < CHECKSUM_START_INDEX; i++) { - checksum += buffer[i]; - } + const uint16_t checksum = sum16_bytes(buffer, CHECKSUM_START_INDEX); const uint16_t check = encode_uint16(buffer[CHECKSUM_START_INDEX], buffer[CHECKSUM_START_INDEX + 1]); if (checksum != check) { diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index f99e5adaa2c..efc80e8c650 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -159,10 +159,7 @@ optional PMSX003Component::check_byte_() { } // checksum is without checksum bytes - uint16_t checksum = 0; - for (uint16_t i = 0; i < total_size - 2; i++) { - checksum += this->data_[i]; - } + const uint16_t checksum = sum16_bytes(this->data_, total_size - 2); const uint16_t check = this->get_16_bit_uint_(total_size - 2); if (checksum != check) { @@ -207,10 +204,7 @@ void PMSX003Component::send_command_(Command cmd, uint16_t data) { }; // Calculate checksum - uint16_t checksum = 0; - for (uint8_t i = 0; i < 5; i++) { - checksum += send_data[i]; - } + const uint16_t checksum = sum16_bytes(send_data, 5); send_data[5] = (checksum >> 8) & 0xFF; // Verify Byte 1 send_data[6] = (checksum >> 0) & 0xFF; // Verify Byte 2 diff --git a/esphome/components/rdm6300/rdm6300.cpp b/esphome/components/rdm6300/rdm6300.cpp index 2bdbde91748..12fc9204cf6 100644 --- a/esphome/components/rdm6300/rdm6300.cpp +++ b/esphome/components/rdm6300/rdm6300.cpp @@ -39,9 +39,7 @@ void rdm6300::RDM6300Component::loop() { ESP_LOGW(TAG, "Invalid end byte from RDM6300!"); this->read_state_ = RDM6300_STATE_WAITING_FOR_START; } else { - uint8_t checksum = 0; - for (uint8_t i = 0; i < 5; i++) - checksum ^= this->buffer_[i]; + const uint8_t checksum = xor_bytes(this->buffer_, 5); this->read_state_ = RDM6300_STATE_WAITING_FOR_START; if (checksum != this->buffer_[5]) { ESP_LOGW(TAG, "Checksum from RDM6300 doesn't match! (0x%02X!=0x%02X)", checksum, this->buffer_[5]); diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 6b5fb087171..d0e4c635eb2 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -62,24 +62,8 @@ void MR60FDA2Component::loop() { } } -/** - * @brief Calculate the checksum for a byte array. - * - * This function calculates the checksum for the provided byte array using an - * XOR-based checksum algorithm. - * - * @param data The byte array to calculate the checksum for. - * @param len The length of the byte array. - * @return The calculated checksum. - */ -static uint8_t calculate_checksum(const uint8_t *data, size_t len) { - uint8_t checksum = 0; - for (size_t i = 0; i < len; i++) { - checksum ^= data[i]; - } - checksum = ~checksum; - return checksum; -} +// The checksum is the inverted XOR of the bytes +static uint8_t calculate_checksum(const uint8_t *data, size_t len) { return ~xor_bytes(data, len); } /** * @brief Validate the checksum of a byte array. diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index 5996929ab84..9d3bd9f1e11 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -27,6 +27,7 @@ #include "debug.h" #include "dev_table.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -725,20 +726,13 @@ stm32_err_t stm32_write_memory(const stm32_unique_ptr &stm, uint32_t address, co return STM32_ERR_UNKNOWN; const unsigned int aligned_len = (len + 3) & ~3; - uint8_t cs = aligned_len - 1; uint8_t buf[256 + 2]; buf[0] = aligned_len - 1; - for (unsigned int i = 0; i < len; i++) { - cs ^= data[i]; - buf[i + 1] = data[i]; - } + memcpy(&buf[1], data, len); /* padding data */ - for (auto i = len; i < aligned_len; i++) { - cs ^= 0xFF; - buf[i + 1] = 0xFF; - } - buf[aligned_len + 1] = cs; + memset(&buf[1 + len], 0xFF, aligned_len - len); + buf[aligned_len + 1] = xor_bytes(buf, aligned_len + 1); stream->write_array(buf, aligned_len + 2); stream->flush(); diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index eae24641c40..2cf9419ef8e 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -544,9 +544,7 @@ void ToshibaClimate::transmit_generic_() { } // 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]; - } + message[message_length - 1] = xor_bytes(message + 4, message_length - 5); // Transmit auto transmit = this->transmitter_->transmit(); @@ -1335,13 +1333,10 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { break; // Generic (old) Toshiba packet default: - uint8_t checksum = 0; // Add back the length of the header (we pruned it above) message_length += TOSHIBA_HEADER_LENGTH; // Validate the second checksum before trusting any more of the message - for (uint8_t i = TOSHIBA_HEADER_LENGTH; i < message_length - 1; i++) { - checksum ^= message[i]; - } + const uint8_t checksum = xor_bytes(message + TOSHIBA_HEADER_LENGTH, message_length - 1 - TOSHIBA_HEADER_LENGTH); // Did our computed checksum and the provided checksum match? if (checksum != message[message_length - 1]) { return false; diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index bfa16f857a0..a7b1fa0ced0 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -34,15 +34,9 @@ static constexpr bool is_bootloader_menu_byte(uint8_t byte) { return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E); } -static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { - // Calculate Z-Wave frame checksum - // XOR all bytes between SOF and checksum position (exclusive) +static uint8_t calculate_frame_checksum(const uint8_t *data, size_t length) { // Initial value is 0xFF per Z-Wave protocol specification - uint8_t checksum = 0xFF; - for (uint8_t i = 1; i < length - 1; i++) { - checksum ^= data[i]; - } - return checksum; + return xor_bytes(data + 1, length - 2, 0xFF); } ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } From cef9418a0f77a47410ee1e9a43a9b26026ee0e5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 03:20:28 -1000 Subject: [PATCH 500/651] [i2c][uart][spi] Add new_i2c_component, new_uart_component and new_spi_component (#20908) --- esphome/codegen.py | 1 + esphome/components/i2c/__init__.py | 11 ++++ esphome/components/spi/__init__.py | 12 ++++ esphome/components/uart/__init__.py | 12 ++++ esphome/cpp_helpers.py | 22 ++++++- .../components/test_new_bus_component.py | 54 +++++++++++++++++ tests/unit_tests/test_cpp_helpers.py | 58 +++++++++++++++++++ 7 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/components/test_new_bus_component.py diff --git a/esphome/codegen.py b/esphome/codegen.py index 9ed36de0d55..64f2ca9f04d 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -58,6 +58,7 @@ from esphome.cpp_helpers import ( # noqa: F401 extract_registry_entry_config, get_slot_count, gpio_pin_expression, + new_bus_component, past_safe_mode, register_component, register_parented, diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 5bf64609f53..84f6d0c2e66 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Coroutine import logging import re import sys @@ -394,6 +395,16 @@ async def register_i2c_device(var: MockObj, config: ConfigType) -> None: cg.add(var.set_i2c_address(config[CONF_ADDRESS])) +def new_i2c_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Create a component on an i2c bus: construct, register, attach to the bus. + + Extra args go to the constructor. + """ + return cg.new_bus_component(register_i2c_device, config, *args) + + def final_validate_device_schema( name: str, *, diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d6949cc5909..92d5baccf76 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Coroutine import re from typing import Any @@ -504,6 +505,17 @@ async def register_spi_device( cg.add(var.set_psram_dma(psram_dma)) +def new_spi_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Create a component on an SPI bus: construct, register, attach to the bus. + + Extra args go to the constructor. Write-only devices call register_spi_device + themselves with write_only=True. + """ + return cg.new_bus_component(register_spi_device, config, *args) + + def final_validate_device_schema( name: str, *, require_mosi: bool, require_miso: bool ) -> cv.Schema: diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index fc811e385f4..dd6ae47bbfa 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,7 +1,9 @@ +from collections.abc import Coroutine from dataclasses import dataclass, field from logging import getLogger import math import re +from typing import Any from esphome import automation, pins import esphome.codegen as cg @@ -591,6 +593,16 @@ async def register_uart_device(var, config): cg.add(var.set_uart_parent(parent)) +def new_uart_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Create a component on a UART bus: construct, register, attach to the bus. + + Extra args go to the constructor. + """ + return cg.new_bus_component(register_uart_device, config, *args) + + @automation.register_action( "uart.write", UARTWriteAction, diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index fc44d27f472..c744b09728e 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,8 +1,10 @@ -from collections.abc import Callable, Hashable +from collections.abc import Awaitable, Callable, Hashable from dataclasses import dataclass, field import logging +from typing import Any from esphome.const import ( + CONF_ID, CONF_SAFE_MODE, CONF_SETUP_PRIORITY, CONF_TYPE_ID, @@ -12,11 +14,13 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine, coroutine_with_priority from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import ( + MockObj, RawStatement, add, add_define, add_global, get_variable, + new_Pvariable, ) from esphome.cpp_types import App from esphome.helpers import cpp_string_escape @@ -294,6 +298,22 @@ async def register_component(var, config): return var +async def new_bus_component( + register_device: Callable[[MockObj, ConfigType], Awaitable[None]], + config: ConfigType, + *args: Any, +) -> MockObj: + """Create a component, register it and attach it to its bus. + + Extra args go to the constructor. Bus wrappers must be plain functions + returning this coroutine, so the component source stays their caller. + """ + var = new_Pvariable(config[CONF_ID], *args) + await register_component(var, config) + await register_device(var, config) + return var + + async def register_parented(var, value): if isinstance(value, ID): paren = await get_variable(value) diff --git a/tests/unit_tests/components/test_new_bus_component.py b/tests/unit_tests/components/test_new_bus_component.py new file mode 100644 index 00000000000..54f947d6257 --- /dev/null +++ b/tests/unit_tests/components/test_new_bus_component.py @@ -0,0 +1,54 @@ +"""Tests for the new__component helpers.""" + +from collections.abc import Callable +import inspect +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from esphome import cpp_helpers +from esphome.components import i2c, spi, uart +from esphome.const import CONF_ID + + +@pytest.mark.parametrize( + ("helper", "module", "register_name"), + [ + (i2c.new_i2c_component, i2c, "register_i2c_device"), + (uart.new_uart_component, uart, "register_uart_device"), + (spi.new_spi_component, spi, "register_spi_device"), + ], +) +@pytest.mark.asyncio +async def test_creates_registers_and_attaches_in_order( + helper: Callable[..., Any], module: Any, register_name: str +) -> None: + config = {CONF_ID: "my_id"} + var = MagicMock(name="var") + order = MagicMock() + order.new_Pvariable.return_value = var + with ( + patch.object(cpp_helpers, "new_Pvariable", order.new_Pvariable), + patch.object( + cpp_helpers, "register_component", AsyncMock(side_effect=order.register) + ), + patch.object(module, register_name, AsyncMock(side_effect=order.bus)), + ): + result = await helper(config, 1, "two") + + assert result is var + assert order.mock_calls == [ + call.new_Pvariable("my_id", 1, "two"), + call.register(var, config), + call.bus(var, config), + ] + + +@pytest.mark.parametrize( + "helper", + [i2c.new_i2c_component, uart.new_uart_component, spi.new_spi_component], +) +def test_wrappers_are_plain_functions(helper: Callable[..., Any]) -> None: + # An async wrapper would stay on the stack and become the component source. + assert not inspect.iscoroutinefunction(helper) diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 725c1daebb4..7f56faddfbc 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,4 +1,8 @@ +import importlib.util import logging +from pathlib import Path +import sys +from types import ModuleType from unittest.mock import Mock import pytest @@ -243,3 +247,57 @@ def test_slot_counter_request_after_emit_raises() -> None: assert _define_value("TEST_SLOT_COUNT_TOO_LATE") == "1" with pytest.raises(ValueError, match="TEST_SLOT_COUNT_TOO_LATE"): request() + + +def _load_module( + monkeypatch: pytest.MonkeyPatch, name: str, path: Path, source: str +) -> ModuleType: + path.write_text(source, encoding="utf-8") + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, module) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + ("wrapper", "expected"), + [ + # A plain wrapper returns the coroutine, so its frame is gone when + # register_component looks for the source; an async one is still on the stack. + ("def new_component(config):\n return ", "fake_dev"), + ("async def new_component(config):\n return await ", "fake_bus"), + ], +) +@pytest.mark.asyncio +async def test_new_bus_component_source_is_the_caller( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, wrapper: str, expected: str +) -> None: + bus = _load_module( + monkeypatch, + "esphome.components.fake_bus", + tmp_path / "fake_bus.py", + "from esphome import cpp_helpers as ch\n" + "async def register_device(var, config):\n" + " pass\n" + f"{wrapper}ch.new_bus_component(register_device, config)\n", + ) + dev = _load_module( + monkeypatch, + "esphome.components.fake_dev", + tmp_path / "fake_dev.py", + "async def to_code(new_component, config):\n" + " return await new_component(config)\n", + ) + base = Mock() + base.__str__ = lambda self: "foo.bar" + var = Mock(base=base) + monkeypatch.setattr(ch, "new_Pvariable", Mock(return_value=var)) + monkeypatch.setattr(ch, "CORE", Mock(component_ids=["foo.bar"], data={})) + monkeypatch.setattr(ch, "add", Mock()) + monkeypatch.setattr(ch, "App", Mock()) + source = Mock(return_value=1) + monkeypatch.setattr(ch, "register_component_source", source) + result = await dev.to_code(bus.new_component, {const.CONF_ID: "foo.bar"}) + assert result is var + source.assert_called_once_with(expected) From 91dfb92c60c30748ce0eaae8dd668c5380a9a29c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 03:22:29 -1000 Subject: [PATCH 501/651] [esp32_ble] Test ble.enabled, ble.enable and ble.disable (#20838) --- tests/components/esp32_ble/common.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/esp32_ble/common.yaml b/tests/components/esp32_ble/common.yaml index 76b35fc8f8b..b2114f5dc41 100644 --- a/tests/components/esp32_ble/common.yaml +++ b/tests/components/esp32_ble/common.yaml @@ -1,2 +1,13 @@ esp32_ble: io_capability: keyboard_only + +button: + - platform: template + name: Toggle BLE + on_press: + - if: + condition: ble.enabled + then: + - ble.disable: + else: + - ble.enable: From d08c845c65cef5fbb7e1367a22df917fab2f8c0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:36:49 -1000 Subject: [PATCH 502/651] [components] Create I2C devices with new_i2c_component (#20909) --- esphome/components/ade7880/sensor.py | 4 +--- esphome/components/ads1115/__init__.py | 5 +---- esphome/components/ags10/sensor.py | 5 +---- esphome/components/aht10/sensor.py | 5 +---- esphome/components/aic3204/audio_dac.py | 6 ++---- esphome/components/am2315c/sensor.py | 5 +---- esphome/components/am2320/sensor.py | 5 +---- esphome/components/apds9930/sensor.py | 5 +---- esphome/components/apds9960/__init__.py | 5 +---- esphome/components/as5600/__init__.py | 5 +---- esphome/components/as7341/sensor.py | 5 +---- esphome/components/as734x/__init__.py | 5 +---- esphome/components/at581x/__init__.py | 4 +--- esphome/components/bh1745/__init__.py | 5 +---- esphome/components/bm8563/time.py | 6 ++---- esphome/components/bme680/sensor.py | 5 +---- esphome/components/bme680_bsec/__init__.py | 4 +--- esphome/components/bmi160/sensor.py | 5 +---- esphome/components/bmp085/sensor.py | 5 +---- esphome/components/bq25186/__init__.py | 4 +--- esphome/components/bq27220/sensor.py | 5 +---- esphome/components/bq76972/__init__.py | 5 +---- esphome/components/ccs811/sensor.py | 5 +---- esphome/components/dht12/sensor.py | 5 +---- esphome/components/dps310/sensor.py | 5 +---- esphome/components/ds248x/__init__.py | 6 ++---- esphome/components/ds3231/__init__.py | 5 +---- esphome/components/ee895/sensor.py | 5 +---- esphome/components/emc2101/__init__.py | 6 ++---- esphome/components/ens210/sensor.py | 5 +---- esphome/components/es7210/audio_adc.py | 6 ++---- esphome/components/es7243e/audio_adc.py | 6 ++---- esphome/components/es8156/audio_dac.py | 4 +--- esphome/components/es8311/audio_dac.py | 6 ++---- esphome/components/es8388/audio_dac.py | 5 +---- esphome/components/es9038q2m_katana/audio_dac.py | 6 ++---- esphome/components/ezo_pmp/__init__.py | 4 +--- esphome/components/fusb302b/__init__.py | 5 +---- esphome/components/gdk101/__init__.py | 5 +---- esphome/components/gp8403/__init__.py | 6 ++---- esphome/components/grove_gas_mc_v2/sensor.py | 5 +---- esphome/components/grove_tb6612fng/__init__.py | 4 +--- esphome/components/hdc1080/sensor.py | 5 +---- esphome/components/hdc2010/sensor.py | 5 +---- esphome/components/hdc2080/sensor.py | 5 +---- esphome/components/hdc302x/sensor.py | 5 +---- esphome/components/hm3301/sensor.py | 5 +---- esphome/components/hmc5883l/sensor.py | 5 +---- esphome/components/honeywell_hih_i2c/sensor.py | 5 +---- esphome/components/honeywellabp2_i2c/sensor.py | 5 +---- esphome/components/hte501/sensor.py | 5 +---- esphome/components/htu21d/sensor.py | 5 +---- esphome/components/htu31d/sensor.py | 5 +---- esphome/components/husb238/__init__.py | 5 +---- esphome/components/hyt271/sensor.py | 5 +---- esphome/components/i2c_device/__init__.py | 4 +--- esphome/components/ina219/sensor.py | 5 +---- esphome/components/ina226/sensor.py | 5 +---- esphome/components/ina3221/sensor.py | 5 +---- esphome/components/kmeteriso/sensor.py | 5 +---- esphome/components/lc709203f/sensor.py | 5 +---- esphome/components/lp5562/__init__.py | 5 +---- esphome/components/lps22/sensor.py | 5 +---- esphome/components/ltr390/sensor.py | 5 +---- esphome/components/ltr501/sensor.py | 5 +---- esphome/components/ltr_als_ps/sensor.py | 5 +---- esphome/components/m5stack_8angle/__init__.py | 5 +---- esphome/components/max17043/sensor.py | 4 +--- esphome/components/max17048/sensor.py | 5 +---- esphome/components/max44009/sensor.py | 5 +---- esphome/components/max6956/__init__.py | 4 +--- esphome/components/max9611/sensor.py | 5 +---- esphome/components/mcp23016/__init__.py | 4 +--- esphome/components/mcp4461/__init__.py | 7 ++----- esphome/components/mcp4725/output.py | 4 +--- esphome/components/mcp4728/__init__.py | 5 +---- esphome/components/mcp47a1/output.py | 4 +--- esphome/components/mics_4514/sensor.py | 5 +---- esphome/components/mlx90393/sensor.py | 5 +---- esphome/components/mlx90614/sensor.py | 5 +---- esphome/components/mmc5603/sensor.py | 5 +---- esphome/components/mmc5983/sensor.py | 5 +---- esphome/components/mpl3115a2/sensor.py | 5 +---- esphome/components/mpu6050/sensor.py | 5 +---- esphome/components/mpu6886/sensor.py | 5 +---- esphome/components/ms5611/sensor.py | 5 +---- esphome/components/ms8607/sensor.py | 5 +---- esphome/components/msa3xx/__init__.py | 5 +---- esphome/components/nau7802/sensor.py | 4 +--- esphome/components/npi19/sensor.py | 5 +---- esphome/components/pca6416a/__init__.py | 4 +--- esphome/components/pca9505/__init__.py | 4 +--- esphome/components/pcf8574/__init__.py | 4 +--- esphome/components/pcm5122/audio_dac.py | 4 +--- esphome/components/pi4ioe5v6408/__init__.py | 4 +--- esphome/components/pm2005/sensor.py | 5 +---- esphome/components/pmsa003i/sensor.py | 5 +---- esphome/components/pmwcs3/sensor.py | 5 +---- esphome/components/qmc5883l/sensor.py | 5 +---- esphome/components/qmp6988/sensor.py | 5 +---- esphome/components/rx8111/time.py | 5 +---- esphome/components/rx8130/time.py | 5 +---- esphome/components/scd30/sensor.py | 5 +---- esphome/components/scd4x/sensor.py | 4 +--- esphome/components/sen5x/sensor.py | 4 +--- esphome/components/sen6x/sensor.py | 4 +--- esphome/components/sfa30/sensor.py | 5 +---- esphome/components/sfa40/sensor.py | 4 +--- esphome/components/sgp30/sensor.py | 5 +---- esphome/components/sgp4x/sensor.py | 5 +---- esphome/components/sht3xd/sensor.py | 5 +---- esphome/components/sht4x/sensor.py | 5 +---- esphome/components/shtcx/sensor.py | 5 +---- esphome/components/sps30/sensor.py | 4 +--- esphome/components/stcc4/sensor.py | 5 +---- esphome/components/sx1509/__init__.py | 4 +--- esphome/components/sy6970/__init__.py | 7 ++----- esphome/components/tas2780/audio_dac.py | 6 ++---- esphome/components/tas58xx/audio_dac.py | 4 +--- esphome/components/tca8418/__init__.py | 6 ++---- esphome/components/tca9548a/__init__.py | 4 +--- esphome/components/tca9555/__init__.py | 4 +--- esphome/components/tcs34725/sensor.py | 5 +---- esphome/components/tem3200/sensor.py | 5 +---- esphome/components/tfluna/__init__.py | 5 +---- esphome/components/tlc59208f/__init__.py | 5 +---- esphome/components/tsl2591/sensor.py | 5 +---- esphome/components/ttp229_lsf/__init__.py | 5 +---- esphome/components/veml7700/sensor.py | 5 +---- esphome/components/waveshare_io_ch32v003/__init__.py | 4 +--- esphome/components/xdb401/sensor.py | 5 +---- esphome/components/xgzp68xx/sensor.py | 5 +---- esphome/components/xl9535/__init__.py | 4 +--- tests/unit_tests/components/test_pcf8574.py | 4 +--- 134 files changed, 147 insertions(+), 507 deletions(-) diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 93c279e2359..541d37603e1 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -282,9 +282,7 @@ async def power_channel(config: ConfigType) -> MockObj: 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) + var = await i2c.new_i2c_component(config) if irq0_pin := config.get(CONF_IRQ0_PIN): pin = await cg.gpio_pin_expression(irq0_pin) diff --git a/esphome/components/ads1115/__init__.py b/esphome/components/ads1115/__init__.py index 1b8405322fa..7358c88db7c 100644 --- a/esphome/components/ads1115/__init__.py +++ b/esphome/components/ads1115/__init__.py @@ -1,7 +1,6 @@ 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 DEPENDENCIES = ["i2c"] @@ -27,8 +26,6 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_continuous_mode(config[CONF_CONTINUOUS_MODE])) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 8fd18671f81..407245407a2 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -4,7 +4,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_ID, CONF_MODE, CONF_TVOC, CONF_VALUE, @@ -58,9 +57,7 @@ FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency= 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TVOC, var.set_tvoc) diff --git a/esphome/components/aht10/sensor.py b/esphome/components/aht10/sensor.py index 974e7d4df4c..6065d702bde 100644 --- a/esphome/components/aht10/sensor.py +++ b/esphome/components/aht10/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, CONF_VARIANT, DEVICE_CLASS_HUMIDITY, @@ -49,9 +48,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_variant(config[CONF_VARIANT])) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index b4942fe3908..52cd24b2021 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MODE +from esphome.const import CONF_MODE from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -41,6 +41,4 @@ automation.register_apply_action( 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) + await i2c.new_i2c_component(config) diff --git a/esphome/components/am2315c/sensor.py b/esphome/components/am2315c/sensor.py index 39ded956c3f..9e9da3a34ce 100644 --- a/esphome/components/am2315c/sensor.py +++ b/esphome/components/am2315c/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -42,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/am2320/sensor.py b/esphome/components/am2320/sensor.py index 30a1b1d8a34..b70ac70e6b2 100644 --- a/esphome/components/am2320/sensor.py +++ b/esphome/components/am2320/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/apds9930/sensor.py b/esphome/components/apds9930/sensor.py index 2b169271c68..9895985438d 100644 --- a/esphome/components/apds9930/sensor.py +++ b/esphome/components/apds9930/sensor.py @@ -3,7 +3,6 @@ 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, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(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])) diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py index 8c19eec5ca1..e32984970f5 100644 --- a/esphome/components/apds9960/__init__.py +++ b/esphome/components/apds9960/__init__.py @@ -1,7 +1,6 @@ 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 DEPENDENCIES = ["i2c"] @@ -60,9 +59,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(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])) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index 3cc5ed5cc1a..d695e34235f 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_DIR_PIN, CONF_DIRECTION, CONF_HYSTERESIS, - CONF_ID, CONF_POWER_MODE, CONF_RANGE, CONF_WATCHDOG, @@ -209,9 +208,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_direction(config[CONF_DIRECTION])) cg.add(var.set_watchdog(config[CONF_WATCHDOG])) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index 3afab8422f9..65307def3d0 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR, CONF_GAIN, - CONF_ID, DEVICE_CLASS_EMPTY, ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, @@ -97,9 +96,7 @@ SENSORS = { 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_atime(config[CONF_ATIME])) diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index 61eeaa5a35b..dc45ddd1308 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CALIBRATION, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_TYPE, CONF_UPDATE_INTERVAL, ) @@ -166,9 +165,7 @@ def _per_band( async def to_code(config: ConfigType) -> None: chip = config[CONF_TYPE] cg.add_define("USE_AS7341" if chip == "AS7341" else "USE_AS7343") - var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&as734x::{chip}_CHIP")) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config, cg.RawExpression(f"&as734x::{chip}_CHIP")) cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_atime(config[CONF_ATIME])) cg.add(var.set_astep(config[CONF_ASTEP])) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index fd1a8002921..2ba284bd223 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -73,9 +73,7 @@ CONFIG_SCHEMA = cv.All( 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) + await i2c.new_i2c_component(config) # Actions diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py index a922340df71..daba7af4753 100644 --- a/esphome/components/bh1745/__init__.py +++ b/esphome/components/bh1745/__init__.py @@ -6,7 +6,6 @@ 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 @@ -62,9 +61,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_adc_gain(config[CONF_GAIN])) cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME])) diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index c28976eb872..6bbacf307a2 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_DURATION, CONF_ID +from esphome.const import CONF_DURATION from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -50,7 +50,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index 73f48526be6..8883659e979 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_GAS_RESISTANCE, CONF_HEATER, CONF_HUMIDITY, - CONF_ID, CONF_IIR_FILTER, CONF_OVERSAMPLING, CONF_PRESSURE, @@ -115,9 +114,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 55fd1c975f5..1562b4f33b4 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -73,9 +73,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_device_id(str(config[CONF_ID]))) cg.add(var.set_temperature_offset(config[CONF_TEMPERATURE_OFFSET])) diff --git a/esphome/components/bmi160/sensor.py b/esphome/components/bmi160/sensor.py index 9ad1b19fa0e..845a37e91f5 100644 --- a/esphome/components/bmi160/sensor.py +++ b/esphome/components/bmi160/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_GYROSCOPE_X, CONF_GYROSCOPE_Y, CONF_GYROSCOPE_Z, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, ICON_ACCELERATION_X, @@ -84,9 +83,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ACCELERATION_X, var.set_accel_x_sensor) diff --git a/esphome/components/bmp085/sensor.py b/esphome/components/bmp085/sensor.py index 5eb8dae7314..898faff8531 100644 --- a/esphome/components/bmp085/sensor.py +++ b/esphome/components/bmp085/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index 03a100d9297..26799b8ce51 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -338,9 +338,7 @@ CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( 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) + var = await i2c.new_i2c_component(config) if config[CONF_CONFIGURE_ON_BOOT]: cg.add(var.set_setup_registers(cg.ArrayInitializer(*pack_registers(config)))) diff --git a/esphome/components/bq27220/sensor.py b/esphome/components/bq27220/sensor.py index a3fcec22432..418632b2c67 100644 --- a/esphome/components/bq27220/sensor.py +++ b/esphome/components/bq27220/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CURRENT, - CONF_ID, CONF_TEMPERATURE, CONF_VOLTAGE, DEVICE_CLASS_BATTERY, @@ -108,9 +107,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py index 5ccf043c250..672d6428d26 100644 --- a/esphome/components/bq76972/__init__.py +++ b/esphome/components/bq76972/__init__.py @@ -4,7 +4,6 @@ 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, @@ -75,9 +74,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED])) cg.add(var.set_reg_disable(config[CONF_REG_DISABLED])) diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index 95e51c825a8..80948a6b433 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BASELINE, CONF_ECO2, CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, CONF_TVOC, CONF_VERSION, @@ -61,9 +60,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ECO2, var.set_co2) diff --git a/esphome/components/dht12/sensor.py b/esphome/components/dht12/sensor.py index 88877b2dc26..677b158633a 100644 --- a/esphome/components/dht12/sensor.py +++ b/esphome/components/dht12/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -42,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/dps310/sensor.py b/esphome/components/dps310/sensor.py index e3827b484c4..7d6ab7b66c2 100644 --- a/esphome/components/dps310/sensor.py +++ b/esphome/components/dps310/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -50,9 +49,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 3a2cb34747a..02faa806f7a 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE +from esphome.const import CONF_SLEEP_PIN, CONF_TYPE from esphome.types import ConfigType CODEOWNERS = ["@tomwellnitz"] @@ -86,9 +86,7 @@ def get_channel_count(config: ConfigType) -> int: 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_active_pullup(config[CONF_ACTIVE_PULLUP])) cg.add(var.set_channel_count(get_channel_count(config))) diff --git a/esphome/components/ds3231/__init__.py b/esphome/components/ds3231/__init__.py index 262c951e80f..b3e496a883c 100644 --- a/esphome/components/ds3231/__init__.py +++ b/esphome/components/ds3231/__init__.py @@ -2,7 +2,6 @@ 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"] @@ -23,9 +22,7 @@ CONFIG_SCHEMA = ( 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) + await i2c.new_i2c_component(config) automation.register_apply_action( diff --git a/esphome/components/ee895/sensor.py b/esphome/components/ee895/sensor.py index a7110bd6d2e..fb0f8c59551 100644 --- a/esphome/components/ee895/sensor.py +++ b/esphome/components/ee895/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CO2, - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -53,9 +52,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 024ff3c8937..19661dbb584 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION +from esphome.const import CONF_INVERTED, CONF_RESOLUTION from esphome.types import ConfigType CODEOWNERS = ["@ellull"] @@ -71,9 +71,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) if pwm_config := config.get(CONF_PWM): cg.add(var.set_dac_mode(False)) diff --git a/esphome/components/ens210/sensor.py b/esphome/components/ens210/sensor.py index cb49986f7c5..a390b24eccb 100644 --- a/esphome/components/ens210/sensor.py +++ b/esphome/components/ens210/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/es7210/audio_adc.py b/esphome/components/es7210/audio_adc.py index c2dd40b3e86..09f575629d6 100644 --- a/esphome/components/es7210/audio_adc.py +++ b/esphome/components/es7210/audio_adc.py @@ -3,7 +3,7 @@ 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.const import CONF_BITS_PER_SAMPLE, CONF_MIC_GAIN, CONF_SAMPLE_RATE from esphome.core import CORE from esphome.types import ConfigType @@ -66,9 +66,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) if channel_gains := config.get(CONF_CHANNEL_GAINS): diff --git a/esphome/components/es7243e/audio_adc.py b/esphome/components/es7243e/audio_adc.py index 49161339822..c354ffcc6fb 100644 --- a/esphome/components/es7243e/audio_adc.py +++ b/esphome/components/es7243e/audio_adc.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_adc import AudioAdc import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MIC_GAIN +from esphome.const import CONF_MIC_GAIN from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -28,8 +28,6 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) diff --git a/esphome/components/es8156/audio_dac.py b/esphome/components/es8156/audio_dac.py index ebba75e70dd..e00f67571e6 100644 --- a/esphome/components/es8156/audio_dac.py +++ b/esphome/components/es8156/audio_dac.py @@ -48,6 +48,4 @@ 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 i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index 036ee93bd3e..785c91fe1c8 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_MIC_GAIN, CONF_SAMPLE_RATE from esphome.types import ConfigType CODEOWNERS = ["@kroimon", "@kahrendt"] @@ -58,9 +58,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) diff --git a/esphome/components/es8388/audio_dac.py b/esphome/components/es8388/audio_dac.py index 2616cbfa536..ac1cc9f8177 100644 --- a/esphome/components/es8388/audio_dac.py +++ b/esphome/components/es8388/audio_dac.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@P4uLT"] @@ -22,6 +21,4 @@ CONFIG_SCHEMA = ( 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) + await i2c.new_i2c_component(config) diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py index 0568e5e202a..5f2a5ab557e 100644 --- a/esphome/components/es9038q2m_katana/audio_dac.py +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import audio_dac, i2c import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_SAMPLE_RATE +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_SAMPLE_RATE from esphome.types import ConfigType CONF_DEEMPHASIS = "deemphasis" @@ -73,9 +73,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_filter_shape(config[CONF_FILTER_SHAPE])) cg.add( diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index aed071e8cf5..9d2c90529c0 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -34,9 +34,7 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) EZO_PMP_NO_ARGS_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index c9dd24463b4..26bf49b28e3 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -4,7 +4,6 @@ from esphome.components import i2c, sensor, text_sensor import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, - CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_CONNECT, CONF_ON_DISCONNECT, @@ -99,9 +98,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - var = cg.new_Pvariable(config[CONF_ID], interrupt_pin) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config, interrupt_pin) cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/gdk101/__init__.py b/esphome/components/gdk101/__init__.py index e0ede6136dc..4463da8e685 100644 --- a/esphome/components/gdk101/__init__.py +++ b/esphome/components/gdk101/__init__.py @@ -1,7 +1,6 @@ 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 = ["@Szewcson"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = ( 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) + await i2c.new_i2c_component(config) diff --git a/esphome/components/gp8403/__init__.py b/esphome/components/gp8403/__init__.py index 59bab37d255..d19228fea74 100644 --- a/esphome/components/gp8403/__init__.py +++ b/esphome/components/gp8403/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MODEL, CONF_VOLTAGE +from esphome.const import CONF_MODEL, CONF_VOLTAGE from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@sebydocky"] @@ -35,8 +35,6 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_model(config[CONF_MODEL])) cg.add(var.set_voltage(config[CONF_VOLTAGE])) diff --git a/esphome/components/grove_gas_mc_v2/sensor.py b/esphome/components/grove_gas_mc_v2/sensor.py index da687c4cd34..3378d94bf41 100644 --- a/esphome/components/grove_gas_mc_v2/sensor.py +++ b/esphome/components/grove_gas_mc_v2/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CARBON_MONOXIDE, CONF_ETHANOL, - CONF_ID, CONF_NITROGEN_DIOXIDE, CONF_TVOC, DEVICE_CLASS_CARBON_MONOXIDE, @@ -68,9 +67,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) for key in [CONF_TVOC, CONF_CARBON_MONOXIDE, CONF_NITROGEN_DIOXIDE, CONF_ETHANOL]: if sensor_config := config.get(key): diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 0874613c5cf..501071f42b9 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -38,9 +38,7 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) automation.register_apply_action( diff --git a/esphome/components/hdc1080/sensor.py b/esphome/components/hdc1080/sensor.py index c35488c96c3..4f5dc69a6c9 100644 --- a/esphome/components/hdc1080/sensor.py +++ b/esphome/components/hdc1080/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/hdc2010/sensor.py b/esphome/components/hdc2010/sensor.py index 9837b9af333..93b469d20d8 100644 --- a/esphome/components/hdc2010/sensor.py +++ b/esphome/components/hdc2010/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/hdc2080/sensor.py b/esphome/components/hdc2080/sensor.py index 63625bbf948..3e511f6a880 100644 --- a/esphome/components/hdc2080/sensor.py +++ b/esphome/components/hdc2080/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -45,9 +44,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 604730f971a..94239ce0310 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -8,7 +8,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DURATION, CONF_HUMIDITY, - CONF_ID, CONF_POWER, CONF_POWER_MODE, CONF_TEMPERATURE, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temp_sensor) diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index d5e15f7c620..a8eefa4865a 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -6,7 +6,6 @@ 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, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -89,9 +88,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index 229d6c8b623..72d9148b62e 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, CONF_HEADING, - CONF_ID, CONF_OVERSAMPLING, CONF_RANGE, CONF_UPDATE_INTERVAL, @@ -129,9 +128,7 @@ def auto_data_rate(config: ConfigType) -> MockObj: 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(auto_data_rate(config))) diff --git a/esphome/components/honeywell_hih_i2c/sensor.py b/esphome/components/honeywell_hih_i2c/sensor.py index 8ab2cd16b8b..d06e19747f0 100644 --- a/esphome/components/honeywell_hih_i2c/sensor.py +++ b/esphome/components/honeywell_hih_i2c/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/honeywellabp2_i2c/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index 7f41fe21939..2d0f212d812 100644 --- a/esphome/components/honeywellabp2_i2c/sensor.py +++ b/esphome/components/honeywellabp2_i2c/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -56,9 +55,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_PRESSURE, var.set_pressure_sensor): diff --git a/esphome/components/hte501/sensor.py b/esphome/components/hte501/sensor.py index 80d59862959..8218dc32cbc 100644 --- a/esphome/components/hte501/sensor.py +++ b/esphome/components/hte501/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index bde4b244b4a..6f8d95f63c8 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_HEATER, CONF_HUMIDITY, - CONF_ID, CONF_LEVEL, CONF_MODEL, CONF_STATUS, @@ -61,9 +60,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/htu31d/sensor.py b/esphome/components/htu31d/sensor.py index 41cc54686a4..5fed7143ed0 100644 --- a/esphome/components/htu31d/sensor.py +++ b/esphome/components/htu31d/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/husb238/__init__.py b/esphome/components/husb238/__init__.py index 92bf8c998ff..e0e4b576eb7 100644 --- a/esphome/components/husb238/__init__.py +++ b/esphome/components/husb238/__init__.py @@ -1,7 +1,6 @@ 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"] @@ -28,6 +27,4 @@ CONFIG_SCHEMA = ( 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) + await i2c.new_i2c_component(config) diff --git a/esphome/components/hyt271/sensor.py b/esphome/components/hyt271/sensor.py index 1fe5d9e14aa..1f13d16bfbc 100644 --- a/esphome/components/hyt271/sensor.py +++ b/esphome/components/hyt271/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/i2c_device/__init__.py b/esphome/components/i2c_device/__init__.py index a118cb3f172..a79b158b2fb 100644 --- a/esphome/components/i2c_device/__init__.py +++ b/esphome/components/i2c_device/__init__.py @@ -23,6 +23,4 @@ CONFIG_SCHEMA = cv.Schema( 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) + await i2c.new_i2c_component(config) diff --git a/esphome/components/ina219/sensor.py b/esphome/components/ina219/sensor.py index 0a81adc785e..98246b2a819 100644 --- a/esphome/components/ina219/sensor.py +++ b/esphome/components/ina219/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_MAX_CURRENT, CONF_MAX_VOLTAGE, CONF_POWER, @@ -72,9 +71,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_shunt_resistance_ohm(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_max_current_a(config[CONF_MAX_CURRENT])) diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index 53427fc63e1..40e2e41ffed 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -7,7 +7,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_MAX_CURRENT, CONF_POWER, CONF_SHUNT_RESISTANCE, @@ -115,9 +114,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_shunt_resistance_ohm(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_max_current_a(config[CONF_MAX_CURRENT])) diff --git a/esphome/components/ina3221/sensor.py b/esphome/components/ina3221/sensor.py index e872b73b09f..69e66a3382d 100644 --- a/esphome/components/ina3221/sensor.py +++ b/esphome/components/ina3221/sensor.py @@ -7,7 +7,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_POWER, CONF_SHUNT_RESISTANCE, CONF_SHUNT_VOLTAGE, @@ -199,9 +198,7 @@ def alert_limit_register(current_a: float, shunt_ohm: float) -> int: 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) + var = await i2c.new_i2c_component(config) bus_us, shunt_us = adc_times_us(config) register = ( diff --git a/esphome/components/kmeteriso/sensor.py b/esphome/components/kmeteriso/sensor.py index 5b7087362f9..d0637d27873 100644 --- a/esphome/components/kmeteriso/sensor.py +++ b/esphome/components/kmeteriso/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 67e5942a43b..8ebb14d2ca7 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, - CONF_ID, CONF_SIZE, CONF_TEMPERATURE, CONF_VOLTAGE, @@ -72,9 +71,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_pack_size(config.get(CONF_SIZE))) cg.add(var.set_pack_voltage(BATTERY_VOLTAGE_OPTIONS[config[CONF_VOLTAGE]])) diff --git a/esphome/components/lp5562/__init__.py b/esphome/components/lp5562/__init__.py index 4a5b77f36bb..f6c9956d70f 100644 --- a/esphome/components/lp5562/__init__.py +++ b/esphome/components/lp5562/__init__.py @@ -1,7 +1,6 @@ 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"] @@ -27,8 +26,6 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if config[CONF_LOGARITHMIC_DIMMING]: cg.add(var.set_logarithmic_dimming(True)) diff --git a/esphome/components/lps22/sensor.py b/esphome/components/lps22/sensor.py index f219639eeea..8590ec5fbeb 100644 --- a/esphome/components/lps22/sensor.py +++ b/esphome/components/lps22/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index 6c25729beec..69ee5020c48 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_AMBIENT_LIGHT, CONF_GAIN, - CONF_ID, CONF_LIGHT, CONF_RESOLUTION, DEVICE_CLASS_EMPTY, @@ -119,9 +118,7 @@ TYPES = { 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_wfac_value(config[CONF_WINDOW_CORRECTION_FACTOR])) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index 51378e4cddf..ca9416f924f 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM_COUNTS, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INTEGRATION_TIME, CONF_NAME, CONF_REPEAT, @@ -225,9 +224,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 223528581e9..5ebaed1f249 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM_COUNTS, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INTEGRATION_TIME, CONF_NAME, CONF_REPEAT, @@ -215,9 +214,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) diff --git a/esphome/components/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index 96b9782e811..ef686174659 100644 --- a/esphome/components/m5stack_8angle/__init__.py +++ b/esphome/components/m5stack_8angle/__init__.py @@ -1,7 +1,6 @@ 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 DEPENDENCIES = ["i2c"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = cv.Schema( 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) + await i2c.new_i2c_component(config) diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 1b621057c67..4fdc6fda544 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -49,9 +49,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BATTERY_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/max17048/sensor.py b/esphome/components/max17048/sensor.py index 30cd0ec59f0..40f676c9fe1 100644 --- a/esphome/components/max17048/sensor.py +++ b/esphome/components/max17048/sensor.py @@ -4,7 +4,6 @@ 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, @@ -56,9 +55,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor) diff --git a/esphome/components/max44009/sensor.py b/esphome/components/max44009/sensor.py index 09b480335ec..a74dc8242ac 100644 --- a/esphome/components/max44009/sensor.py +++ b/esphome/components/max44009/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_MODE, DEVICE_CLASS_ILLUMINANCE, STATE_CLASS_MEASUREMENT, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) await sensor.register_sensor(var, config) cg.add(var.set_mode(config[CONF_MODE])) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index b5d5041c968..6dd9dc0b09f 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -51,9 +51,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_brightness_mode(config[CONF_BRIGHTNESS_MODE])) cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) diff --git a/esphome/components/max9611/sensor.py b/esphome/components/max9611/sensor.py index e6309b21ccb..e1f705cdbab 100644 --- a/esphome/components/max9611/sensor.py +++ b/esphome/components/max9611/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, CONF_GAIN, - CONF_ID, CONF_POWER, CONF_SHUNT_RESISTANCE, CONF_TEMPERATURE, @@ -72,9 +71,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_current_resistor(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_gain(config[CONF_GAIN])) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 7dddf4b0422..ab77bc5a5b3 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -37,9 +37,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index d5465098f29..8f12212b481 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -1,7 +1,6 @@ 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 = ["@p1ngb4ck"] @@ -33,12 +32,10 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable( - config[CONF_ID], + await i2c.new_i2c_component( + config, config[CONF_DISABLE_WIPER_0], config[CONF_DISABLE_WIPER_1], config[CONF_DISABLE_WIPER_2], config[CONF_DISABLE_WIPER_3], ) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mcp4725/output.py b/esphome/components/mcp4725/output.py index 0c1224a1a7f..5dda2145b15 100644 --- a/esphome/components/mcp4725/output.py +++ b/esphome/components/mcp4725/output.py @@ -21,7 +21,5 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) await output.register_output(var, config) diff --git a/esphome/components/mcp4728/__init__.py b/esphome/components/mcp4728/__init__.py index 7156b4aa7fe..54678204bc3 100644 --- a/esphome/components/mcp4728/__init__.py +++ b/esphome/components/mcp4728/__init__.py @@ -1,7 +1,6 @@ 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 = ["@berfenger"] @@ -27,6 +26,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_STORE_IN_EEPROM]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config, config[CONF_STORE_IN_EEPROM]) diff --git a/esphome/components/mcp47a1/output.py b/esphome/components/mcp47a1/output.py index 91bb3b47dbb..2386ac12abb 100644 --- a/esphome/components/mcp47a1/output.py +++ b/esphome/components/mcp47a1/output.py @@ -22,7 +22,5 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) await output.register_output(var, config) diff --git a/esphome/components/mics_4514/sensor.py b/esphome/components/mics_4514/sensor.py index 3ba560c781a..1dee96e5abc 100644 --- a/esphome/components/mics_4514/sensor.py +++ b/esphome/components/mics_4514/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CARBON_MONOXIDE, CONF_ETHANOL, CONF_HYDROGEN, - CONF_ID, CONF_METHANE, CONF_NITROGEN_DIOXIDE, DEVICE_CLASS_CARBON_MONOXIDE, @@ -58,9 +57,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) for sensor_type in SENSORS: if sensor_config := config.get(sensor_type): diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index 4ff42c7d9c7..d6a777e7d88 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FILTER, CONF_GAIN, - CONF_ID, CONF_OVERSAMPLING, CONF_RESOLUTION, CONF_TEMPERATURE, @@ -129,9 +128,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_gain(GAIN[config[CONF_GAIN]])) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) diff --git a/esphome/components/mlx90614/sensor.py b/esphome/components/mlx90614/sensor.py index e818ea708d0..8a4c4e90eff 100644 --- a/esphome/components/mlx90614/sensor.py +++ b/esphome/components/mlx90614/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, @@ -49,9 +48,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT, var.set_ambient_sensor) diff --git a/esphome/components/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index 70c70877b75..874b774829e 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, CONF_HEADING, - CONF_ID, CONF_UPDATE_INTERVAL, ICON_MAGNET, ICON_SCREEN_ROTATION, @@ -77,9 +76,7 @@ def auto_data_rate(config: ConfigType) -> MockObj: 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_datarate(auto_data_rate(config))) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/mmc5983/sensor.py b/esphome/components/mmc5983/sensor.py index 48c532b0c67..4ed73664bf7 100644 --- a/esphome/components/mmc5983/sensor.py +++ b/esphome/components/mmc5983/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_X, CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, - CONF_ID, ICON_MAGNET, STATE_CLASS_MEASUREMENT, UNIT_MICROTESLA, @@ -41,9 +40,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) diff --git a/esphome/components/mpl3115a2/sensor.py b/esphome/components/mpl3115a2/sensor.py index 8235e2d6183..c97b8df2b32 100644 --- a/esphome/components/mpl3115a2/sensor.py +++ b/esphome/components/mpl3115a2/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ALTITUDE, - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PRESSURE, var.set_pressure) diff --git a/esphome/components/mpu6050/sensor.py b/esphome/components/mpu6050/sensor.py index bd3de2d32cd..093ededcd64 100644 --- a/esphome/components/mpu6050/sensor.py +++ b/esphome/components/mpu6050/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, ICON_BRIEFCASE_DOWNLOAD, @@ -66,9 +65,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ACCEL_X, var.set_accel_x_sensor) diff --git a/esphome/components/mpu6886/sensor.py b/esphome/components/mpu6886/sensor.py index 878540d5160..aa60ad962de 100644 --- a/esphome/components/mpu6886/sensor.py +++ b/esphome/components/mpu6886/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, ICON_BRIEFCASE_DOWNLOAD, @@ -67,9 +66,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ACCEL_X, var.set_accel_x_sensor) diff --git a/esphome/components/ms5611/sensor.py b/esphome/components/ms5611/sensor.py index bf6d4528e68..d6f7012985d 100644 --- a/esphome/components/ms5611/sensor.py +++ b/esphome/components/ms5611/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ms8607/sensor.py b/esphome/components/ms8607/sensor.py index 13377394beb..83609916ef4 100644 --- a/esphome/components/ms8607/sensor.py +++ b/esphome/components/ms8607/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, @@ -64,9 +63,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 3c491ffc898..4623e1d66ff 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -4,7 +4,6 @@ from esphome.components import i2c import esphome.config_validation as cv from esphome.const import ( CONF_CALIBRATION, - CONF_ID, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_OFFSET_X, @@ -121,9 +120,7 @@ MSA_SENSOR_SCHEMA = cv.Schema( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_model(config[CONF_TYPE])) cg.add(var.set_range(MSA_RANGES[config[CONF_RANGE]])) diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 2647d40441a..ac46a9fb786 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -82,9 +82,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) await sensor.register_sensor(var, config) cg.add(var.set_samples_per_second(config[CONF_SAMPLES_PER_SECOND])) diff --git a/esphome/components/npi19/sensor.py b/esphome/components/npi19/sensor.py index 14dcd72384d..e3521383736 100644 --- a/esphome/components/npi19/sensor.py +++ b/esphome/components/npi19/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -40,9 +39,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 69d8935977c..33f8af9d528 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -41,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index d50e8205a17..4e3acb94e6e 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -41,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 5ede39d7145..f1db7c29748 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -44,9 +44,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) if (reset := config[CONF_RESET]) != DEFAULT_RESET: cg.add(var.set_reset(reset)) diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index 5091efabeac..cb50b101c13 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -133,9 +133,7 @@ async def pcm5122_pin_to_code(config: ConfigType) -> MockObj: 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index cd901cb448d..4128114b3cc 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -46,9 +46,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_reset(config[CONF_RESET])) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): diff --git a/esphome/components/pm2005/sensor.py b/esphome/components/pm2005/sensor.py index 0e36992141b..76ca2b6862b 100644 --- a/esphome/components/pm2005/sensor.py +++ b/esphome/components/pm2005/sensor.py @@ -4,7 +4,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -65,9 +64,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: """Code generation entry point.""" - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_sensor_type(config[CONF_TYPE])) diff --git a/esphome/components/pmsa003i/sensor.py b/esphome/components/pmsa003i/sensor.py index 14f27f89ccd..04e3b1a70af 100644 --- a/esphome/components/pmsa003i/sensor.py +++ b/esphome/components/pmsa003i/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -116,9 +115,7 @@ TYPES = { 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_standard_units(config[CONF_STANDARD_UNITS])) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index 61f89afa41b..5420317716b 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, CONF_EC, - CONF_ID, CONF_TEMPERATURE, ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, @@ -63,9 +62,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_E25, var.set_e25_sensor) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index 06091c4f1ca..deba7eab7e0 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, CONF_HEADING, - CONF_ID, CONF_OVERSAMPLING, CONF_RANGE, CONF_TEMPERATURE, @@ -143,9 +142,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(config[CONF_DATA_RATE])) diff --git a/esphome/components/qmp6988/sensor.py b/esphome/components/qmp6988/sensor.py index 955e7026062..b357f03ea31 100644 --- a/esphome/components/qmp6988/sensor.py +++ b/esphome/components/qmp6988/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_IIR_FILTER, CONF_OVERSAMPLING, CONF_PRESSURE, @@ -78,9 +77,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if CONF_TEMPERATURE in config: conf = config[CONF_TEMPERATURE] diff --git a/esphome/components/rx8111/time.py b/esphome/components/rx8111/time.py index 0b166807a22..fc6a1cca0d5 100644 --- a/esphome/components/rx8111/time.py +++ b/esphome/components/rx8111/time.py @@ -4,7 +4,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@techdregs"] @@ -30,7 +29,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 2082c487593..964141596fd 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@beormund"] @@ -34,7 +33,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index f3f4ede8c93..3c39f3e7c21 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_AUTOMATIC_SELF_CALIBRATION, CONF_CO2, CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, CONF_TEMPERATURE_OFFSET, CONF_UPDATE_INTERVAL, @@ -79,9 +78,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_automatic_self_calibration(config[CONF_AUTOMATIC_SELF_CALIBRATION])) if CONF_ALTITUDE_COMPENSATION in config: diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index e78ae2a6b22..12a07a3f63a 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -101,9 +101,7 @@ SETTING_MAP = { 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) + var = await i2c.new_i2c_component(config) for key, funcName in SETTING_MAP.items(): if key in config: diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index d45bb2c7916..b3fde64f820 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -219,9 +219,7 @@ SETTING_MAP = { 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) + var = await i2c.new_i2c_component(config) for key, funcName in SETTING_MAP.items(): if cfg := config.get(key): diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index a5c46801887..9893ef80233 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -183,9 +183,7 @@ SENSOR_MAP = { 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) + var = await i2c.new_i2c_component(config) if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) diff --git a/esphome/components/sfa30/sensor.py b/esphome/components/sfa30/sensor.py index b8039af9e3e..8a07b79e149 100644 --- a/esphome/components/sfa30/sensor.py +++ b/esphome/components/sfa30/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, @@ -68,9 +67,7 @@ SENSOR_MAP = { 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) + var = await i2c.new_i2c_component(config) for key, funcName in SENSOR_MAP.items(): if sensor_config := config.get(key): diff --git a/esphome/components/sfa40/sensor.py b/esphome/components/sfa40/sensor.py index 701660eb8fe..ca5f72de6dd 100644 --- a/esphome/components/sfa40/sensor.py +++ b/esphome/components/sfa40/sensor.py @@ -68,9 +68,7 @@ SENSOR_MAP = { async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_wait_for_ready(config[CONF_WAIT_FOR_READY])) for key, func_name in SENSOR_MAP.items(): diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index b56df3fb2e1..5d4c43fccae 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_BASELINE, CONF_COMPENSATION, CONF_ECO2, - CONF_ID, CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, CONF_TVOC, @@ -83,9 +82,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ECO2, var.set_eco2_sensor) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index bf8fbe1a975..866445f2464 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -11,7 +11,6 @@ from esphome.const import ( CONF_COMPENSATION, CONF_GAIN_FACTOR, CONF_GATING_MAX_DURATION_MINUTES, - CONF_ID, CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, @@ -100,9 +99,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) if CONF_COMPENSATION in config: compensation_config = config[CONF_COMPENSATION] diff --git a/esphome/components/sht3xd/sensor.py b/esphome/components/sht3xd/sensor.py index baee146a22d..8d1aa5d7b4e 100644 --- a/esphome/components/sht3xd/sensor.py +++ b/esphome/components/sht3xd/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -50,9 +49,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_heater_enabled(config[CONF_HEATER_ENABLED])) diff --git a/esphome/components/sht4x/sensor.py b/esphome/components/sht4x/sensor.py index d491d94ba14..48c652224ab 100644 --- a/esphome/components/sht4x/sensor.py +++ b/esphome/components/sht4x/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -84,9 +83,7 @@ TYPES = { 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_precision_value(config[CONF_PRECISION])) cg.add(var.set_heater_power_value(config[CONF_HEATER_POWER])) diff --git a/esphome/components/shtcx/sensor.py b/esphome/components/shtcx/sensor.py index d6ca2b131bd..41d3e72b247 100644 --- a/esphome/components/shtcx/sensor.py +++ b/esphome/components/shtcx/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -47,9 +46,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index b8a4e080213..d86d5b7ecf5 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -117,9 +117,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index 4385164384a..b103aa39534 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, CONF_CO2, CONF_HUMIDITY, - CONF_ID, CONF_MEASUREMENT_MODE, CONF_TEMPERATURE, CONF_TEMPERATURE_SOURCE, @@ -101,9 +100,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index f96479498d6..c44c52a6fc6 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -90,9 +90,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if conf := config.get(CONF_KEYPAD): cg.add(var.set_rows_cols(conf[CONF_KEY_ROWS], conf[CONF_KEY_COLUMNS])) if ( diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 7ed2b4fe869..23aaceb6a3c 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -1,7 +1,6 @@ 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"] @@ -61,8 +60,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable( - config[CONF_ID], + await i2c.new_i2c_component( + config, config[CONF_ENABLE_STATUS_LED], config[CONF_INPUT_CURRENT_LIMIT], config[CONF_CHARGE_VOLTAGE], @@ -72,5 +71,3 @@ async def to_code(config: ConfigType) -> None: config[CONF_ENABLE_ADC], config[CONF_I2C_WATCHDOG_TIMEOUT], ) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) diff --git a/esphome/components/tas2780/audio_dac.py b/esphome/components/tas2780/audio_dac.py index 9fbee8cc297..407ae55604e 100644 --- a/esphome/components/tas2780/audio_dac.py +++ b/esphome/components/tas2780/audio_dac.py @@ -4,7 +4,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_ID, CONF_POWER_MODE +from esphome.const import CONF_CHANNEL, CONF_POWER_MODE from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -97,9 +97,7 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_amp_level(config[CONF_AMP_LEVEL])) cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 94d37832eb4..332d994d5e9 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -168,9 +168,7 @@ for _name, _call in ( async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL]] cg.add_define(f"USE_TAS58XX_{config[CONF_MODEL].upper()}") - var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&{model.model_info}")) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config, cg.RawExpression(f"&{model.model_info}")) cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) cg.add(var.set_dac_mode(config[CONF_DAC_MODE])) diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py index 67b38dc21ee..ee9decd6680 100644 --- a/esphome/components/tca8418/__init__.py +++ b/esphome/components/tca8418/__init__.py @@ -5,7 +5,7 @@ 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.const import CONF_INTERRUPT_PIN, CONF_ON_KEY from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -131,9 +131,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) if (rows := config.get(CONF_ROWS)) is not None: columns = config[CONF_COLUMNS] diff --git a/esphome/components/tca9548a/__init__.py b/esphome/components/tca9548a/__init__.py index 368e34f3250..c28ceb015ea 100644 --- a/esphome/components/tca9548a/__init__.py +++ b/esphome/components/tca9548a/__init__.py @@ -78,9 +78,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(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)) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index a039872e164..0554f9a569d 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -40,9 +40,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index de327f0eba7..12fc3475740 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -11,7 +11,6 @@ from esphome.const import ( CONF_COLOR_TEMPERATURE, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_ILLUMINANCE, CONF_INTEGRATION_TIME, DEVICE_CLASS_ILLUMINANCE, @@ -102,9 +101,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_integration_time(config[CONF_INTEGRATION_TIME])) cg.add(var.set_gain(config[CONF_GAIN])) diff --git a/esphome/components/tem3200/sensor.py b/esphome/components/tem3200/sensor.py index 2b1839c529f..7eb35ad5337 100644 --- a/esphome/components/tem3200/sensor.py +++ b/esphome/components/tem3200/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -42,9 +41,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/tfluna/__init__.py b/esphome/components/tfluna/__init__.py index 6914894ba78..7bd7e0f5c94 100644 --- a/esphome/components/tfluna/__init__.py +++ b/esphome/components/tfluna/__init__.py @@ -1,7 +1,6 @@ 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 = ["@candrews"] @@ -30,6 +29,4 @@ CONFIG_SCHEMA = ( 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) + await i2c.new_i2c_component(config) diff --git a/esphome/components/tlc59208f/__init__.py b/esphome/components/tlc59208f/__init__.py index 5d719e40ba2..c87478715f3 100644 --- a/esphome/components/tlc59208f/__init__.py +++ b/esphome/components/tlc59208f/__init__.py @@ -1,7 +1,6 @@ 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 DEPENDENCIES = ["i2c"] @@ -23,6 +22,4 @@ CONFIG_SCHEMA = ( 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) + await i2c.new_i2c_component(config) diff --git a/esphome/components/tsl2591/sensor.py b/esphome/components/tsl2591/sensor.py index ee6ebaeb75c..7f4e9e43db8 100644 --- a/esphome/components/tsl2591/sensor.py +++ b/esphome/components/tsl2591/sensor.py @@ -31,7 +31,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, @@ -136,9 +135,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FULL_SPECTRUM, var.set_full_spectrum_sensor) diff --git a/esphome/components/ttp229_lsf/__init__.py b/esphome/components/ttp229_lsf/__init__.py index e89b177a3ee..1d802ec5c67 100644 --- a/esphome/components/ttp229_lsf/__init__.py +++ b/esphome/components/ttp229_lsf/__init__.py @@ -1,7 +1,6 @@ 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 DEPENDENCIES = ["i2c"] @@ -28,6 +27,4 @@ CONFIG_SCHEMA = ( 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) + await i2c.new_i2c_component(config) diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 9ed98fc4dfd..01e71aef26b 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM_COUNTS, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, @@ -156,9 +155,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index 5e53d30149b..0b89ca8b561 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -45,9 +45,7 @@ CONFIG_SCHEMA = ( 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) + await i2c.new_i2c_component(config) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/xdb401/sensor.py b/esphome/components/xdb401/sensor.py index df274ac09b3..d18122b70f8 100644 --- a/esphome/components/xdb401/sensor.py +++ b/esphome/components/xdb401/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -50,9 +49,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_pressure_range_bar(config[CONF_PRESSURE_RANGE_BAR])) diff --git a/esphome/components/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index b67b2d8a9b4..778fb48647f 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_OVERSAMPLING, CONF_PRESSURE, CONF_TEMPERATURE, @@ -67,9 +66,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 1c6548bb5fe..85330357d2d 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -33,9 +33,7 @@ CONFIG_SCHEMA = ( 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) + await i2c.new_i2c_component(config) def validate_mode(mode: ConfigType) -> ConfigType: diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py index 43751815358..e5b6519700d 100644 --- a/tests/unit_tests/components/test_pcf8574.py +++ b/tests/unit_tests/components/test_pcf8574.py @@ -22,9 +22,7 @@ async def test_reset_configuration(options: dict, expected: bool) -> None: assert config[CONF_RESET] is expected var = Mock() with ( - patch.object(pcf8574.cg, "new_Pvariable", return_value=var), - patch.object(pcf8574.cg, "register_component", new_callable=AsyncMock), - patch.object(pcf8574.i2c, "register_i2c_device", new_callable=AsyncMock), + patch.object(pcf8574.i2c, "new_i2c_component", AsyncMock(return_value=var)), patch.object(pcf8574.cg, "add"), ): await pcf8574.to_code(config) From 905267a9c30aae83323e8d9f7dcdfddbaac804a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:38:25 -1000 Subject: [PATCH 503/651] [esp32_ble] Build ble.enabled, ble.enable and ble.disable with the apply helpers (#20839) --- esphome/components/esp32_ble/__init__.py | 29 +++++------------------- esphome/components/esp32_ble/ble.h | 21 ----------------- 2 files changed, 6 insertions(+), 44 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 95a83edf76f..6c83d5e44fe 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -296,10 +296,6 @@ NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] esp32_ble_ns = cg.esphome_ns.namespace("esp32_ble") ESP32BLE = esp32_ble_ns.class_("ESP32BLE", cg.Component) -BLEEnabledCondition = esp32_ble_ns.class_("BLEEnabledCondition", automation.Condition) -BLEEnableAction = esp32_ble_ns.class_("BLEEnableAction", automation.Action) -BLEDisableAction = esp32_ble_ns.class_("BLEDisableAction", automation.Action) - IoCapability = esp32_ble_ns.enum("IoCapability") IO_CAPABILITY = { "none": IoCapability.IO_CAP_NONE, @@ -613,27 +609,14 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_ESP32_BLE_UUID") -automation.register_bare_condition( - "ble.enabled", - BLEEnabledCondition, - cv.Schema({}), -) +BLE_AUTOMATION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(ESP32BLE)}) +automation.register_apply_condition("ble.enabled", BLE_AUTOMATION_SCHEMA, "is_active()") -automation.register_bare_action( - "ble.enable", - BLEEnableAction, - cv.Schema({}), - synchronous=True, -) - - -automation.register_bare_action( - "ble.disable", - BLEDisableAction, - cv.Schema({}), - synchronous=True, -) +for _name, _call in (("ble.enable", "enable()"), ("ble.disable", "disable()")): + automation.register_apply_action( + _name, BLE_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) # ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index ef326e1532d..c21daabdbaa 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -259,27 +259,6 @@ class ESP32BLE final : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32BLE *global_ble; -template class BLEEnabledCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } -}; - -template class BLEEnableAction final : public Action { - public: - void play(const Ts &...x) override { - if (global_ble != nullptr) - global_ble->enable(); - } -}; - -template class BLEDisableAction final : public Action { - public: - void play(const Ts &...x) override { - if (global_ble != nullptr) - global_ble->disable(); - } -}; - } // namespace esphome::esp32_ble #endif From a384aae5b18a2cae264f34e8357e5501ed3a42a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:38:45 -1000 Subject: [PATCH 504/651] [button] Add factory reset, restart and query button schema presets (#20915) --- esphome/components/button/__init__.py | 39 +++++++++++++ tests/unit_tests/components/test_button.py | 65 ++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/unit_tests/components/test_button.py diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index f27475bdca3..a4543c06c07 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -15,6 +15,11 @@ from esphome.const import ( DEVICE_CLASS_IDENTIFY, DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_DATABASE, + ICON_RESTART, + ICON_RESTART_ALERT, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( @@ -83,6 +88,40 @@ def button_schema( return _BUTTON_SCHEMA.extend(schema) +def factory_reset_button_schema(class_: MockObjClass) -> cv.Schema: + """Schema for a button that resets the connected module to factory settings. + + For a sensor or radio module's own reset, not the ESPHome node. + """ + return button_schema( + class_, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, + ) + + +def restart_button_schema(class_: MockObjClass) -> cv.Schema: + """Schema for a button that restarts the connected module. + + For a sensor or radio module's own restart, not the ESPHome node; the core + restart button uses the config category instead of diagnostic. + """ + return button_schema( + class_, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_RESTART, + ) + + +def query_button_schema(class_: MockObjClass) -> cv.Schema: + """Schema for a button that reads the settings back from the connected module.""" + return button_schema( + class_, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_DATABASE + ) + + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_PRESS, "add_on_press_callback"), ) diff --git a/tests/unit_tests/components/test_button.py b/tests/unit_tests/components/test_button.py new file mode 100644 index 00000000000..3d1c3b43d47 --- /dev/null +++ b/tests/unit_tests/components/test_button.py @@ -0,0 +1,65 @@ +"""Tests for the button schema presets.""" + +from collections.abc import Callable + +import pytest +import voluptuous as vol + +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ENTITY_CATEGORY, + CONF_ICON, + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_DATABASE, + ICON_RESTART, + ICON_RESTART_ALERT, +) + +MyButton = button.button_ns.class_("MyButton", button.Button) + + +def _defaults(schema: cv.Schema) -> dict[str, str]: + return { + str(key): key.default() + for key in schema.schema + if str(key) in (CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_ICON) + and key.default is not vol.UNDEFINED + } + + +@pytest.mark.parametrize( + ("preset", "expected"), + [ + ( + button.factory_reset_button_schema, + { + CONF_DEVICE_CLASS: DEVICE_CLASS_RESTART, + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, + CONF_ICON: ICON_RESTART_ALERT, + }, + ), + ( + button.restart_button_schema, + { + CONF_DEVICE_CLASS: DEVICE_CLASS_RESTART, + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, + CONF_ICON: ICON_RESTART, + }, + ), + ( + button.query_button_schema, + { + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, + CONF_ICON: ICON_DATABASE, + }, + ), + ], +) +def test_preset_defaults( + preset: Callable[..., cv.Schema], expected: dict[str, str] +) -> None: + assert _defaults(preset(MyButton)) == expected From 73af6b05006da1331d72d6dce3088d49bc7ae2c1 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:40:39 +0200 Subject: [PATCH 505/651] [core][scheduler] run process_to_add after component phase (#20993) --- esphome/core/application.h | 2 + .../__init__.py | 23 ++++++++++ .../scheduler_loop_phase_component.cpp | 21 +++++++++ .../scheduler_loop_phase_component.h | 15 ++++++ .../scheduler_loop_phase_timeout.yaml | 20 ++++++++ .../test_scheduler_loop_phase_timeout.py | 46 +++++++++++++++++++ 6 files changed, 127 insertions(+) create mode 100644 tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp create mode 100644 tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h create mode 100644 tests/integration/fixtures/scheduler_loop_phase_timeout.yaml create mode 100644 tests/integration/test_scheduler_loop_phase_timeout.py diff --git a/esphome/core/application.h b/esphome/core/application.h index f9f98a67070..b19d4ae746b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -784,6 +784,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifdef USE_RUNTIME_STATS loop_tail_start_us = micros(); #endif + // process scheduler items added in component phase + this->scheduler.process_to_add(); this->last_loop_ = last_op_end_time; now = last_op_end_time; // phase_guard destructor clears in_loop_ at scope exit diff --git a/tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py new file mode 100644 index 00000000000..5aaf79a186b --- /dev/null +++ b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@esphome/tests"] + +scheduler_loop_phase_component_ns = cg.esphome_ns.namespace( + "scheduler_loop_phase_component" +) +SchedulerLoopPhaseComponent = scheduler_loop_phase_component_ns.class_( + "SchedulerLoopPhaseComponent", cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(SchedulerLoopPhaseComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp new file mode 100644 index 00000000000..5969dee384b --- /dev/null +++ b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp @@ -0,0 +1,21 @@ +#include "scheduler_loop_phase_component.h" + +#include "esphome/core/log.h" + +namespace esphome::scheduler_loop_phase_component { + +static const char *const TAG = "scheduler_loop_phase_component"; + +void SchedulerLoopPhaseComponent::loop() { + // There might be additional wakeups after the first loop phase because the test harness is connecting. + // The second component phase runs about 5s after boot, so the timeout is queued just before a long sleep. + if (++this->loop_count_ != 2) + return; + + const uint32_t start_time = millis(); + this->set_timeout(100, [start_time]() { + ESP_LOGI(TAG, "SCHEDULER_LOOP_PHASE_RESULT elapsed=%u", static_cast(millis() - start_time)); + }); +} + +} // namespace esphome::scheduler_loop_phase_component diff --git a/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h new file mode 100644 index 00000000000..aa65a19e901 --- /dev/null +++ b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::scheduler_loop_phase_component { + +class SchedulerLoopPhaseComponent : public Component { + public: + void loop() override; + + protected: + uint8_t loop_count_{0}; +}; + +} // namespace esphome::scheduler_loop_phase_component diff --git a/tests/integration/fixtures/scheduler_loop_phase_timeout.yaml b/tests/integration/fixtures/scheduler_loop_phase_timeout.yaml new file mode 100644 index 00000000000..2d2d1dc677d --- /dev/null +++ b/tests/integration/fixtures/scheduler_loop_phase_timeout.yaml @@ -0,0 +1,20 @@ +esphome: + name: scheduler-loop-phase-timeout + on_boot: + priority: -100 + then: + - lambda: |- + App.set_loop_interval(5000); + +host: +api: +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [scheduler_loop_phase_component] + +scheduler_loop_phase_component: diff --git a/tests/integration/test_scheduler_loop_phase_timeout.py b/tests/integration/test_scheduler_loop_phase_timeout.py new file mode 100644 index 00000000000..a8b2e76a863 --- /dev/null +++ b/tests/integration/test_scheduler_loop_phase_timeout.py @@ -0,0 +1,46 @@ +"""A timeout scheduled by a component loop must affect the current sleep.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .conftest import run_binary +from .types import CompileFunction, ConfigWriter + + +@pytest.mark.asyncio +async def test_scheduler_loop_phase_timeout( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, +) -> None: + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + loop = asyncio.get_running_loop() + timeout_result: asyncio.Future[int] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"SCHEDULER_LOOP_PHASE_RESULT elapsed=(\d+)", line) + if match and not timeout_result.done(): + timeout_result.set_result(int(match.group(1))) + + async with run_binary(binary_path, line_callback=on_log_line): + try: + elapsed = await asyncio.wait_for(timeout_result, timeout=15.0) + except TimeoutError: + pytest.fail("loop-phase timeout did not fire within 15 seconds") + + assert elapsed < 1000, ( + f"loop-phase timeout should interrupt the five-second sleep, but fired after {elapsed}ms" + ) From 34f9152d61b98d85b95d8097020746906e36baf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:41:20 -1000 Subject: [PATCH 506/651] [i2c] Add write_registers for constant register tables (#20945) --- esphome/components/i2c/i2c.cpp | 14 +++++ esphome/components/i2c/i2c.h | 14 +++++ .../i2c/test_i2c_write_registers.cpp | 60 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 tests/components/i2c/test_i2c_write_registers.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 59b0cb744fb..61382ccf21e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -53,6 +53,20 @@ ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, return this->bus_->write_readv(this->address_, buffer, len + 2, nullptr, 0); } +bool I2CDevice::write_registers(const I2CRegValue *table, size_t count) const { + for (size_t i = 0; i < count; i++) { + I2CRegValue entry; + progmem_memcpy(&entry, &table[i], sizeof(entry)); + if (this->bus_->write_readv(this->address_, reinterpret_cast(&entry), sizeof(entry), nullptr, 0) != + ERROR_OK) { + ESP_LOGW(TAG, "Writing register 0x%02X (table entry %u) to 0x%02X failed", entry.reg, static_cast(i), + this->address_); + return false; + } + } + return true; +} + bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { if (read_register(a_register, reinterpret_cast(data), len * 2) != ERROR_OK) return false; diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 4546047d603..6a9a6533436 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -14,6 +14,13 @@ namespace esphome::i2c { class I2CDevice; // forward declaration +/// One register write for I2CDevice::write_registers(); tables of these may live in PROGMEM. +struct I2CRegValue { + uint8_t reg; + uint8_t value; +}; +static_assert(sizeof(I2CRegValue) == 2, "each entry is sent to the bus as is"); + /// Out of line so each check_chip_id() caller only pays for a call. void log_wrong_chip_id(uint8_t id, uint8_t expected); @@ -213,6 +220,13 @@ class I2CDevice { /// @return an i2c::ErrorCode ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len) const; + /// Write each register and value of \p table in order, stopping at the first failure. + /// The table may be in PROGMEM. + bool write_registers(const I2CRegValue *table, size_t count) const; + template bool write_registers(const I2CRegValue (&table)[N]) const { + return this->write_registers(table, N); + } + /// Read the 8-bit ID at \p a_register and accept it when it matches \p expected in the bits set in \p mask. /// Otherwise mark \p component failed and return false. bool check_chip_id(Component *component, uint8_t a_register, uint8_t expected, uint8_t mask = 0xFF) { diff --git a/tests/components/i2c/test_i2c_write_registers.cpp b/tests/components/i2c/test_i2c_write_registers.cpp new file mode 100644 index 00000000000..133c86dcc0a --- /dev/null +++ b/tests/components/i2c/test_i2c_write_registers.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/hal.h" + +namespace esphome::i2c::testing { + +class RegTableBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, 0x18); + EXPECT_EQ(write_count, 2u); + EXPECT_EQ(read_count, 0u); + // Only this attempt fails, so any write after it would be recorded + if (this->attempts++ == this->fail_at) + return ERROR_NOT_ACKNOWLEDGED; + this->writes.emplace_back(write_buffer[0], write_buffer[1]); + return ERROR_OK; + } + + std::vector> writes; + size_t fail_at{SIZE_MAX}; + size_t attempts{0}; +}; + +static constexpr I2CRegValue INIT[] PROGMEM = {{0x01, 0x3A}, {0x00, 0x80}, {0xF9, 0x00}, {0x01, 0x00}}; + +class I2CWriteRegistersTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x18); + } + RegTableBus bus_; + I2CDevice device_; +}; + +TEST_F(I2CWriteRegistersTest, WritesEveryEntryInOrder) { + ASSERT_TRUE(this->device_.write_registers(INIT)); + const std::vector> expected{{0x01, 0x3A}, {0x00, 0x80}, {0xF9, 0x00}, {0x01, 0x00}}; + EXPECT_EQ(this->bus_.writes, expected); +} + +TEST_F(I2CWriteRegistersTest, StopsAtFirstFailure) { + this->bus_.fail_at = 2; + EXPECT_FALSE(this->device_.write_registers(INIT)); + const std::vector> expected{{0x01, 0x3A}, {0x00, 0x80}}; + EXPECT_EQ(this->bus_.writes, expected); +} + +TEST_F(I2CWriteRegistersTest, EmptyTableSucceeds) { + EXPECT_TRUE(this->device_.write_registers(INIT, 0)); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +} // namespace esphome::i2c::testing From 62f8cc343674136751941c5dbcf62b5690788830 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:41:51 -1000 Subject: [PATCH 507/651] [uart] Add read_available helpers to drain available bytes in chunks (#20947) --- esphome/components/uart/uart.h | 54 +++++++ tests/components/uart/uart_read_available.cpp | 144 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 tests/components/uart/uart_read_available.cpp diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index 7a34a20583e..a0840aa26a7 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -1,5 +1,8 @@ #pragma once +#include +#include +#include #include #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -48,6 +51,45 @@ class UARTDevice { size_t available() { return this->parent_->available(); } + /// Pass the bytes available now to on_chunk(data, len) in N byte chunks; returning false stops. + /// Returns false only on a read error. + template bool read_available_chunks(F &&on_chunk) { + return this->read_available_chunks(on_chunk, this->available()); + } + /// Same, reading exactly count bytes; count must not exceed available(). + template bool read_available_chunks(F &&on_chunk, size_t count) { + static_assert(N > 0, "read buffer size must be non-zero"); + uint8_t buf[N]; + while (count > 0) { + const size_t len = std::min(count, N); + if (!this->read_array(buf, len)) { + return false; + } + count -= len; + if (!continue_after(on_chunk, static_cast(buf), len)) { + break; + } + } + return true; + } + + /// Same as read_available_chunks(), calling on_byte(uint8_t) once per byte. + template bool read_available(F &&on_byte) { + return this->read_available(on_byte, this->available()); + } + template bool read_available(F &&on_byte, size_t count) { + return this->read_available_chunks( + [&on_byte](const uint8_t *data, size_t len) { + for (const uint8_t *p = data; p != data + len; p++) { + if (!continue_after(on_byte, *p)) { + return false; + } + } + return true; + }, + count); + } + UARTFlushResult flush() { return this->parent_->flush(); } // Compat APIs @@ -74,6 +116,18 @@ class UARTDevice { UARTParityOptions parity = UART_CONFIG_PARITY_NONE, uint8_t data_bits = 8); protected: + /// Call f(args...) and return its result; a void callback always continues. + template static bool continue_after(F &f, A... args) { + using R = std::invoke_result_t; + if constexpr (std::is_void_v) { + f(args...); + return true; + } else { + static_assert(std::is_same_v, bool>, "callback must return void or bool"); + return f(args...); + } + } + UARTComponent *parent_{nullptr}; }; diff --git a/tests/components/uart/uart_read_available.cpp b/tests/components/uart/uart_read_available.cpp new file mode 100644 index 00000000000..10c3e7fa0ef --- /dev/null +++ b/tests/components/uart/uart_read_available.cpp @@ -0,0 +1,144 @@ +#include +#include "common.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::uart::testing { + +// Feeds bytes from a fixed vector so available() and read_array() agree like a real UART. +class FakeRxUART : public MockUARTComponent { + public: + explicit FakeRxUART(std::vector rx) : rx_(std::move(rx)) { + ON_CALL(*this, available()).WillByDefault(Invoke([this]() { return this->rx_.size() - this->pos_; })); + ON_CALL(*this, read_array(_, _)).WillByDefault(Invoke([this](uint8_t *data, size_t len) { + this->read_sizes.push_back(len); + if (this->fail_after_reads >= 0 && static_cast(this->read_sizes.size()) > this->fail_after_reads) + return false; + if (len > this->rx_.size() - this->pos_) + return false; + std::memcpy(data, this->rx_.data() + this->pos_, len); + this->pos_ += len; + return true; + })); + } + void append(std::initializer_list more) { this->rx_.insert(this->rx_.end(), more); } + size_t remaining() const { return this->rx_.size() - this->pos_; } + + std::vector read_sizes; + int fail_after_reads{-1}; + + protected: + std::vector rx_; + size_t pos_{0}; +}; + +static std::vector make_bytes(size_t n) { + std::vector v(n); + for (size_t i = 0; i < n; i++) + v[i] = static_cast(i); + return v; +} + +TEST(UARTReadAvailableTest, NothingAvailableDoesNotRead) { + ::testing::NiceMock uart(std::vector{}); + UARTDevice dev(&uart); + int calls = 0; + EXPECT_TRUE(dev.read_available([&calls](uint8_t) { calls++; })); + EXPECT_EQ(calls, 0); + EXPECT_TRUE(uart.read_sizes.empty()); +} + +TEST(UARTReadAvailableTest, ReadsEveryByteInChunks) { + ::testing::NiceMock uart(make_bytes(10)); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<4>([&got](uint8_t b) { got.push_back(b); })); + EXPECT_EQ(got, make_bytes(10)); + EXPECT_EQ(uart.read_sizes, (std::vector{4, 4, 2})); +} + +TEST(UARTReadAvailableTest, OnlyBytesAvailableOnEntry) { + ::testing::NiceMock uart(std::vector{1, 2, 3}); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<2>([&](uint8_t b) { + got.push_back(b); + if (b == 1) + uart.append({9, 9}); // arrives while draining; left for the next call + })); + EXPECT_EQ(got, (std::vector{1, 2, 3})); + EXPECT_EQ(uart.remaining(), 2u); +} + +TEST(UARTReadAvailableTest, CountReadsExactlyThatMany) { + ::testing::NiceMock uart(make_bytes(100)); + UARTDevice dev(&uart); + size_t count = 0; + EXPECT_TRUE(dev.read_available<16>([&count](uint8_t) { count++; }, 40)); + EXPECT_EQ(count, 40u); + EXPECT_EQ(uart.read_sizes, (std::vector{16, 16, 8})); + EXPECT_EQ(uart.remaining(), 60u); +} + +TEST(UARTReadAvailableTest, ReadFailureStopsAndReturnsFalse) { + ::testing::NiceMock uart(make_bytes(10)); + uart.fail_after_reads = 1; + UARTDevice dev(&uart); + std::vector got; + EXPECT_FALSE(dev.read_available<4>([&got](uint8_t b) { got.push_back(b); })); + EXPECT_EQ(got, (std::vector{0, 1, 2, 3})); + EXPECT_EQ(uart.read_sizes.size(), 2u); +} + +TEST(UARTReadAvailableTest, ByteCallbackReturningFalseStops) { + ::testing::NiceMock uart(make_bytes(10)); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<4>([&got](uint8_t b) { + got.push_back(b); + return b != 5; + })); + // Stops mid chunk: bytes 6 and 7 were read from the UART but not handed over, 8 and 9 were never read. + EXPECT_EQ(got, (std::vector{0, 1, 2, 3, 4, 5})); + EXPECT_EQ(uart.remaining(), 2u); +} + +TEST(UARTReadAvailableTest, ChunkCallbackGetsWholeChunks) { + ::testing::NiceMock uart(make_bytes(70)); + UARTDevice dev(&uart); + std::vector lens; + std::vector got; + EXPECT_TRUE(dev.read_available_chunks([&](const uint8_t *data, size_t len) { + lens.push_back(len); + got.insert(got.end(), data, data + len); + })); + EXPECT_EQ(lens, (std::vector{64, 6})); + EXPECT_EQ(got, make_bytes(70)); +} + +TEST(UARTReadAvailableTest, ChunkCallbackReturningFalseStops) { + ::testing::NiceMock uart(make_bytes(20)); + UARTDevice dev(&uart); + int chunks = 0; + EXPECT_TRUE(dev.read_available_chunks<8>([&chunks](const uint8_t *, size_t) { + chunks++; + return false; + })); + EXPECT_EQ(chunks, 1); + EXPECT_EQ(uart.remaining(), 12u); +} + +TEST(UARTReadAvailableTest, ChunksWithCountStopEarly) { + ::testing::NiceMock uart(make_bytes(30)); + UARTDevice dev(&uart); + std::vector lens; + EXPECT_TRUE(dev.read_available_chunks<8>( + [&lens](const uint8_t *, size_t len) { + lens.push_back(len); + return lens.size() < 2; + }, + 20)); + EXPECT_EQ(lens, (std::vector{8, 8})); + EXPECT_EQ(uart.remaining(), 14u); +} + +} // namespace esphome::uart::testing From 25b684f7628326deb3b7b86acbd89ef69c359896 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:44:41 -1000 Subject: [PATCH 508/651] [pylontech] Parse each line as it arrives with one fixed buffer (#20959) --- esphome/components/pylontech/__init__.py | 31 +++ esphome/components/pylontech/pylontech.cpp | 192 +++++++------ esphome/components/pylontech/pylontech.h | 66 +++-- .../components/pylontech/sensor/__init__.py | 5 +- .../pylontech/sensor/pylontech_sensor.cpp | 7 +- .../pylontech/sensor/pylontech_sensor.h | 5 +- .../pylontech/text_sensor/__init__.py | 5 +- .../text_sensor/pylontech_text_sensor.cpp | 7 +- .../text_sensor/pylontech_text_sensor.h | 5 +- esphome/core/defines.h | 2 + tests/components/pylontech/__init__.py | 15 ++ tests/components/pylontech/pylontech_test.cpp | 252 ++++++++++++++++++ .../fixtures/uart_mock_pylontech.yaml | 105 ++++++++ tests/integration/test_uart_mock_pylontech.py | 136 ++++++++++ 14 files changed, 716 insertions(+), 117 deletions(-) create mode 100644 tests/components/pylontech/__init__.py create mode 100644 tests/components/pylontech/pylontech_test.cpp create mode 100644 tests/integration/fixtures/uart_mock_pylontech.yaml create mode 100644 tests/integration/test_uart_mock_pylontech.py diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index f9eef4c8096..8bd57f252c1 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -1,9 +1,12 @@ +from dataclasses import dataclass, field import logging 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.cpp_generator import MockObj from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -24,6 +27,33 @@ PylontechBattery = pylontech_ns.class_("PylontechBattery") CV_NUM_BATTERIES = cv.int_range(1, 16) +_request_listener_slot = cg.slot_counter("PYLONTECH_LISTENER_COUNT") +_request_battery_slot = cg.slot_counter("PYLONTECH_BATTERY_COUNT") + + +@dataclass +class PylontechData: + batteries: set[tuple[str, int]] = field(default_factory=set) + + +def _get_data() -> PylontechData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = PylontechData() + return CORE.data[DOMAIN] + + +async def register_listener(config: ConfigType, listener: MockObj) -> None: + """Register a battery listener; the hub keeps one line slot per distinct battery.""" + hub = await cg.get_variable(config[CONF_PYLONTECH_ID]) + hub_key = str(hub) + _request_listener_slot(hub_key) + data = _get_data() + if (key := (hub_key, config[CONF_BATTERY])) not in data.batteries: + data.batteries.add(key) + _request_battery_slot(hub_key) + cg.add(hub.register_listener(listener)) + + PYLONTECH_COMPONENT_SCHEMA = cv.Schema( { cv.GenerateID(CONF_PYLONTECH_ID): cv.use_id(PylontechComponent), @@ -53,5 +83,6 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 50d8c814fa4..5e48e8b726c 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -1,25 +1,35 @@ #include "pylontech.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#include +#include // Helper macros +#define PARSE_TOKEN_INT(field, field_name) \ + { \ + auto val = parse_number(token_buf); \ + if (val.has_value() && in_range(*val)) { \ + (field) = *val; \ + } else { \ + ESP_LOGV(TAG, "invalid " field_name " in line %s", buffer); \ + this->reject_line_(); \ + return; \ + } \ + } + #define PARSE_INT(field, field_name) \ { \ get_token(token_buf); \ - auto val = parse_number(token_buf); \ - if (val.has_value()) { \ - (field) = val.value(); \ - } else { \ - ESP_LOGD(TAG, "invalid " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ - return; \ - } \ + PARSE_TOKEN_INT(field, field_name) \ } #define PARSE_STR(field, field_name) \ { \ get_token(field); \ if (strlen(field) < 2) { \ - ESP_LOGD(TAG, "too short " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ + ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \ + this->reject_line_(); \ return; \ } \ } @@ -27,17 +37,26 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech"); -static const int MAX_DATA_LENGTH_BYTES = 256; -static const uint8_t ASCII_LF = 0x0A; + +// Rejects values that would wrap in a narrowed field +template static bool in_range(int value) { + if constexpr (sizeof(T) >= sizeof(int)) { + return true; + } else { + return value >= std::numeric_limits::min() && value <= std::numeric_limits::max(); + } +} PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { ESP_LOGCONFIG(TAG, "pylontech:"); +#ifdef PYLONTECH_LISTENER_COUNT for (PylontechListener *listener : this->listeners_) { listener->dump_config(); } +#endif LOG_UPDATE_INTERVAL(this); } @@ -51,41 +70,70 @@ void PylontechComponent::setup() { void PylontechComponent::update() { this->write_str("pwr\n"); } void PylontechComponent::loop() { + // Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle. size_t avail = this->available(); - if (avail > 0) { - // pylontech sends a lot of data very suddenly - // we need to quickly put it all into our own buffer, otherwise the uart's buffer will overflow - int recv = 0; - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; - } - avail -= to_read; - recv += to_read; - - for (size_t i = 0; i < to_read; i++) { - buffer_[buffer_index_write_] += (char) buf[i]; - if (buf[i] == ASCII_LF || buffer_[buffer_index_write_].length() >= MAX_DATA_LENGTH_BYTES) { - // complete line received - buffer_index_write_ = (buffer_index_write_ + 1) % NUM_BUFFERS; +#ifdef PYLONTECH_LISTENER_COUNT + if (avail == 0) { + this->dispatch_one_(); + return; + } +#endif + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; +#ifdef PYLONTECH_LISTENER_COUNT + for (size_t i = 0; i < to_read; i++) { + const char *line = this->line_reader_.feed(buf[i]); + if (line != nullptr) { + this->process_line_(line); + } else if (this->line_reader_.overflowed()) { + this->line_reader_.log_if_overflowed(TAG); + // Tlow.Id sits near the start, so a too long header still sets the column layout + const char *partial = this->line_reader_.partial(); + if (strncmp(partial, "Power", 5) == 0) { + this->parse_header_(partial); } } } - ESP_LOGV(TAG, "received %d bytes", recv); - } else { - // only process one line per call of loop() to not block esphome for too long - if (buffer_index_read_ != buffer_index_write_) { - this->process_line_(buffer_[buffer_index_read_]); - buffer_[buffer_index_read_].clear(); - buffer_index_read_ = (buffer_index_read_ + 1) % NUM_BUFFERS; - } +#endif } } -void PylontechComponent::process_line_(std::string &buffer) { - ESP_LOGV(TAG, "Read from serial: %s", buffer.substr(0, buffer.size() - 2).c_str()); +#ifdef PYLONTECH_LISTENER_COUNT +void PylontechComponent::dispatch_one_() { + for (uint8_t i = 0; this->pending_ != 0 && i < PYLONTECH_BATTERY_COUNT; i++) { + const battery_mask_t bit = 1u << i; + if ((this->pending_ & bit) == 0) { + continue; + } + this->pending_ &= ~bit; + PylontechListener::LineContents *l = &this->slots_[i]; + ESP_LOGV(TAG, "Publishing battery %u", l->bat_num); + for (PylontechListener *listener : this->listeners_) { + if (listener->get_battery() == l->bat_num) { + listener->on_line_read(l); + } + } + return; + } + if (this->rejected_ != 0) { + ESP_LOGW(TAG, "Rejected %u malformed battery lines", this->rejected_); + this->rejected_ = 0; + } +} + +void PylontechComponent::parse_header_(const char *buffer) { + this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr; + ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), + buffer); +} + +// Runs while the UART drains: no logging above VERBOSE and no publishing here +void PylontechComponent::process_line_(const char *buffer) { // clang-format off // example lines to parse: // Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St @@ -96,9 +144,7 @@ void PylontechComponent::process_line_(std::string &buffer) { // 1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal 60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal // clang-format on - PylontechListener::LineContents l{}; - - const char *cursor = buffer.c_str(); + const char *cursor = buffer; char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; // Helper Lambda to extract tokens @@ -116,7 +162,7 @@ void PylontechComponent::process_line_(std::string &buffer) { const char *start = cursor; // Find end of field - while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t' && *cursor != '\r') { + while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { cursor++; } @@ -125,23 +171,33 @@ void PylontechComponent::process_line_(std::string &buffer) { token_buf[token_len] = 0; }; - { - get_token(token_buf); - auto val = parse_number(token_buf); - if (val.has_value() && val.value() > 0) { - l.bat_num = val.value(); - } else if (strcmp(token_buf, "Power") == 0) { + get_token(token_buf); + auto bat = parse_number(token_buf); + if (!bat.has_value()) { + if (strcmp(token_buf, "Power") == 0) { // header line i.e. "Power Volt Curr" and so on - this->has_tlow_id_ = buffer.find("Tlow.Id") != std::string::npos; - ESP_LOGD(TAG, "header line %s Tlow.Id: %s", - this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), - buffer.substr(0, buffer.size() - 2).c_str()); - return; + this->parse_header_(buffer); } else { - ESP_LOGD(TAG, "unknown line %s", buffer.substr(0, buffer.size() - 2).c_str()); - return; + ESP_LOGV(TAG, "unknown line %s", buffer); } + return; } + const unsigned bat_index = static_cast(*bat) - 1u; + if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) { + return; // no listener for this battery + } + const battery_mask_t bat_bit = 1u << bat_index; + // The slot index is the number of listened batteries below this one + uint8_t slot = 0; + for (battery_mask_t below = this->batteries_ & (bat_bit - 1); below != 0; below &= below - 1) { + slot++; + } + const battery_mask_t slot_bit = 1u << slot; + // Cleared until the whole line parses, so a bad line never publishes a half written slot + this->pending_ &= ~slot_bit; + PylontechListener::LineContents &l = this->slots_[slot]; + l.bat_num = *bat; + PARSE_INT(l.volt, "Volt"); PARSE_INT(l.curr, "Curr"); PARSE_INT(l.tempr, "Tempr"); @@ -165,34 +221,20 @@ void PylontechComponent::process_line_(std::string &buffer) { PARSE_STR(l.volt_st, "Volt.St"); PARSE_STR(l.curr_st, "Curr.St"); PARSE_STR(l.temp_st, "Temp.St"); - { - get_token(token_buf); - for (char &i : token_buf) { - if (i == '%') { - i = 0; - break; - } - } - auto coul_val = parse_number(token_buf); - if (coul_val.has_value()) { - l.coulomb = coul_val.value(); - } else { - ESP_LOGD(TAG, "invalid Coulomb in line %s", buffer.substr(0, buffer.size() - 2).c_str()); - return; - } + get_token(token_buf); + if (char *pct = strchr(token_buf, '%')) { + *pct = '\0'; } + PARSE_TOKEN_INT(l.coulomb, "Coulomb"); get_token(token_buf); // Skip Date get_token(token_buf); // Skip Time get_token(token_buf); // Skip B.V.St get_token(token_buf); // Skip B.T.St PARSE_INT(l.mostempr, "Mostempr"); - ESP_LOGD(TAG, "successful line %s", buffer.substr(0, buffer.size() - 2).c_str()); - - for (PylontechListener *listener : this->listeners_) { - listener->on_line_read(&l); - } + this->pending_ |= slot_bit; } +#endif } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index eae5e6e7bca..aa652056dca 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -1,24 +1,42 @@ #pragma once +#include +#include + #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::pylontech { -static const uint8_t NUM_BUFFERS = 20; -static const uint8_t TEXT_SENSOR_MAX_LEN = 14; +static constexpr uint8_t TEXT_SENSOR_MAX_LEN = 14; +// Battery numbers run 1 to MAX_BATTERIES; bit n - 1 of a battery_mask_t stands for battery n +static constexpr uint8_t MAX_BATTERIES = 16; +using battery_mask_t = uint16_t; +static_assert(MAX_BATTERIES <= sizeof(battery_mask_t) * 8, "battery mask too narrow"); class PylontechListener { public: + // No default initializers: the hub's slots stay plain zeroed storage; a parse writes every field struct LineContents { - int bat_num = 0, volt, curr, tempr, tlow, thigh, vlow, vhigh, coulomb, mostempr; - char base_st[TEXT_SENSOR_MAX_LEN] = {0}, volt_st[TEXT_SENSOR_MAX_LEN] = {0}, curr_st[TEXT_SENSOR_MAX_LEN] = {0}, - temp_st[TEXT_SENSOR_MAX_LEN] = {0}; + int32_t curr, tempr, tlow, thigh, mostempr; + uint16_t volt, vlow, vhigh; // mV + uint8_t bat_num, coulomb; + char base_st[TEXT_SENSOR_MAX_LEN], volt_st[TEXT_SENSOR_MAX_LEN], curr_st[TEXT_SENSOR_MAX_LEN], + temp_st[TEXT_SENSOR_MAX_LEN]; }; - virtual void on_line_read(LineContents *line); - virtual void dump_config(); + explicit PylontechListener(uint8_t bat_num) : bat_num_(bat_num) {} + uint8_t get_battery() const { return this->bat_num_; } + + /// Called only with records for this listener's battery. + virtual void on_line_read(LineContents *line) = 0; + virtual void dump_config() = 0; + + protected: + uint8_t bat_num_; }; class PylontechComponent final : public PollingComponent, public uart::UARTDevice { @@ -33,18 +51,34 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic void setup() override; void dump_config() override; - void register_listener(PylontechListener *listener) { this->listeners_.push_back(listener); } +#ifdef PYLONTECH_LISTENER_COUNT + void register_listener(PylontechListener *listener) { + this->listeners_.push_back(listener); + this->batteries_ |= 1u << (listener->get_battery() - 1); + } protected: - void process_line_(std::string &buffer); + void process_line_(const char *buffer); + void parse_header_(const char *buffer); + void dispatch_one_(); + void reject_line_() { + if (this->rejected_ < UINT8_MAX) { + this->rejected_++; + } + } - // ring buffer - std::string buffer_[NUM_BUFFERS]; - int buffer_index_write_ = 0; - int buffer_index_read_ = 0; - bool has_tlow_id_ = false; - - std::vector listeners_{}; + // The longest line, the newer firmware header, is 182 characters + LineReader<200> line_reader_; + StaticVector listeners_; + // One slot per listened battery, in battery order; parsed while the UART drains, published one per + // loop once it is idle. A newer line for a battery replaces the unpublished one. + static_assert(PYLONTECH_BATTERY_COUNT <= MAX_BATTERIES, "more slots than batteries"); + std::array slots_; + battery_mask_t batteries_{0}; // batteries with a listener + battery_mask_t pending_{0}; // bit i set: slots_[i] is waiting to be published + bool has_tlow_id_{false}; + uint8_t rejected_{0}; // malformed battery lines since the last idle pass +#endif }; } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 40391206fbc..51aceace3e7 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -18,7 +18,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns +from .. import CONF_BATTERY, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns, register_listener PylontechSensor = pylontech_ns.class_("PylontechSensor") @@ -92,7 +92,6 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) for marker in TYPES: @@ -100,4 +99,4 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_sensor")(sens)) - cg.add(paren.register_listener(bat)) + await register_listener(config, bat) diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index dc236f47a55..bead0387bef 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -6,12 +6,10 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech.sensor"); -PylontechSensor::PylontechSensor(int8_t bat_num) { this->bat_num_ = bat_num; } - void PylontechSensor::dump_config() { ESP_LOGCONFIG(TAG, "Pylontech Sensor:\n" - " Battery %d", + " Battery %u", this->bat_num_); LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); LOG_SENSOR(" ", "Current", this->current_sensor_); @@ -25,9 +23,6 @@ void PylontechSensor::dump_config() { } void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { - if (this->bat_num_ != line->bat_num) { - return; - } if (this->voltage_sensor_ != nullptr) { this->voltage_sensor_->publish_state(((float) line->volt) / 1000.0f); } diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 1403d3445dd..6b466b74664 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -7,7 +7,7 @@ namespace esphome::pylontech { class PylontechSensor final : public PylontechListener { public: - PylontechSensor(int8_t bat_num); + explicit PylontechSensor(uint8_t bat_num) : PylontechListener(bat_num) {} void dump_config() override; SUB_SENSOR(voltage) @@ -22,9 +22,6 @@ class PylontechSensor final : public PylontechListener { SUB_SENSOR(mos_temperature) void on_line_read(LineContents *line) override; - - protected: - int8_t bat_num_; }; } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/text_sensor/__init__.py b/esphome/components/pylontech/text_sensor/__init__.py index 511eb7d542e..cb3e04a91d8 100644 --- a/esphome/components/pylontech/text_sensor/__init__.py +++ b/esphome/components/pylontech/text_sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns +from .. import CONF_BATTERY, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns, register_listener PylontechTextSensor = pylontech_ns.class_("PylontechTextSensor") @@ -26,7 +26,6 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) for marker in MARKERS: @@ -34,4 +33,4 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_text_sensor")(var)) - cg.add(paren.register_listener(bat)) + await register_listener(config, bat) diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp index 1beb04064ec..07b8e36df03 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp @@ -6,12 +6,10 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech.textsensor"); -PylontechTextSensor::PylontechTextSensor(int8_t bat_num) { this->bat_num_ = bat_num; } - void PylontechTextSensor::dump_config() { ESP_LOGCONFIG(TAG, "Pylontech Text Sensor:\n" - " Battery %d", + " Battery %u", this->bat_num_); LOG_TEXT_SENSOR(" ", "Base state", this->base_state_text_sensor_); LOG_TEXT_SENSOR(" ", "Voltage state", this->voltage_state_text_sensor_); @@ -20,9 +18,6 @@ void PylontechTextSensor::dump_config() { } void PylontechTextSensor::on_line_read(PylontechListener::LineContents *line) { - if (this->bat_num_ != line->bat_num) { - return; - } if (this->base_state_text_sensor_ != nullptr) { this->base_state_text_sensor_->publish_state(line->base_st); } diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index 3ba4f1fd4e7..6f229893c0c 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -7,7 +7,7 @@ namespace esphome::pylontech { class PylontechTextSensor final : public PylontechListener { public: - PylontechTextSensor(int8_t bat_num); + explicit PylontechTextSensor(uint8_t bat_num) : PylontechListener(bat_num) {} void dump_config() override; SUB_TEXT_SENSOR(base_state) @@ -16,9 +16,6 @@ class PylontechTextSensor final : public PylontechListener { SUB_TEXT_SENSOR(temperature_state) void on_line_read(LineContents *line) override; - - protected: - int8_t bat_num_; }; } // namespace esphome::pylontech diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3555b3cd1c2..8d17dc3f514 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -178,6 +178,8 @@ #define PN532_ON_TAG_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_TRIGGER_COUNT 1 +#define PYLONTECH_BATTERY_COUNT 1 +#define PYLONTECH_LISTENER_COUNT 2 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/tests/components/pylontech/__init__.py b/tests/components/pylontech/__init__.py new file mode 100644 index 00000000000..c16210128f6 --- /dev/null +++ b/tests/components/pylontech/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +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: + # Storage is sized by code generation; the gtests register three batteries. + cg.add_define("PYLONTECH_LISTENER_COUNT", 3) + cg.add_define("PYLONTECH_BATTERY_COUNT", 3) + cg.add_define("USE_LINE_READER") + + # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. + manifest.multi_conf = False + manifest.to_code = to_code_testing diff --git a/tests/components/pylontech/pylontech_test.cpp b/tests/components/pylontech/pylontech_test.cpp new file mode 100644 index 00000000000..479530571c0 --- /dev/null +++ b/tests/components/pylontech/pylontech_test.cpp @@ -0,0 +1,252 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/pylontech/pylontech.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::pylontech::testing { + +namespace { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(std::initializer_list lines) { + for (const std::string &line : lines) { + this->rx.insert(this->rx.end(), line.begin(), line.end()); + } + } + + void write_array(const uint8_t *data, size_t len) override {} + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +// Every record any listener gets, in publish order +std::vector published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +class TestListener final : public PylontechListener { + public: + explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {} + void on_line_read(LineContents *line) override { + EXPECT_EQ(line->bat_num, this->bat_num_); + published.push_back(*line); + } + void dump_config() override {} +}; + +// clang-format off +constexpr const char *HEADER = + "Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time" + " B.V.St B.T.St MosTempr M.T.St\r\n"; +constexpr const char *BAT1 = + "1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% " + "2021-06-30 20:49:45 Normal Normal 22700 Normal\r\n"; +constexpr const char *BAT2 = + "2 46012 -1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% " + "2025-11-28 17:56:33 Low Normal 7800 Normal\r\n"; +constexpr const char *NEW_HEADER = + "Power Volt Curr Tempr Tlow Tlow.Id Thigh Thigh.Id Vlow Vlow.Id Vhigh Vhigh.Id Base.St Volt.St Curr.St Temp.St " + "Coulomb Time B.V.St B.T.St MosTempr M.T.St SysAlarm.St\r\n"; +constexpr const char *NEW_BAT1 = + "1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal " + "60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n"; +// clang-format on + +// An older firmware line for battery bat with pack voltage volt (mV) +std::string bat_line(int bat, int volt) { + char buf[192]; + snprintf(buf, sizeof(buf), + "%d %d 0 17000 16000 18000 3200 3300 Idle Normal Normal Normal 50%% " + "2025-11-28 17:56:33 Normal Normal 16000 Normal\r\n", + bat, volt); + return buf; +} + +class PylontechTest : public ::testing::Test { + protected: + void SetUp() override { + published.clear(); + this->hub_.set_uart_parent(&this->uart_); + for (TestListener &listener : this->listeners_) { + this->hub_.register_listener(&listener); + } + } + + std::vector &published_() { return published; } + + MockUARTComponent uart_; + PylontechComponent hub_; + TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)}; +}; + +} // namespace + +TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) { + this->uart_.push_rx({HEADER, BAT1, BAT2}); + this->hub_.loop(); + EXPECT_TRUE(this->uart_.rx.empty()); + EXPECT_TRUE(this->published_().empty()); + + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 2u); + this->hub_.loop(); + EXPECT_EQ(this->published_().size(), 2u); + + const auto &b1 = this->published_()[0]; + EXPECT_EQ(b1.bat_num, 1); + EXPECT_EQ(b1.volt, 50548); + EXPECT_EQ(b1.curr, 8910); + EXPECT_EQ(b1.tempr, 25000); + EXPECT_EQ(b1.tlow, 24200); + EXPECT_EQ(b1.thigh, 25000); + EXPECT_EQ(b1.vlow, 3368); + EXPECT_EQ(b1.vhigh, 3371); + EXPECT_STREQ(b1.base_st, "Charge"); + EXPECT_STREQ(b1.volt_st, "Normal"); + EXPECT_EQ(b1.coulomb, 97); + EXPECT_EQ(b1.mostempr, 22700); + + const auto &b2 = this->published_()[1]; + EXPECT_EQ(b2.bat_num, 2); + EXPECT_EQ(b2.curr, -1255); + EXPECT_STREQ(b2.base_st, "SysError"); + EXPECT_STREQ(b2.volt_st, "Low"); + EXPECT_EQ(b2.coulomb, 4); + EXPECT_EQ(b2.mostempr, 7800); +} + +TEST_F(PylontechTest, NothingIsPublishedWhileBytesKeepArriving) { + this->uart_.push_rx({HEADER, BAT1}); + this->hub_.loop(); + this->uart_.push_rx({BAT2}); + this->hub_.loop(); + EXPECT_TRUE(this->published_().empty()); + this->hub_.loop(); + this->hub_.loop(); + EXPECT_EQ(this->published_().size(), 2u); +} + +TEST_F(PylontechTest, NewerFirmwareSkipsIdColumns) { + this->uart_.push_rx({NEW_HEADER, NEW_BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + const auto &b1 = this->published_()[0]; + EXPECT_EQ(b1.volt, 49405); + EXPECT_EQ(b1.tlow, 13700); + EXPECT_EQ(b1.thigh, 14500); + EXPECT_EQ(b1.vlow, 3293); + EXPECT_EQ(b1.vhigh, 3294); + EXPECT_STREQ(b1.base_st, "Idle"); + EXPECT_EQ(b1.coulomb, 60); + EXPECT_EQ(b1.mostempr, 16600); +} + +TEST_F(PylontechTest, OverlongHeaderStillSetsTheColumnLayout) { + // Past the 199 character line limit: the line is dropped but its start still selects the layout + const std::string pad(40, ' '); + std::string new_header(NEW_HEADER); + new_header.insert(new_header.size() - 2, pad + "Extra.St"); + ASSERT_GT(new_header.size(), 201u); + this->uart_.push_rx({new_header, NEW_BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].thigh, 14500); + EXPECT_EQ(this->published_()[0].mostempr, 16600); + + // An overlong older header switches back + std::string old_header(HEADER); + old_header.insert(old_header.size() - 2, pad + pad + "Extra.St"); + ASSERT_GT(old_header.size(), 201u); + this->uart_.push_rx({old_header, BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[1].thigh, 25000); + EXPECT_EQ(this->published_()[1].mostempr, 22700); +} + +TEST_F(PylontechTest, BatteryWithoutListenerIsIgnored) { + this->uart_.push_rx( + {HEADER, bat_line(5, 49000), bat_line(17, 49000), bat_line(0, 49000), bat_line(INT32_MIN, 49000), BAT1, BAT2}); + for (int i = 0; i < 4; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + EXPECT_EQ(this->published_()[1].bat_num, 2); +} + +TEST_F(PylontechTest, OutOfRangeValueRejectsTheLine) { + this->uart_.push_rx({HEADER, bat_line(1, -1), bat_line(1, 70000), BAT2}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +TEST_F(PylontechTest, InvalidAndUnknownLinesAreSkipped) { + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, "1 abc\r\n", BAT2, "$$\r\n"}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +TEST_F(PylontechTest, RepeatedBatteryLineOverwrites) { + this->uart_.push_rx({HEADER, bat_line(3, 49000), BAT1, bat_line(3, 49500)}); + for (int i = 0; i < 4; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + EXPECT_EQ(this->published_()[1].bat_num, 3); + EXPECT_EQ(this->published_()[1].volt, 49500); + + // The next reply fills the slot again + this->uart_.push_rx({HEADER, bat_line(3, 48000)}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 3u); + EXPECT_EQ(this->published_()[2].volt, 48000); +} + +TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) { + this->uart_.push_rx({HEADER, BAT1, "1 abc\r\n", BAT2}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +} // namespace esphome::pylontech::testing diff --git a/tests/integration/fixtures/uart_mock_pylontech.yaml b/tests/integration/fixtures/uart_mock_pylontech.yaml new file mode 100644 index 00000000000..fcf87cc8246 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_pylontech.yaml @@ -0,0 +1,105 @@ +esphome: + name: uart-mock-pylontech-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 pylontech'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 + rx_buffer_size: 1024 + auto_start: false + +pylontech: + id: pylontech0 + uart_id: mock_uart + +sensor: + - platform: pylontech + pylontech_id: pylontech0 + battery: 1 + voltage: + name: "Bat1 Voltage" + current: + name: "Bat1 Current" + temperature: + name: "Bat1 Temperature" + temperature_low: + name: "Bat1 Temperature Low" + temperature_high: + name: "Bat1 Temperature High" + voltage_low: + name: "Bat1 Voltage Low" + voltage_high: + name: "Bat1 Voltage High" + coulomb: + name: "Bat1 Coulomb" + mos_temperature: + name: "Bat1 MOS Temperature" + - platform: pylontech + pylontech_id: pylontech0 + battery: 2 + voltage: + name: "Bat2 Voltage" + current: + name: "Bat2 Current" + temperature_high: + name: "Bat2 Temperature High" + coulomb: + name: "Bat2 Coulomb" + +text_sensor: + - platform: pylontech + pylontech_id: pylontech0 + battery: 1 + base_state: + name: "Bat1 Base State" + - platform: pylontech + pylontech_id: pylontech0 + battery: 2 + base_state: + name: "Bat2 Base State" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # Phase 1: older firmware header and two battery lines + - uart_mock.inject_rx: + id: mock_uart + data: "pwr\r\n@\r\nPower Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St\r\n1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% 2021-06-30 20:49:45 Normal Normal 22700 Normal\r\n2 46012 -1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% 2025-11-28 17:56:33 Low Normal 7800 Normal\r\n$$\r\n" + - delay: 300ms + # Phase 2: garbage bytes, ending a line + - uart_mock.inject_rx: + id: mock_uart + data: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF, 0x0D, 0x0A] + - delay: 100ms + # Phase 3: truncated battery line; must not publish + - uart_mock.inject_rx: + id: mock_uart + data: "1 49000 0 17000\r\n" + - delay: 100ms + # Phase 4: line over the 199 character limit; dropped with a warning + - uart_mock.inject_rx: + id: mock_uart + data: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\r\n" + - delay: 100ms + # Phase 5 (recovery): newer firmware header, also over the limit, then its data lines + - uart_mock.inject_rx: + id: mock_uart + data: "Power Volt Curr Tempr Tlow Tlow.Id Thigh Thigh.Id Vlow Vlow.Id Vhigh Vhigh.Id Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St SysAlarm.St Extra.St\r\n1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal 60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n2 48100 -2500 16900 13200 3 14100 1 3201 4 3215 9 Dischg Normal Normal Normal 42% 2025-12-05 00:53:41 Normal Normal 15800 Normal Normal\r\n$$\r\n" diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py new file mode 100644 index 00000000000..fffaa902072 --- /dev/null +++ b/tests/integration/test_uart_mock_pylontech.py @@ -0,0 +1,136 @@ +"""Integration test for the pylontech component with mock UART. + +Phases, all injected after the Start Scenario button press: + 1. Older firmware header and two battery lines publish exact values + 2. Garbage bytes are skipped + 3. A truncated battery line publishes nothing and is counted in a warning + 4. A line over the 199 character limit is dropped with a warning + 5. Recovery: a newer firmware header, itself over the limit, still selects the + layout with the extra Id columns, so its battery lines parse correctly +""" + +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 + +SENSORS = [ + "bat1_voltage", + "bat1_current", + "bat1_temperature", + "bat1_temperature_low", + "bat1_temperature_high", + "bat1_voltage_low", + "bat1_voltage_high", + "bat1_coulomb", + "bat1_mos_temperature", + "bat2_voltage", + "bat2_current", + "bat2_temperature_high", + "bat2_coulomb", +] +TEXT_SENSORS = ["bat1_base_state", "bat2_base_state"] + +# Values per entity: phase 1 reply, then the phase 5 recovery reply +EXPECTED = { + "bat1_voltage": [50.548, 49.405], + "bat1_current": [8.91, 0.0], + "bat1_temperature": [25.0, 17.6], + "bat1_temperature_low": [24.2, 13.7], + "bat1_temperature_high": [25.0, 14.5], + "bat1_voltage_low": [3.368, 3.293], + "bat1_voltage_high": [3.371, 3.294], + "bat1_coulomb": [97.0, 60.0], + "bat1_mos_temperature": [22.7, 16.6], + "bat2_voltage": [46.012, 48.1], + "bat2_current": [-1.255, -2.5], + "bat2_temperature_high": [5.5, 14.1], + "bat2_coulomb": [4.0, 42.0], +} +EXPECTED_TEXT = { + "bat1_base_state": ["Charge", "Idle"], + "bat2_base_state": ["SysError", "Dischg"], +} + + +@pytest.mark.asyncio +async def test_uart_mock_pylontech( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test pylontech parsing with happy path, garbage, overflow and recovery.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + overflow_lines: list[str] = [] + rejected_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "Line over 199 chars dropped" in line: + overflow_lines.append(line) + if "malformed battery lines" in line: + rejected_lines.append(line) + + collector = SensorStateCollector( + sensor_names=SENSORS, text_sensor_names=TEXT_SENSORS + ) + recovery_received = collector.add_waiter( + lambda: ( + all(len(collector.sensor_states[name]) >= 2 for name in SENSORS) + and all( + len(collector.text_sensor_states[name]) >= 2 for name in TEXT_SENSORS + ) + ) + ) + + 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) + + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the recovery reply. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" text_states: {collector.text_sensor_states}" + ) + # Let any stray publish from the bad phases arrive before the exact checks + await asyncio.sleep(0.2) + + for name, values in EXPECTED.items(): + assert collector.sensor_states[name] == pytest.approx(values), name + for name, values in EXPECTED_TEXT.items(): + assert collector.text_sensor_states[name] == values, name + + # Phase 4 and the phase 5 header both overflow + assert len(overflow_lines) == 2, overflow_lines + # The phase 3 cut line is counted and reported once the UART is idle + assert rejected_lines, "no rejected-line summary logged" From 052a6096e65cb520a299f8d6de253342e30bb7d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:46:16 -1000 Subject: [PATCH 509/651] [components] Log hex dumps with the ESP_LOG*_HEX macros (#20932) --- .../haier_water_heater/hon_water_heater.cpp | 12 +++------ esphome/components/hdmi_cec/hdmi_cec.cpp | 5 +--- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 7 ++---- esphome/components/i2c/i2c_bus_arduino.cpp | 5 +--- esphome/components/i2c/i2c_bus_esp_idf.cpp | 5 +--- .../improv_ble/improv_ble_component.cpp | 7 ++---- esphome/components/ld2410/ld2410.cpp | 10 ++------ esphome/components/ld2412/ld2412.cpp | 10 ++------ esphome/components/ld2450/ld2450.cpp | 10 ++------ esphome/components/ld2460/ld2460.cpp | 5 +--- .../midea_portasplit/midea_portasplit.cpp | 10 ++------ esphome/components/mitsubishi/mitsubishi.cpp | 5 +--- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 5 +--- esphome/components/modbus/modbus.cpp | 6 +---- .../output/modbus_output.cpp | 6 +---- .../switch/modbus_switch.cpp | 6 +---- .../mopeka_std_check/mopeka_std_check.cpp | 7 ++---- esphome/components/pn532_spi/pn532_spi.cpp | 10 ++------ .../seeed_mr60bha2/seeed_mr60bha2.cpp | 10 ++------ .../seeed_mr60fda2/seeed_mr60fda2.cpp | 25 ++++--------------- esphome/components/sonoff_d1/sonoff_d1.cpp | 15 +++-------- .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 10 ++------ esphome/components/xiaomi_ble/xiaomi_ble.cpp | 5 +--- .../components/zwave_proxy/zwave_proxy.cpp | 10 ++------ 24 files changed, 43 insertions(+), 163 deletions(-) diff --git a/esphome/components/haier_water_heater/hon_water_heater.cpp b/esphome/components/haier_water_heater/hon_water_heater.cpp index 3f190de5b83..b2c35c4cca4 100644 --- a/esphome/components/haier_water_heater/hon_water_heater.cpp +++ b/esphome/components/haier_water_heater/hon_water_heater.cpp @@ -46,10 +46,7 @@ void HonWaterHeater::send_frame_(FrameType type, const uint8_t *payload, size_t const size_t length = encode_frame(type, payload, size, wire); if (length == 0) return; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex[format_hex_pretty_size(sizeof(wire))]; - ESP_LOGV(TAG, "TX type=%02X: %s", static_cast(type), format_hex_pretty_to(hex, wire, length)); -#endif + ESP_LOGV_HEX(TAG, wire, length, sizeof(wire), "TX type=%02X: %s", static_cast(type)); this->write_array(wire, length); } @@ -221,11 +218,8 @@ void HonWaterHeater::confirm_control_(const Status &status) { void HonWaterHeater::process_frame_() { const uint8_t *frame = this->parser_.data(); const auto type = static_cast(frame[FRAME_TYPE_OFFSET]); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex[format_hex_pretty_size(MAX_STORED_FRAME)]; - ESP_LOGV(TAG, "RX decoded type=%02X flags=%02X: %s", static_cast(type), frame[FRAME_FLAGS_OFFSET], - format_hex_pretty_to(hex, frame, this->parser_.stored_size())); -#endif + ESP_LOGV_HEX(TAG, frame, this->parser_.stored_size(), MAX_STORED_FRAME, "RX decoded type=%02X flags=%02X: %s", + static_cast(type), frame[FRAME_FLAGS_OFFSET]); this->handle_frame_type_(type); Status status{}; diff --git a/esphome/components/hdmi_cec/hdmi_cec.cpp b/esphome/components/hdmi_cec/hdmi_cec.cpp index 82e354dbae7..e0e9abde7b1 100644 --- a/esphome/components/hdmi_cec/hdmi_cec.cpp +++ b/esphome/components/hdmi_cec/hdmi_cec.cpp @@ -75,10 +75,7 @@ void HDMICEC::loop() { } // One inbound frame per pass keeps the time spent in the automations bounded if (const Frame *frame = this->frames_queue_.front()) { -#ifdef ESPHOME_LOG_HAS_DEBUG - char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; - ESP_LOGD(TAG, "Received: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); -#endif + ESP_LOGD_HEX(TAG, frame->bytes().data(), frame->size(), Frame::MAX_LENGTH, "Received: %s"); #ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT this->message_callbacks_.call(frame->initiator_addr(), frame->destination_addr(), frame->payload()); #endif diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index e4845daa57b..8674a1de97c 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -161,11 +161,8 @@ void HlkFm22xComponent::recv_command_() { } } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(HLK_FM22X_MAX_RESPONSE_SIZE)]; - ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type, - format_hex_pretty_to(hex_buf, this->recv_buf_.data(), to_store)); -#endif + ESP_LOGV_HEX(TAG, this->recv_buf_.data(), to_store, HLK_FM22X_MAX_RESPONSE_SIZE, "Recv type: 0x%.2X, data: %s", + response_type); byte = this->read(); if (byte != checksum) { diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 215cc1364f8..799004b1c0b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -111,10 +111,7 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe return ERROR_NOT_INITIALIZED; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(I2C_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty_to(hex_buf, write_buffer, write_count)); -#endif + ESP_LOGV_HEX(TAG, write_buffer, write_count, I2C_MAX_LOG_BYTES, "0x%02X TX %s", address); uint8_t status = 0; if (write_count != 0 || read_count == 0) { diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 931bcfa0889..2b1b83ef6f5 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -167,10 +167,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s jobs[num_jobs++].write.total_bytes = 1; } else { if (write_count != 0) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(I2C_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty_to(hex_buf, write_buffer, write_count)); -#endif + ESP_LOGV_HEX(TAG, write_buffer, write_count, I2C_MAX_LOG_BYTES, "0x%02X TX %s", address); jobs[num_jobs++].command = I2C_MASTER_CMD_START; jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; jobs[num_jobs].write.ack_check = true; diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 6e11975b6c3..0d441ee62dc 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -354,11 +354,8 @@ void ImprovBLEComponent::process_incoming_data_() { return; uint8_t length = this->incoming_data_[1]; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(IMPROV_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "Processing bytes - %s", - format_hex_pretty_to(hex_buf, this->incoming_data_.data(), this->incoming_data_.size())); -#endif + ESP_LOGV_HEX(TAG, this->incoming_data_.data(), this->incoming_data_.size(), IMPROV_MAX_LOG_BYTES, + "Processing bytes - %s"); if (this->incoming_data_.size() - 3 == length) { this->set_error_(improv::ERROR_NONE); improv::ImprovCommand command = improv::parse_improv_data(this->incoming_data_); diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 22bd20d1e5a..7461a2ade40 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -607,17 +607,11 @@ void LD2410Component::readline_(int readch) { return; // Not enough data to process yet } if (ld2410::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next message } else if (ld2410::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index c6137f29f7c..704e3845e73 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -702,17 +702,11 @@ void LD2412Component::readline_(int readch) { return; // Not enough data to process yet } if (ld2412::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next message } else if (ld2412::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index cdf542ee8b8..52327d309e3 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -829,17 +829,11 @@ void LD2450Component::readline_(int readch) { } if (this->buffer_data_[this->buffer_pos_ - 2] == DATA_FRAME_FOOTER[0] && this->buffer_data_[this->buffer_pos_ - 1] == DATA_FRAME_FOOTER[1]) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next frame } else if (ld2450::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp index 2efd34a1d8c..abeb6779cf4 100644 --- a/esphome/components/ld2460/ld2460.cpp +++ b/esphome/components/ld2460/ld2460.cpp @@ -168,10 +168,7 @@ void LD2460Component::readline_(uint8_t readch) { 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 + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Frame: %s"); if (is_data) { this->handle_periodic_data_(); diff --git a/esphome/components/midea_portasplit/midea_portasplit.cpp b/esphome/components/midea_portasplit/midea_portasplit.cpp index a1f22226d9c..309e1f880f4 100644 --- a/esphome/components/midea_portasplit/midea_portasplit.cpp +++ b/esphome/components/midea_portasplit/midea_portasplit.cpp @@ -202,10 +202,7 @@ void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t * f[10 + len] = crc8(&f[10], (uint8_t) len); f[11 + len] = checksum(f, 11 + len); this->write_array(f, total); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(sizeof(f))]; - ESP_LOGV(TAG, "TX: %s", format_hex_pretty_to(hex_buf, f, total)); -#endif + ESP_LOGV_HEX(TAG, f, total, sizeof(f), "TX: %s"); } void PortaSplitClimate::send_set_command_() { @@ -276,10 +273,7 @@ void PortaSplitClimate::rx_feed_(uint8_t b) { ESP_LOGW(TAG, "RX checksum mismatch, dropping frame"); return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_FRAME)]; - ESP_LOGV(TAG, "RX: %s", format_hex_pretty_to(hex_buf, this->rx_.data(), total)); -#endif + ESP_LOGV_HEX(TAG, this->rx_.data(), total, MAX_FRAME, "RX: %s"); this->handle_frame_(this->rx_.data(), total); } diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 9fba222d635..19b8bb3fad3 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -387,10 +387,7 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { break; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MITSUBISHI_FRAME_SIZE)]; -#endif - ESP_LOGV(TAG, "Receiving: %s", format_hex_pretty_to(hex_buf, state_frame, MITSUBISHI_FRAME_SIZE)); + ESP_LOGV_HEX(TAG, state_frame, MITSUBISHI_FRAME_SIZE, MITSUBISHI_FRAME_SIZE, "Receiving: %s"); this->publish_state(); return true; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 3261d9d5694..75e8d547c05 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -458,10 +458,7 @@ void MitsubishiCN105::FrameParser::reset_and_dump_buffer_(const char *prefix) { } void MitsubishiCN105::FrameParser::dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char buf[format_hex_pretty_size(READ_BUFFER_SIZE)]; - ESP_LOGVV(TAG, "%s (%zu): %s", prefix, len, format_hex_pretty_to(buf, data, len)); -#endif + ESP_LOGVV_HEX(TAG, data, len, READ_BUFFER_SIZE, "%s (%zu): %s", prefix, len); } } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index a79a7c3bd8c..d135ae58e63 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -1161,11 +1161,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); return false; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, - format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); + ESP_LOGV_HEX(TAG, pdu.data(), pdu.size(), MODBUS_MAX_LOG_BYTES, "Adding frame to tx queue: %" PRIu8 ":%s", address); this->tx_buffer_.emplace_back(device, address, pdu, options, this->next_seq_++); return true; } diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index 9fb40f90053..f49429b1ece 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -100,11 +100,7 @@ void ModbusBinaryOutput::write_state(bool state) { } if (!data.empty()) { this->warn_write_buffer_deprecated_(LOG_STR("binary output"), this->start_address); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus binary output write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), MODBUS_OUTPUT_MAX_LOG_BYTES, "Modbus binary output write raw: %s"); // The lambda filled a legacy raw frame (device address + function code + data). if (!this->send_raw_frame_deprecated_(data)) { ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address()); diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index fe2b2762313..c29378c5436 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -70,11 +70,7 @@ void ModbusSwitch::write_state(bool state) { } if (!data.empty()) { this->warn_write_buffer_deprecated_(LOG_STR("switch"), this->start_address); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus Switch write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), MODBUS_SWITCH_MAX_LOG_BYTES, "Modbus Switch write raw: %s"); // The lambda filled a legacy raw frame (device address + function code + data). if (!this->send_raw_frame_deprecated_(data)) { ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index f63f2bc190b..831c264d24e 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -64,11 +64,8 @@ bool MopekaStdCheck::parse_device(const ble_device_base::ESPBTDevice &device) { const auto &manu_data = manu_datas[0]; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MOPEKA_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "[%s] Manufacturer data: %s", addr_str, - format_hex_pretty_to(hex_buf, manu_data.data.data(), manu_data.data.size())); + ESP_LOGVV_HEX(TAG, manu_data.data.data(), manu_data.data.size(), MOPEKA_MAX_LOG_BYTES, "[%s] Manufacturer data: %s", + addr_str); if (manu_data.data.size() != MANUFACTURER_DATA_LENGTH) { ESP_LOGE(TAG, "[%s] Unexpected manu_data size (%d)", addr_str, manu_data.data.size()); diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 34c6a3e44f7..eb0ce241f39 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -38,10 +38,7 @@ bool PN532Spi::write_data(const std::span data) { delay(2); // First byte, communication mode: Write data this->write_byte(0x01); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Writing data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), PN532_MAX_LOG_BYTES, "Writing data: %s"); this->write_array(data.data(), data.size()); this->disable(); @@ -65,10 +62,7 @@ bool PN532Spi::read_data(pn532::PN532Frame &data, size_t len) { data[0] = 0x01; this->read_array(data.data() + 1, len); this->disable(); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Read data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), PN532_MAX_LOG_BYTES, "Read data: %s"); return true; } diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index e86909d0491..eec4e7afa6b 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -119,10 +119,7 @@ bool MR60BHA2Component::validate_message_() { if (at == 7) { if (!validate_checksum(data, 7, header_checksum)) { ESP_LOGE(TAG, "HEAD_CKSUM_FRAME ERROR: 0x%02x", header_checksum); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MR60BHA2_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "GET FRAME: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data, 8)); + ESP_LOGV_HEX(TAG, data, 8, MR60BHA2_MAX_LOG_BYTES, "GET FRAME: %s"); return false; } return true; @@ -137,10 +134,7 @@ bool MR60BHA2Component::validate_message_() { if (at == 8 + length) { if (!validate_checksum(data + 8, length, data_checksum)) { ESP_LOGE(TAG, "DATA_CKSUM_FRAME ERROR: 0x%02x", data_checksum); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MR60BHA2_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "GET FRAME: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data, 8 + length)); + ESP_LOGV_HEX(TAG, data, 8 + length, MR60BHA2_MAX_LOG_BYTES, "GET FRAME: %s"); return false; } } diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index d0e4c635eb2..5a0143071c6 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -329,10 +329,7 @@ void MR60FDA2Component::set_install_height(uint8_t index) { float_to_bytes(INSTALL_HEIGHT[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND INSTALL HEIGHT FRAME: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND INSTALL HEIGHT FRAME: %s"); } void MR60FDA2Component::set_height_threshold(uint8_t index) { @@ -342,10 +339,7 @@ void MR60FDA2Component::set_height_threshold(uint8_t index) { float_to_bytes(HEIGHT_THRESHOLD[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND HEIGHT THRESHOLD: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND HEIGHT THRESHOLD: %s"); } void MR60FDA2Component::set_sensitivity(uint8_t index) { @@ -357,28 +351,19 @@ void MR60FDA2Component::set_sensitivity(uint8_t index) { send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND SET SENSITIVITY: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND SET SENSITIVITY: %s"); } void MR60FDA2Component::get_radar_parameters() { uint8_t send_data[8] = {0x01, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x06, 0xF6}; this->write_array(send_data, 8); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(8)]; -#endif - ESP_LOGV(TAG, "SEND GET PARAMETERS: %s", format_hex_pretty_to(hex_buf, send_data, 8)); + ESP_LOGV_HEX(TAG, send_data, 8, 8, "SEND GET PARAMETERS: %s"); } void MR60FDA2Component::factory_reset() { uint8_t send_data[8] = {0x01, 0x00, 0x00, 0x00, 0x00, 0x21, 0x10, 0xCF}; this->write_array(send_data, 8); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(8)]; -#endif - ESP_LOGV(TAG, "SEND RESET: %s", format_hex_pretty_to(hex_buf, send_data, 8)); + ESP_LOGV_HEX(TAG, send_data, 8, 8, "SEND RESET: %s"); this->get_radar_parameters(); } diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index c72210e932f..ca3b4267e84 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -84,10 +84,7 @@ bool SonoffD1Output::read_command_(uint8_t *cmd, size_t &len) { // Read a minimal packet if (this->read_array(cmd, 6)) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(6)]; - ESP_LOGV(TAG, "[%04d] Reading from dimmer: %s", this->write_count_, format_hex_pretty_to(hex_buf, cmd, 6)); -#endif + ESP_LOGV_HEX(TAG, cmd, 6, 6, "[%04d] Reading from dimmer: %s", this->write_count_); if (cmd[0] != 0xAA || cmd[1] != 0x55) { ESP_LOGW(TAG, "[%04d] RX: wrong header (%x%x, must be AA55)", this->write_count_, cmd[0], cmd[1]); @@ -101,10 +98,7 @@ bool SonoffD1Output::read_command_(uint8_t *cmd, size_t &len) { return false; } if (this->read_array(&cmd[6], cmd[5] + 1 /*checksum suffix*/)) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf2[format_hex_pretty_size(SONOFF_D1_MAX_CMD_SIZE)]; - ESP_LOGV(TAG, "[%04d] %s", this->write_count_, format_hex_pretty_to(hex_buf2, &cmd[6], cmd[5] + 1)); -#endif + ESP_LOGV_HEX(TAG, &cmd[6], cmd[5] + 1, SONOFF_D1_MAX_CMD_SIZE, "[%04d] %s", this->write_count_); // Check the checksum uint8_t valid_checksum = this->calc_checksum_(cmd, cmd[5] + 7); @@ -178,10 +172,7 @@ bool SonoffD1Output::write_command_(uint8_t *cmd, const size_t len, bool needs_a // 2. UART command initiated by this component can clash with a command initiated by RF uint32_t retries = 10; do { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(SONOFF_D1_MAX_CMD_SIZE)]; - ESP_LOGV(TAG, "[%04d] Writing to the dimmer: %s", this->write_count_, format_hex_pretty_to(hex_buf, cmd, len)); -#endif + ESP_LOGV_HEX(TAG, cmd, len, SONOFF_D1_MAX_CMD_SIZE, "[%04d] Writing to the dimmer: %s", this->write_count_); this->write_array(cmd, len); this->write_count_++; if (!needs_ack) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 0f02ddf3c7a..ebf44e41438 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -50,10 +50,7 @@ static void tinyusb_cdc_rx_callback(int itf, cdcacm_event_t *event) { esp_err_t ret = tinyusb_cdcacm_read(static_cast(itf), rx_buf, CONFIG_TINYUSB_CDC_RX_BUFSIZE, &rx_size); ESP_LOGV(TAG, "tinyusb_cdc_rx_callback itf=%d (size: %u)", itf, rx_size); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char rx_hex_buf[format_hex_pretty_size(USB_CDC_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "rx_buf = %s", format_hex_pretty_to(rx_hex_buf, rx_buf, rx_size)); + ESP_LOGVV_HEX(TAG, rx_buf, rx_size, USB_CDC_MAX_LOG_BYTES, "rx_buf = %s"); if (ret == ESP_OK && rx_size > 0) { RingbufHandle_t rx_ringbuf = instance->get_rx_ringbuf(); @@ -194,10 +191,7 @@ void USBCDCACMInstance::usb_tx_task() { } ESP_LOGV(TAG, "USB TX itf=%d: Read %d bytes from buffer", this->itf_, tx_data_size); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char tx_hex_buf[format_hex_pretty_size(USB_CDC_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "data = %s", format_hex_pretty_to(tx_hex_buf, data, tx_data_size)); + ESP_LOGVV_HEX(TAG, data, tx_data_size, USB_CDC_MAX_LOG_BYTES, "data = %s"); // Serial data will be split up into 64 byte chunks to be sent over USB so this // usually will take multiple iterations diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 57f26b6acc7..90ea5c32094 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -286,10 +286,7 @@ optional parse_xiaomi_header(const ble_device_base::ServiceDa bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address) { if ((raw.size() != 19) && ((raw.size() < 22) || (raw.size() > 24))) { ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): data packet has wrong size (%d)!", raw.size()); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(XIAOMI_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty_to(hex_buf, raw.data(), raw.size())); + ESP_LOGVV_HEX(TAG, raw.data(), raw.size(), XIAOMI_MAX_LOG_BYTES, " Packet : %s"); return false; } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index a7b1fa0ced0..7134e223351 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -311,10 +311,7 @@ void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) { return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "Sending: %s", format_hex_pretty_to(hex_buf, data, length)); + ESP_LOGVV_HEX(TAG, data, length, ZWAVE_MAX_LOG_BYTES, "Sending: %s"); this->write_array(data, length); } @@ -395,10 +392,7 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; } else { this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_ACK; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty_to(hex_buf, this->buffer_.data(), this->buffer_index_)); + ESP_LOGVV_HEX(TAG, this->buffer_.data(), this->buffer_index_, ZWAVE_MAX_LOG_BYTES, "Received frame: %s"); frame_completed = true; } this->response_handler_(); From 7c6fac91fffb63b9f7617311ded8badfc7a824f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:49:15 -0400 Subject: [PATCH 510/651] [esp32] Bump recommended ESP-IDF to 5.5.6 (#20987) --- esphome/components/esp32/__init__.py | 9 +++++---- platformio.ini | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aae1a4c41f6..cd602e6c64b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1183,7 +1183,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), - cv.Version(3, 3, 12): cv.Version(5, 5, 5), + cv.Version(3, 3, 12): cv.Version(5, 5, 6), cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), @@ -1207,9 +1207,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 5), - "latest": cv.Version(5, 5, 5), - "dev": cv.Version(5, 5, 5), + "recommended": cv.Version(5, 5, 6), + "latest": cv.Version(5, 5, 6), + "dev": cv.Version(5, 5, 6), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -1219,6 +1219,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(5, 5, 6): cv.Version(55, 3, 312), cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index fe61470a358..e5d0200ea7c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -147,7 +147,7 @@ extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -183,7 +183,7 @@ extra_scripts = extends = common:idf platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = espidf lib_deps = From bd06d37d1a0a47c10f1862092ad5e4b4d6513931 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:49:46 -1000 Subject: [PATCH 511/651] [components] Check I2C chip IDs with check_chip_id (#20943) --- esphome/components/apds9306/apds9306.cpp | 11 ++-------- esphome/components/apds9930/apds9930.cpp | 10 +--------- esphome/components/as7341/as7341.cpp | 8 +------- esphome/components/bh1745/bh1745.cpp | 9 +-------- esphome/components/bmi270/bmi270.cpp | 11 +--------- esphome/components/bq25186/bq25186.cpp | 12 +---------- .../es9038q2m_katana/es9038q2m_katana.cpp | 10 +--------- esphome/components/lis3dh/lis3dh.cpp | 10 +--------- esphome/components/mmc5603/mmc5603.cpp | 11 ++-------- esphome/components/mmc5983/mmc5983.cpp | 17 +++------------- esphome/components/mpl3115a2/mpl3115a2.cpp | 9 +-------- esphome/components/msa3xx/msa3xx.cpp | 10 +--------- esphome/components/qmi8658/qmi8658.cpp | 10 +--------- esphome/components/qwiic_pir/qwiic_pir.cpp | 11 +--------- esphome/components/stts22h/stts22h.cpp | 20 +------------------ esphome/components/stts22h/stts22h.h | 1 - esphome/components/tsl2591/tsl2591.cpp | 12 +---------- 17 files changed, 20 insertions(+), 162 deletions(-) diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp index af123d8c4f3..bcfcce1f3b4 100644 --- a/esphome/components/apds9306/apds9306.cpp +++ b/esphome/components/apds9306/apds9306.cpp @@ -52,16 +52,9 @@ enum { // APDS9306 registers } void APDS9306::setup() { - uint8_t id; - if (!this->read_byte(APDS9306_PART_ID, &id)) { // Part ID register - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + // 0xB1 for APDS9306, 0xB3 for APDS9306-065; they differ only in bit 1 + if (!this->check_chip_id(this, APDS9306_PART_ID, 0xB1, 0xFD)) return; - } - - if (id != 0xB1 && id != 0xB3) { // 0xB1 for APDS9306 0xB3 for APDS9306-065 - this->mark_failed(LOG_STR("APDS9306 has invalid id")); - return; - } // ALS resolution and measurement, see datasheet or init.py for options uint8_t als_meas_rate = ((this->bit_width_ & 0x07) << 4) | (this->measurement_rate_ & 0x07); diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index 6debc0d9c7b..a4b836beaa1 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -64,16 +64,8 @@ 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")); + if (!this->check_chip_id(this, APDS9930_ID | APDS9930_CMD, APDS9930_CHIP_ID)) 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 | diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index 60f576f7bb9..cf02884c5e4 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -9,14 +9,8 @@ ESPHOME_LOG_TAG(TAG, "as7341"); void AS7341Component::setup() { LOG_I2C_DEVICE(this); - // Verify device ID - uint8_t id; - this->read_byte(AS7341_ID, &id); - ESP_LOGCONFIG(TAG, " Read ID: 0x%X", id); - if ((id & 0xFC) != (AS7341_CHIP_ID << 2)) { - this->mark_failed(LOG_STR("Wrong chip ID")); + if (!this->check_chip_id(this, AS7341_ID, AS7341_CHIP_ID << 2, 0xFC)) return; - } // Power on (enter IDLE state) if (!this->enable_power(true)) { diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index 24744c3330d..ee7b9935f4c 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -63,15 +63,8 @@ static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low } 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)); + if (!this->check_chip_id(this, REG_MANUFACTURER_ID, MANUFACTURER_ID)) 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) { diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index e3c6d83a722..0e010b03118 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -78,17 +78,8 @@ bool BMI270Component::load_config_file_() { void BMI270Component::setup() { MotionComponent::setup(); // 1. Verify chip ID - uint8_t chip_id = 0; - if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, BMI270_REG_CHIP_ID, BMI270_CHIP_ID_VALUE)) return; - } - if (chip_id != BMI270_CHIP_ID_VALUE) { - ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } - ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); // 2. Soft-reset via CMD register (0x7E = 0xB6) if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) { diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index edea50d1115..91bbacba467 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -79,19 +79,9 @@ bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { } 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")); + if (!this->check_chip_id(this, BQ25186_REG_MASK_ID, 0x01, 0x0F)) return; - } // A failed write only sets the warning status: the chip has identified itself, so a later // apply_configuration may retry diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp index 464f971acef..e3cd3820d7e 100644 --- a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -69,16 +69,8 @@ const LogString *deemphasis_to_str(DeemphasisMode mode) { } // namespace void ES9038Q2MKatana::setup() { - uint8_t chip_id = 0; - if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, KATANA_REG_CHIP_ID, KATANA_CHIP_ID)) return; - } - if (chip_id != KATANA_CHIP_ID) { - ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id); - this->mark_failed(LOG_STR("Unexpected chip ID")); - return; - } if (!this->write_byte(KATANA_REG_RESET, 0x01)) { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp index f12c00852cd..64e6da4ef95 100644 --- a/esphome/components/lis3dh/lis3dh.cpp +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -27,16 +27,8 @@ static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1 void LIS3DHComponent::setup() { MotionComponent::setup(); - uint8_t who_am_i = 0; - if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) { - this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); + if (!this->check_chip_id(this, LIS3DH_REG_WHO_AM_I, LIS3DH_WHO_AM_I_VALUE)) 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(LOG_STR("Wrong chip ID")); - return; - } if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { this->mark_failed(LOG_STR("Failed to configure sensor")); diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index fa5ba5985d7..bb7ea34cbdf 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -32,16 +32,9 @@ static const uint8_t MMC56X3_CTRL2_REG = 0x1D; static const uint8_t MMC5603_ODR_REG = 0x1A; void MMC5603Component::setup() { - uint8_t id = 0; - if (!this->read_byte(MMC56X3_PRODUCT_ID, &id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + // Some chips report 0 instead of MMC56X3_CHIP_ID (0x10), so bit 4 is ignored + if (!this->check_chip_id(this, MMC56X3_PRODUCT_ID, 0x00, static_cast(~MMC56X3_CHIP_ID))) return; - } - - if (id != 0 && id != MMC56X3_CHIP_ID) { // ID is not reported correctly by all chips, 0 on some chips - this->mark_failed(LOG_STR("The ID registers don't match - Is this really an MMC5603?")); - return; - } if (!this->write_byte(MMC56X3_CTRL1_REG, 0x80)) { // turn on set bit this->mark_failed(LOG_STR("Control 1 failed for set bit")); diff --git a/esphome/components/mmc5983/mmc5983.cpp b/esphome/components/mmc5983/mmc5983.cpp index f7840aafbe9..8a636dacb5d 100644 --- a/esphome/components/mmc5983/mmc5983.cpp +++ b/esphome/components/mmc5983/mmc5983.cpp @@ -14,6 +14,7 @@ constexpr uint8_t IC1_ADDR = 0x0a; constexpr uint8_t IC2_ADDR = 0x0b; constexpr uint8_t IC3_ADDR = 0x0c; constexpr uint8_t PRODUCT_ID_ADDR = 0x2f; +constexpr uint8_t MMC5983_PRODUCT_ID = 0x30; float convert_data_to_millitesla(uint8_t data_17_10, uint8_t data_9_2, uint8_t data_1_0) { int32_t counts = (data_17_10 << 10) | (data_9_2 << 2) | data_1_0; @@ -66,25 +67,13 @@ void MMC5983Component::update() { } void MMC5983Component::setup() { - // Verify product id. - const uint8_t mmc5983_product_id = 0x30; - uint8_t id; - i2c::ErrorCode err = this->read_register(PRODUCT_ID_ADDR, &id, 1); - if (err != i2c::ErrorCode::ERROR_OK) { - ESP_LOGE(TAG, "Reading product id failed with i2c error %d", err); - this->mark_failed(); + if (!this->check_chip_id(this, PRODUCT_ID_ADDR, MMC5983_PRODUCT_ID)) return; - } - if (id != mmc5983_product_id) { - ESP_LOGE(TAG, "Product id 0x%02x does not match expected value 0x%02x", id, mmc5983_product_id); - this->mark_failed(); - return; - } // Initialize Internal Control registers to 0. // Internal Control 0. const uint8_t zero = 0; - err = this->write_register(IC0_ADDR, &zero, 1); + i2c::ErrorCode err = this->write_register(IC0_ADDR, &zero, 1); if (err != i2c::ErrorCode::ERROR_OK) { ESP_LOGE(TAG, "Initializing Internal Control 0 failed with i2c error %d", err); this->mark_failed(); diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 4a206b30dfe..58f346ab77a 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -8,15 +8,8 @@ namespace esphome::mpl3115a2 { ESPHOME_LOG_TAG(TAG, "mpl3115a2"); void MPL3115A2Component::setup() { - uint8_t whoami = 0xFF; - if (!this->read_byte(MPL3115A2_WHOAMI, &whoami)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, MPL3115A2_WHOAMI, 0xC4)) return; - } - if (whoami != 0xC4) { - this->mark_failed(LOG_STR("MPL3115A2 has invalid id")); - return; - } // reset this->write_byte(MPL3115A2_CTRL_REG1, MPL3115A2_CTRL_REG1_RST); diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index 19a4b067bc7..ed713cb30e1 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -117,16 +117,8 @@ const char *orientation_xy_to_string(OrientationXY orientation) { const char *orientation_z_to_string(bool orientation) { return orientation ? "Downwards looking" : "Upwards looking"; } void MSA3xxComponent::setup() { - uint8_t part_id{0xff}; - if (!this->read_byte(static_cast(RegisterMap::PART_ID), &part_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, static_cast(RegisterMap::PART_ID), MSA_3XX_PART_ID)) return; - } - if (part_id != MSA_3XX_PART_ID) { - ESP_LOGE(TAG, "Part ID is wrong. Got 0x%02X", part_id); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } // Resolution LSB/g // Range : MSA301 : MSA311 diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index da1f735fb9e..f03aac9a80c 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -30,16 +30,8 @@ void QMI8658Component::setup() { MotionComponent::setup(); // 1. Verify chip ID - uint8_t who_am_i = 0; - if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { - this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); + if (!this->check_chip_id(this, QMI8658_REG_WHO_AM_I, QMI8658_WHO_AM_I_VALUE)) return; - } - if (who_am_i != QMI8658_WHO_AM_I_VALUE) { - ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } // 2. Soft reset if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { diff --git a/esphome/components/qwiic_pir/qwiic_pir.cpp b/esphome/components/qwiic_pir/qwiic_pir.cpp index de9be3c8f0c..4b30a21a831 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.cpp +++ b/esphome/components/qwiic_pir/qwiic_pir.cpp @@ -6,17 +6,8 @@ namespace esphome::qwiic_pir { ESPHOME_LOG_TAG(TAG, "qwiic_pir"); void QwiicPIRComponent::setup() { - // Verify I2C communcation by reading and verifying the chip ID - uint8_t chip_id; - if (!this->read_byte(QWIIC_PIR_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR("Failed to read chip ID")); + if (!this->check_chip_id(this, QWIIC_PIR_CHIP_ID, QWIIC_PIR_DEVICE_ID)) return; - } - - if (chip_id != QWIIC_PIR_DEVICE_ID) { - this->mark_failed(LOG_STR("Unknown chip ID")); - return; - } if (!this->write_byte_16(QWIIC_PIR_DEBOUNCE_TIME, this->debounce_time_)) { this->mark_failed(LOG_STR("Failed to configure debounce time")); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 7d66dd0a183..5132b2a3aa5 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -19,11 +19,8 @@ static const uint8_t WHOAMI_STTS22H_IDENTIFICATION = 0xA0; // ID value of STTS2 static const float SENSOR_SCALE = 0.01f; // Sensor resolution in degrees Celsius void STTS22HComponent::setup() { - // Check if device is a STTS22H - if (!this->is_stts22h_sensor_()) { - this->mark_failed(LOG_STR("Device is not a STTS22H sensor")); + if (!this->check_chip_id(this, WHOAMI_REG, WHOAMI_STTS22H_IDENTIFICATION)) return; - } this->initialize_sensor_(); } @@ -58,21 +55,6 @@ float STTS22HComponent::read_temperature_() { return temp_raw_value * SENSOR_SCALE; // Apply sensor resolution } -bool STTS22HComponent::is_stts22h_sensor_() { - uint8_t whoami_value; - if (this->read_register(WHOAMI_REG, &whoami_value, 1) != i2c::NO_ERROR) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return false; - } - - if (whoami_value != WHOAMI_STTS22H_IDENTIFICATION) { - this->mark_failed(LOG_STR("Unexpected WHOAMI identifier. Sensor is not a STTS22H")); - return false; - } - - return true; -} - void STTS22HComponent::initialize_sensor_() { // Read current CTRL_REG configuration uint8_t ctrl_value; diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h index d8d7a485cf6..e1becb4b507 100644 --- a/esphome/components/stts22h/stts22h.h +++ b/esphome/components/stts22h/stts22h.h @@ -14,7 +14,6 @@ class STTS22HComponent final : public sensor::Sensor, public PollingComponent, p protected: void initialize_sensor_(); - bool is_stts22h_sensor_(); float read_temperature_(); }; diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index f1f9ae9cfdb..493f4ad3627 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -62,18 +62,8 @@ void TSL2591Component::setup() { break; } - uint8_t id; - if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_DEVICE_ID, &id)) { - ESP_LOGE(TAG, "I2C read failed"); - this->mark_failed(); + if (!this->check_chip_id(this, TSL2591_COMMAND_BIT | TSL2591_REGISTER_DEVICE_ID, 0x50)) return; - } - - if (id != 0x50) { - ESP_LOGE(TAG, "Unknown chip ID"); - this->mark_failed(); - return; - } this->set_integration_time_and_gain(this->integration_time_, this->gain_); this->disable_if_power_saving_(); From 1ead61f11f3349fc7cd41dede4891b5de96cfd1e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:11 -0400 Subject: [PATCH 512/651] [esp32] Remove trailing comment from ECDSA test signing key (#20988) --- tests/components/esp32/dummy_signing_key_v1_ecdsa.pem | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem index bd092056069..eb27f9b2c48 100644 --- a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem +++ b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem @@ -4,4 +4,3 @@ MHcCAQEEIEZIp96p7Z7QN6vxOFE5FdRNm535vW81Ax07KnGxVjiMoAoGCCqGSM49 AwEHoUQDQgAEK+fBQDn1Q+r5lGwcDoMUgeg2Aq16LLrLUz7xWI6mS0PUClzolDIo eaV/Pfjl7zAvkbQQsZq3rTNnr1eGAk5P+A== -----END EC PRIVATE KEY----- -*** DO NOT USE THIS KEY...EVER *** From d16d8c111ebf992f3d9054295298d030533ef1f2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:54 -0400 Subject: [PATCH 513/651] [debug] Log uptime in dump_config (#20989) --- esphome/components/debug/debug_component.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 97f8550c5bc..b2cb1910f68 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -34,6 +34,15 @@ void DebugComponent::dump_config() { this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); + uint32_t uptime = static_cast(millis_64() / 1000); + uint32_t days = uptime / 86400; + uptime -= days * 86400; + uint32_t hours = uptime / 3600; + uptime -= hours * 3600; + uint32_t minutes = uptime / 60; + uptime -= minutes * 60; + ESP_LOGD(TAG, "Uptime: %" PRIu32 "d %02" PRIu32 "h %02" PRIu32 "m %02" PRIu32 "s", days, hours, minutes, uptime); + pos = get_device_info_(std::span(device_info_buffer), pos); #ifdef USE_TEXT_SENSOR From 635df9c6ada2d7eb88ce69a60b635dbd45ae8eda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:51:29 -1000 Subject: [PATCH 514/651] [socket] Share the allow-list reject log and config lines through Ipv4Allow (#20983) --- esphome/components/socket/__init__.py | 1 + esphome/components/socket/ipv4_allow.cpp | 36 +++++++++++++++++ esphome/components/socket/ipv4_allow.h | 9 ++++- esphome/components/socket/tcp_listener.cpp | 22 ++-------- esphome/components/socket/tcp_listener.h | 1 - .../socket/test_ipv4_allow_host.cpp | 40 ++++++++++++++++++- 6 files changed, 87 insertions(+), 22 deletions(-) create mode 100644 esphome/components/socket/ipv4_allow.cpp diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index effa7d864fc..09d58d22e9f 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -274,6 +274,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( "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_allow.cpp": "USE_SOCKET_IPV4_ALLOW", "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", "tcp_client_link.cpp": "USE_SOCKET_TCP_CLIENT_LINK", "tcp_listener.cpp": "USE_SOCKET_TCP_LISTENER", diff --git a/esphome/components/socket/ipv4_allow.cpp b/esphome/components/socket/ipv4_allow.cpp new file mode 100644 index 00000000000..4791eeda369 --- /dev/null +++ b/esphome/components/socket/ipv4_allow.cpp @@ -0,0 +1,36 @@ +#include "ipv4_allow.h" + +#ifdef USE_SOCKET_IPV4_ALLOW + +#include "esphome/core/log.h" + +#include + +namespace esphome::socket { + +bool Ipv4Allow::admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now) { + if (this->allows(peer)) { + return true; + } + if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { + this->last_reject_log_ms_ = now; + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(peer, len, std::span(text)); + ESP_LOGW(tag, "Rejected %s", text); + } + return false; +} + +void Ipv4Allow::dump_config(const char *tag) const { + for (size_t i = 0; i < this->count_; i++) { + const Ipv4AllowEntry e = this->entry(i); + // Network order is dotted order, and the contiguous mask's popcount is the prefix. + const auto *b = reinterpret_cast(&e.addr); + ESP_LOGCONFIG(tag, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], + static_cast(__builtin_popcount(e.mask))); + } +} + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/ipv4_allow.h b/esphome/components/socket/ipv4_allow.h index f2f66edc05f..64116691812 100644 --- a/esphome/components/socket/ipv4_allow.h +++ b/esphome/components/socket/ipv4_allow.h @@ -47,6 +47,10 @@ class Ipv4Allow { return false; } + /// allows(peer), logging a refused peer at most once every REJECT_LOG_INTERVAL_MS. + bool admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now); + /// One config line per allowed network. + void dump_config(const char *tag) const; size_t size() const { return this->count_; } /// A copy of entry i, read from flash. Ipv4AllowEntry entry(size_t i) const { @@ -55,9 +59,12 @@ class Ipv4Allow { return e; } - private: + protected: + static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; + const Ipv4AllowEntry *entries_{nullptr}; size_t count_{0}; + uint32_t last_reject_log_ms_{0}; }; } // namespace esphome::socket diff --git a/esphome/components/socket/tcp_listener.cpp b/esphome/components/socket/tcp_listener.cpp index e1f00e29277..cfd53b9e83f 100644 --- a/esphome/components/socket/tcp_listener.cpp +++ b/esphome/components/socket/tcp_listener.cpp @@ -12,9 +12,6 @@ namespace esphome::socket { // One client at a time; a second connection waits in the stack until the first drops. static constexpr int LISTEN_BACKLOG = 1; -#ifdef USE_SOCKET_IPV4_ALLOW -static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; -#endif void TcpListener::try_listen_(TcpClientLink &link) { this->listen_ = socket_ip_loop_monitored(SOCK_STREAM, IPPROTO_TCP); @@ -57,31 +54,20 @@ void TcpListener::accept_(TcpClientLink &link) { return; } const auto *sa = reinterpret_cast(&peer); - char text[SOCKADDR_STR_LEN]; - format_sockaddr_to(sa, peer_len, std::span(text)); #ifdef USE_SOCKET_IPV4_ALLOW - if (!this->allow_.allows(sa)) { - uint32_t now = App.get_loop_component_start_time(); - if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { - this->last_reject_log_ms_ = now; - ESP_LOGW(this->tag_, "Rejected %s", text); - } + if (!this->allow_.admit(sa, peer_len, this->tag_, App.get_loop_component_start_time())) { return; } #endif + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(sa, peer_len, std::span(text)); link.adopt(std::move(client)); ESP_LOGI(this->tag_, "Client connected from %s", text); } void TcpListener::dump_config() const { #ifdef USE_SOCKET_IPV4_ALLOW - for (size_t i = 0; i < this->allow_.size(); i++) { - Ipv4AllowEntry e = this->allow_.entry(i); - // Network order is dotted order, and the contiguous mask's popcount is the prefix. - const auto *b = reinterpret_cast(&e.addr); - ESP_LOGCONFIG(this->tag_, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], - static_cast(__builtin_popcount(e.mask))); - } + this->allow_.dump_config(this->tag_); #endif } diff --git a/esphome/components/socket/tcp_listener.h b/esphome/components/socket/tcp_listener.h index a42080e641f..6448a969bdd 100644 --- a/esphome/components/socket/tcp_listener.h +++ b/esphome/components/socket/tcp_listener.h @@ -50,7 +50,6 @@ class TcpListener { std::unique_ptr listen_; const char *tag_{nullptr}; #ifdef USE_SOCKET_IPV4_ALLOW - uint32_t last_reject_log_ms_{0}; Ipv4Allow allow_; #endif }; diff --git a/tests/components/socket/test_ipv4_allow_host.cpp b/tests/components/socket/test_ipv4_allow_host.cpp index a8924de3713..ab89a59b78a 100644 --- a/tests/components/socket/test_ipv4_allow_host.cpp +++ b/tests/components/socket/test_ipv4_allow_host.cpp @@ -9,8 +9,8 @@ namespace esphome::socket::testing { -// The size_t count packs into the pointer's padding; no RAM over a uint8_t. -static_assert(sizeof(Ipv4Allow) == 2 * sizeof(void *), "unexpected padding in Ipv4Allow"); +// Entries pointer, count and the reject-log timestamp; nothing else may creep in. +static_assert(sizeof(Ipv4Allow) <= 3 * sizeof(void *), "unexpected padding in Ipv4Allow"); // 192.168.175.20/32 and 192.168.175.0/24, network order, host bits cleared, // mirroring what add_ipv4_allow emits. @@ -26,6 +26,42 @@ static bool allows_peer(const Ipv4Allow &list, const char *ip) { return list.allows(reinterpret_cast(&peer)); } +// Exposes the reject-log timestamp, the only trace the throttle leaves besides the log line. +struct ThrottledAllow : public Ipv4Allow { + using Ipv4Allow::last_reject_log_ms_; +}; + +// admit() with the same production parser. +static bool admits_peer(Ipv4Allow &list, const char *ip, uint32_t now) { + struct sockaddr_storage peer {}; + socklen_t len = set_sockaddr(reinterpret_cast(&peer), sizeof(peer), ip, 0); + EXPECT_NE(len, 0); + return list.admit(reinterpret_cast(&peer), len, "test", now); +} + +TEST(Ipv4Allow, AdmitLogsARejectAtMostOncePerInterval) { + ThrottledAllow list; + list.set(ENTRIES, std::size(ENTRIES)); + EXPECT_TRUE(admits_peer(list, "192.168.175.66", 1000)); + EXPECT_TRUE(admits_peer(list, "::ffff:192.168.175.20", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 0u); + + EXPECT_FALSE(admits_peer(list, "10.0.0.1", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Inside the interval the peer is still refused, but the log line is skipped. + EXPECT_FALSE(admits_peer(list, "10.0.0.2", 1001)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + EXPECT_FALSE(admits_peer(list, "10.0.0.3", 5999)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Exactly one interval later it logs again. + EXPECT_FALSE(admits_peer(list, "10.0.0.4", 6000)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + // An admitted peer leaves the throttle alone. + EXPECT_TRUE(admits_peer(list, "192.168.175.1", 6001)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + list.dump_config("test"); +} + TEST(Ipv4Allow, EmptyAllowsEveryPeer) { Ipv4Allow list; EXPECT_TRUE(list.allows(htonl(0xC0A8AF01))); From 35276a38bc7e769f8e9c2f32ec1757338a354e34 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 17 Oct 2026 18:18:52 +0100 Subject: [PATCH 515/651] [rtsp_server] Add an RTSP server that streams the esp32_camera picture (#17990) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/camera/camera.h | 3 +- esphome/components/rtsp_server/__init__.py | 85 +++ .../components/rtsp_server/rtsp_server.cpp | 144 +++++ esphome/components/rtsp_server/rtsp_server.h | 50 ++ .../components/rtsp_server/rtsp_session.cpp | 545 ++++++++++++++++++ esphome/components/rtsp_server/rtsp_session.h | 119 ++++ esphome/core/defines.h | 1 + tests/components/rtsp_server/__init__.py | 13 + tests/components/rtsp_server/common.yaml | 10 + .../rtsp_server/test.esp32-idf.yaml | 3 + .../rtsp_server/test_rtsp_session_host.cpp | 367 ++++++++++++ 12 files changed, 1340 insertions(+), 1 deletion(-) create mode 100644 esphome/components/rtsp_server/__init__.py create mode 100644 esphome/components/rtsp_server/rtsp_server.cpp create mode 100644 esphome/components/rtsp_server/rtsp_server.h create mode 100644 esphome/components/rtsp_server/rtsp_session.cpp create mode 100644 esphome/components/rtsp_server/rtsp_session.h create mode 100644 tests/components/rtsp_server/__init__.py create mode 100644 tests/components/rtsp_server/common.yaml create mode 100644 tests/components/rtsp_server/test.esp32-idf.yaml create mode 100644 tests/components/rtsp_server/test_rtsp_session_host.cpp diff --git a/CODEOWNERS b/CODEOWNERS index c9ff7c6bb32..f23f3613032 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -512,6 +512,7 @@ esphome/components/rp2040_pwm/* @jesserockz esphome/components/rp2_ble_tracker/* @bdraco esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 +esphome/components/rtsp_server/* @max246 esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index 433361d2985..9e3a95220fa 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -11,8 +11,9 @@ namespace esphome::camera { * IDLE: Camera requests to send an image to the API. * API_REQUESTER: API requests a new image. * WEB_REQUESTER: ESP32 web server request an image. Ignored by API. + * RTSP_REQUESTER: RTSP server requests a stream of images. Ignored by API and web server. */ -enum CameraRequester : uint8_t { IDLE, API_REQUESTER, WEB_REQUESTER }; +enum CameraRequester : uint8_t { IDLE, API_REQUESTER, WEB_REQUESTER, RTSP_REQUESTER }; /// Enumeration of different pixel formats. enum PixelFormat : uint8_t { diff --git a/esphome/components/rtsp_server/__init__.py b/esphome/components/rtsp_server/__init__.py new file mode 100644 index 00000000000..86b1d380267 --- /dev/null +++ b/esphome/components/rtsp_server/__init__.py @@ -0,0 +1,85 @@ +import esphome.codegen as cg +from esphome.components import network, socket +from esphome.components.const import CONF_ALLOWED_IPS +from esphome.components.esp32_camera import ( + CONF_JPEG_QUALITY, + CONF_PIXEL_FORMAT, + DOMAIN as ESP32_CAMERA_DOMAIN, + FRAME_SIZES, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MAX_CONNECTIONS, CONF_PORT, CONF_RESOLUTION +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@max246"] +AUTO_LOAD = ["camera", "socket"] +DEPENDENCIES = ["network"] +DOMAIN = "rtsp_server" + +# RFC 2435 stores width and height in units of 8 pixels in one byte each. +RTP_JPEG_MAX_DIMENSION = 255 * 8 + +rtsp_server_ns = cg.esphome_ns.namespace("rtsp_server") +RTSPServer = rtsp_server_ns.class_("RTSPServer", cg.Component) + + +def _consume_rtsp_server_sockets(config: ConfigType) -> ConfigType: + socket.consume_sockets(config[CONF_MAX_CONNECTIONS], DOMAIN)(config) + socket.consume_sockets(1, DOMAIN, socket.SocketType.TCP_LISTEN)(config) + # A 13 KB frame fills the default 5.7 KB TCP send buffer four segments at a time; over Wi-Fi + # that is about 1 fps, so ask for the larger buffers (network: enable_high_performance: false undoes it) + network.require_high_performance_networking() + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RTSPServer), + cv.Optional(CONF_PORT, default=554): cv.port, + cv.Optional(CONF_MAX_CONNECTIONS, default=2): cv.int_range(min=1, max=4), + cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA, + } + ).extend(cv.COMPONENT_SCHEMA), + _consume_rtsp_server_sockets, +) + + +def _final_validate(config: ConfigType) -> None: + camera = fv.full_config.get().get(ESP32_CAMERA_DOMAIN) + if camera is None: + raise cv.Invalid( + f"{DOMAIN} requires a camera, add an '{ESP32_CAMERA_DOMAIN}:' block" + ) + pixel_format = camera[CONF_PIXEL_FORMAT] + # A GRAYSCALE frame converts to a one-component JPEG, which RTP/JPEG cannot carry. + if pixel_format == "GRAYSCALE": + raise cv.Invalid( + f"{DOMAIN} cannot stream '{CONF_PIXEL_FORMAT}: GRAYSCALE', use a color " + f"format in '{ESP32_CAMERA_DOMAIN}:'" + ) + # Other formats are converted to JPEG only when jpeg_quality is set. + if pixel_format != "JPEG" and camera[CONF_JPEG_QUALITY] == 0: + raise cv.Invalid( + f"{DOMAIN} streams JPEG frames, set '{CONF_JPEG_QUALITY}' in " + f"'{ESP32_CAMERA_DOMAIN}:' to convert '{CONF_PIXEL_FORMAT}: {pixel_format}'" + ) + resolution = camera[CONF_RESOLUTION] + # The enum names end in the frame size, e.g. ESP32_CAMERA_SIZE_2560X1440 + width, height = map(int, str(FRAME_SIZES[resolution]).rsplit("_", 1)[1].split("X")) + if max(width, height) > RTP_JPEG_MAX_DIMENSION: + raise cv.Invalid( + f"{DOMAIN} supports frames up to {RTP_JPEG_MAX_DIMENSION} pixels per side, " + f"lower '{CONF_RESOLUTION}' in '{ESP32_CAMERA_DOMAIN}:' (got {width}x{height})" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_PORT]) + await cg.register_component(var, config) + cg.add_define("MAX_RTSP_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) + socket.add_ipv4_allow(var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID]) diff --git a/esphome/components/rtsp_server/rtsp_server.cpp b/esphome/components/rtsp_server/rtsp_server.cpp new file mode 100644 index 00000000000..3c344e354d7 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_server.cpp @@ -0,0 +1,144 @@ +#include "rtsp_server.h" + +#ifdef USE_CAMERA + +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::rtsp_server { + +ESPHOME_LOG_TAG(TAG, "rtsp_server"); + +void RTSPServer::socket_failed_(const char *what) { + ESP_LOGE(TAG, "Socket %s failed: errno %d", what, errno); + delete this->socket_; + this->socket_ = nullptr; + this->mark_failed(); +} + +void RTSPServer::setup() { + this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); + if (this->socket_ == nullptr) { + ESP_LOGE(TAG, "Socket creation failed: errno %d", errno); + this->mark_failed(); + return; + } + + int enable = 1; + this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)); + + if (this->socket_->setblocking(false) != 0) { + this->socket_failed_("nonblocking"); + return; + } + + struct sockaddr_storage server {}; + socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); + if (sl == 0 || this->socket_->bind((struct sockaddr *) &server, sl) != 0) { + this->socket_failed_("bind"); + return; + } + + if (this->socket_->listen(4) != 0) { + this->socket_failed_("listen"); + return; + } + + camera::Camera::instance()->add_listener(this); +} + +void RTSPServer::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (this->socket_ != nullptr && this->socket_->ready()) { + this->accept_new_connections_(now); + } + + bool playing = false; + for (auto &session : this->sessions_) { + session.loop(now, this->port_); + playing |= session.is_playing(); + } + + if (playing != this->streaming_) { + this->streaming_ = playing; + if (playing) { + camera::Camera::instance()->start_stream(camera::RTSP_REQUESTER); + } else { + camera::Camera::instance()->stop_stream(camera::RTSP_REQUESTER); + } + } +} + +void RTSPServer::accept_new_connections_(uint32_t now) { + while (true) { + struct sockaddr_storage source_addr; + socklen_t addr_len = sizeof(source_addr); + + auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); + if (!sock) + break; +#ifdef USE_SOCKET_IPV4_ALLOW + if (!this->allow_.admit(reinterpret_cast(&source_addr), addr_len, TAG, now)) + continue; +#endif + + RTSPSession *free_slot = nullptr; + for (auto &session : this->sessions_) { + if (!session.is_active()) { + free_slot = &session; + break; + } + } + if (free_slot == nullptr) { + ESP_LOGW(TAG, "Max connections (%d) reached, rejecting connection", MAX_RTSP_CONNECTIONS); + continue; + } + + ESP_LOGD(TAG, "Accepted RTSP connection"); + free_slot->open(std::move(sock), now); + } +} + +void RTSPServer::on_camera_image(const std::shared_ptr &image) { + if (!this->streaming_ || !image->was_requested_by(camera::RTSP_REQUESTER)) + return; + + JpegInfo jpeg; + if (!parse_jpeg_info(image->get_data_buffer(), image->get_data_length(), jpeg) || + jpeg.width > RTP_JPEG_MAX_DIMENSION || jpeg.height > RTP_JPEG_MAX_DIMENSION) { + if (!this->frame_warned_) { + this->frame_warned_ = true; + ESP_LOGW(TAG, + "Dropping frame %" PRIu16 "x%" PRIu16 ": not a baseline 4:2:2/4:2:0 JPEG without restart markers" + " of at most %" PRIu16 " px per side", + jpeg.width, jpeg.height, RTP_JPEG_MAX_DIMENSION); + } + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + for (auto &session : this->sessions_) { + session.on_new_frame(image, jpeg, now); + } +} + +void RTSPServer::dump_config() { + ESP_LOGCONFIG(TAG, + "RTSP Server:\n" + " Port: %u\n" + " Max connections: %u", + this->port_, MAX_RTSP_CONNECTIONS); +#ifdef USE_SOCKET_IPV4_ALLOW + this->allow_.dump_config(TAG); +#endif +} + +float RTSPServer::get_setup_priority() const { return setup_priority::LATE; } + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_server.h b/esphome/components/rtsp_server/rtsp_server.h new file mode 100644 index 00000000000..48b7fd77ed5 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_server.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CAMERA + +#include +#include + +#include "esphome/components/camera/camera.h" +#ifdef USE_SOCKET_IPV4_ALLOW +#include "esphome/components/socket/ipv4_allow.h" +#endif +#include "esphome/components/socket/socket.h" +#include "esphome/core/component.h" +#include "rtsp_session.h" + +namespace esphome::rtsp_server { + +class RTSPServer : public Component, public camera::CameraListener { + public: + explicit RTSPServer(uint16_t port) : port_(port) {} + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + void on_camera_image(const std::shared_ptr &image) override; +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->allow_.set(entries, count); } +#endif + + protected: + void accept_new_connections_(uint32_t now); + void socket_failed_(const char *what); + + socket::ListenSocket *socket_{nullptr}; + std::array sessions_{}; +#ifdef USE_SOCKET_IPV4_ALLOW + socket::Ipv4Allow allow_; +#endif + uint16_t port_; + bool streaming_{false}; + bool frame_warned_{false}; +}; + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_session.cpp b/esphome/components/rtsp_server/rtsp_session.cpp new file mode 100644 index 00000000000..ee188f07b20 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_session.cpp @@ -0,0 +1,545 @@ +#include "rtsp_session.h" + +#ifdef USE_CAMERA + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::rtsp_server { + +ESPHOME_LOG_TAG(TAG, "rtsp_server"); + +static constexpr uint8_t RTP_PAYLOAD_TYPE_JPEG = 26; +static constexpr uint32_t RTP_CLOCK_RATE = 90000; +static constexpr uint32_t SESSION_TIMEOUT_MS = 60000; +static constexpr char REQUEST_END[] = "\r\n\r\n"; +static constexpr size_t REQUEST_END_LEN = sizeof(REQUEST_END) - 1; +static constexpr uint8_t ZERO_FILL[64]{}; +static constexpr char SDP_BODY[] = "v=0\r\n" + "o=- 0 0 IN IP4 0.0.0.0\r\n" + "s=ESPHome RTSP\r\n" + "t=0 0\r\n" + "m=video 0 RTP/AVP 26\r\n" + "a=control:trackID=0\r\n"; + +namespace { + +StringRef trim(const StringRef &s) { + const char *start = s.c_str(); + size_t len = s.size(); + while (len > 0 && (*start == ' ' || *start == '\t')) { + start++; + len--; + } + while (len > 0 && (start[len - 1] == ' ' || start[len - 1] == '\t' || start[len - 1] == '\r')) { + len--; + } + return {start, len}; +} + +const char *find_request_end(const char *buf, size_t len) { + for (size_t i = 0; i + REQUEST_END_LEN <= len; i++) { + if (std::memcmp(buf + i, REQUEST_END, REQUEST_END_LEN) == 0) + return buf + i; + } + return nullptr; +} + +/// Bounded substring search; StringRef::find() uses strstr and could run past the header value. +bool contains(const StringRef &hay, const StringRef &needle) { + if (needle.size() > hay.size()) + return false; + for (size_t i = 0; i + needle.size() <= hay.size(); i++) { + if (std::memcmp(hay.c_str() + i, needle.c_str(), needle.size()) == 0) + return true; + } + return false; +} + +} // namespace + +/// Scans JPEG marker segments (SOF0/1, DQT, SOS) for the dimensions, quantization tables and the start of +/// the entropy-coded scan data. Assumes the standard Huffman tables (true for esp32-camera), so DHT segments +/// are not parsed; receivers rebuild them per RFC 2435. Only three-component 4:2:2 or 4:2:0 frames without +/// restart markers (DRI) can be carried as RFC 2435 types 0 and 1; anything else returns false. +bool parse_jpeg_info(const uint8_t *data, size_t len, JpegInfo &out) { + if (len < 4 || data[0] != 0xFF || data[1] != 0xD8) + return false; // no SOI + + size_t pos = 2; + while (pos + 2 <= len) { + if (data[pos] != 0xFF) { + pos++; // resync on a stray non-marker byte + continue; + } + uint8_t marker = data[pos + 1]; + if (marker == 0xFF) { + pos++; // fill byte before the real marker + continue; + } + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD9)) { + pos += 2; // standalone marker, no length field (TEM, RSTn, SOI, EOI) + continue; + } + if (pos + 4 > len) + break; + + uint16_t seg_len = encode_uint16(data[pos + 2], data[pos + 3]); + size_t seg_start = pos + 4; + if (seg_len < 2 || seg_start + (seg_len - 2) > len) + return false; + size_t seg_end = seg_start + (seg_len - 2); + + if (marker == 0xC0 || marker == 0xC1) { // SOF0 / SOF1 + if (seg_len < 8 + 3 * 3 || data[seg_start + 5] != 3) + return false; // not three components + out.height = encode_uint16(data[seg_start + 1], data[seg_start + 2]); + out.width = encode_uint16(data[seg_start + 3], data[seg_start + 4]); + const uint8_t sampling = data[seg_start + 7]; // Y component H/V sampling factors + if (sampling == 0x21) { + out.type = 0; // 4:2:2 + } else if (sampling == 0x22) { + out.type = 1; // 4:2:0 + } else { + return false; + } + } else if (marker == 0xDD) { // DRI + return false; + } else if (marker == 0xDB) { // DQT, one or more tables back to back + size_t p = seg_start; + while (p < seg_end) { + uint8_t precision = data[p] >> 4; + uint8_t table_id = data[p] & 0x0F; + size_t table_len = precision == 0 ? QTABLE_LEN : 2 * QTABLE_LEN; + if (p + 1 + table_len > seg_end) + break; + if (precision == 0 && table_id == 0) { + out.qtable0 = &data[p + 1]; + } else if (precision == 0 && table_id == 1) { + out.qtable1 = &data[p + 1]; + } + p += 1 + table_len; + } + } else if (marker == 0xDA) { // SOS, scan data follows + if (out.width == 0 || out.height == 0 || out.qtable0 == nullptr) + return false; + if (out.qtable1 == nullptr) + out.qtable1 = out.qtable0; + out.scan_data = data + seg_end; + out.scan_len = len - seg_end; + if (out.scan_len >= 2 && out.scan_data[out.scan_len - 2] == 0xFF && out.scan_data[out.scan_len - 1] == 0xD9) + out.scan_len -= 2; // the RFC 2435 payload is the entropy-coded data only + return true; + } + + pos = seg_end; + } + return false; +} + +void RTSPSession::open(std::unique_ptr socket, uint32_t now) { + this->socket_ = std::move(socket); + this->socket_->setblocking(false); + int enable = 1; + this->socket_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(enable)); + this->should_close_ = false; + this->close_after_send_ = false; + this->read_pending_ = false; + this->request_len_ = 0; + this->skip_len_ = 0; + this->filler_len_ = 0; + this->cseq_ = 0; + this->last_frame_ms_ = 0; + this->last_activity_ms_ = now; + this->ssrc_ = random_uint32(); + this->sequence_number_ = static_cast(random_uint32()); + this->rtp_timestamp_ = random_uint32(); + format_hex_to(this->session_id_, random_uint32()); +} + +void RTSPSession::close_() { + this->frame_.reset(); + this->socket_.reset(); + this->state_ = State::INIT; + this->iov_pos_ = 0; + this->iov_count_ = 0; + this->filler_len_ = 0; +} + +void RTSPSession::loop(uint32_t now, uint16_t port) { + if (this->socket_ == nullptr) + return; + if (now - this->last_activity_ms_ > SESSION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Session %s timed out", this->session_id_); + this->should_close_ = true; + } else { + this->service_(now, port); + } + if (this->should_close_) + this->close_(); +} + +void RTSPSession::service_(uint32_t now, uint16_t port) { + if (this->frame_ != nullptr && now - this->frame_progress_ms_ > FRAME_STALL_MS) { + ESP_LOGV(TAG, "Session %s stalled, dropping frame", this->session_id_); + this->drop_frame_(); + } + if (!this->flush_(now)) + return; + if (!this->close_after_send_) + this->read_requests_(now, port); + // Drain the current frame until done or the socket backs up; one packet per loop would cap the frame rate. + while (this->flush_(now)) { + if (this->close_after_send_) { + this->should_close_ = true; + return; + } + if (!this->queue_next_packet_()) + return; + } +} + +void RTSPSession::read_requests_(uint32_t now, uint16_t port) { + // A pipelined request may already be buffered; it is handled once the previous response is written. + if (this->process_input_(port)) + return; + if (!this->read_pending_ && !this->socket_->ready()) + return; + this->read_pending_ = false; + + while (true) { + if (this->request_len_ >= this->request_buf_.size()) { + ESP_LOGW(TAG, "Request too large, closing session"); + this->should_close_ = true; + return; + } + ssize_t n = this->socket_->read(this->request_buf_.data() + this->request_len_, + this->request_buf_.size() - this->request_len_); + if (n > 0) { + this->request_len_ += static_cast(n); + this->last_activity_ms_ = now; + if (this->process_input_(port)) { + this->read_pending_ = true; // stopped before EAGAIN, so ready() may not report what is left + return; + } + continue; + } + if (n == 0) { + this->should_close_ = true; + return; + } + if (errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "Read failed: errno %d", errno); + this->should_close_ = true; + } + return; + } +} + +bool RTSPSession::process_input_(uint16_t port) { + while (this->request_len_ > 0) { + if (this->skip_len_ > 0) { + const size_t len = std::min(this->skip_len_, this->request_len_); + this->consume_input_(len); + this->skip_len_ -= len; + continue; + } + char *buf = this->request_buf_.data(); + if (buf[0] == '$') { // interleaved packet: '$', channel, 16-bit length, payload + if (this->request_len_ < INTERLEAVED_HEADER_LEN) + return false; + this->skip_len_ = + INTERLEAVED_HEADER_LEN + encode_uint16(static_cast(buf[2]), static_cast(buf[3])); + continue; + } + const char *end = find_request_end(buf, this->request_len_); + if (end == nullptr) + return false; // wait for the rest of the request + const size_t len = end - buf; + buf[len] = '\0'; // over the terminator's first CR + this->handle_request_(StringRef(buf, len), port); + this->consume_input_(len + REQUEST_END_LEN); + return true; + } + return false; +} + +void RTSPSession::consume_input_(size_t len) { + this->request_len_ -= len; + std::memmove(this->request_buf_.data(), this->request_buf_.data() + len, this->request_len_); +} + +void RTSPSession::handle_request_(StringRef request, uint16_t port) { + size_t line_end = request.find("\r\n"); + StringRef first_line = line_end == std::string::npos ? request : StringRef(request.c_str(), line_end); + + this->cseq_ = 0; + StringRef transport; + int64_t content_length = 0; + + // Headers first: even a 400 reply has to echo the request's CSeq + size_t pos = line_end == std::string::npos ? request.size() : line_end + 2; + while (pos < request.size()) { + size_t next = request.find("\r\n", pos); + size_t header_end = next == std::string::npos ? request.size() : next; + StringRef header(request.c_str() + pos, header_end - pos); + + size_t colon = header.find(':'); + if (colon != std::string::npos) { + StringRef key = trim(StringRef(header.c_str(), colon)); + StringRef value = trim(StringRef(header.c_str() + colon + 1, header.size() - colon - 1)); + if (str_equals_case_insensitive(key, StringRef::from_lit("CSeq"))) { + this->cseq_ = static_cast(stol(value)); + } else if (str_equals_case_insensitive(key, StringRef::from_lit("Transport"))) { + transport = value; + } else if (str_equals_case_insensitive(key, StringRef::from_lit("Content-Length"))) { + content_length = stol(value); + } + } + + if (next == std::string::npos) + break; + pos = next + 2; + } + + if (content_length < 0 || content_length > static_cast(this->request_buf_.size())) { + this->queue_response_(400, "Bad Request"); + this->close_after_send_ = true; + return; + } + // The body is ignored; it is discarded as it arrives, like an interleaved packet. + this->skip_len_ = static_cast(content_length); + + size_t sp1 = first_line.find(' '); + size_t sp2 = sp1 == std::string::npos ? std::string::npos : first_line.find(' ', sp1 + 1); + if (sp1 == std::string::npos || sp2 == std::string::npos) { + this->queue_response_(400, "Bad Request"); + return; + } + StringRef method(first_line.c_str(), sp1); + + if (str_equals_case_insensitive(method, StringRef::from_lit("OPTIONS"))) { + this->queue_response_(200, "OK", "Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN, GET_PARAMETER\r\n"); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("DESCRIBE"))) { + char addr_buf[socket::SOCKADDR_STR_LEN]; + this->socket_->getsockname_to(addr_buf); + const bool ipv6 = std::strchr(addr_buf, ':') != nullptr; + this->queue_response_(200, "OK", + "Content-Base: rtsp://%s%s%s:%u/\r\n" + "Content-Type: application/sdp\r\n" + "Content-Length: %u\r\n", + ipv6 ? "[" : "", addr_buf, ipv6 ? "]" : "", port, + static_cast(sizeof(SDP_BODY) - 1)); + this->iov_[this->iov_count_++] = {const_cast(SDP_BODY), sizeof(SDP_BODY) - 1}; + } else if (str_equals_case_insensitive(method, StringRef::from_lit("SETUP"))) { + this->handle_setup_(transport); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("PLAY"))) { + this->handle_play_(); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("TEARDOWN"))) { + this->stop_playing_(); + this->queue_response_(200, "OK"); + this->close_after_send_ = true; + } else if (str_equals_case_insensitive(method, StringRef::from_lit("GET_PARAMETER"))) { + this->queue_response_(200, "OK"); + } else { + this->queue_response_(501, "Not Implemented"); + } +} + +void RTSPSession::queue_response_(uint16_t code, const char *reason, const char *extra_headers_fmt, ...) { + char *buf = this->send_buf_.data(); + const size_t size = this->send_buf_.size(); + size_t pos = buf_append_printf(buf, size, 0, "RTSP/1.0 %u %s\r\nCSeq: %" PRIu32 "\r\n", code, reason, this->cseq_); + if (this->state_ != State::INIT) + pos = buf_append_printf(buf, size, pos, "Session: %s;timeout=60\r\n", this->session_id_); + if (extra_headers_fmt != nullptr && pos < size) { + va_list args; + va_start(args, extra_headers_fmt); + int written = vsnprintf(buf + pos, size - pos, extra_headers_fmt, args); + va_end(args); + if (written > 0) + pos = std::min(pos + static_cast(written), size); + } + pos = buf_append_str(buf, size, pos, "\r\n"); + this->iov_[0] = {buf, std::min(pos, size - 1)}; + this->iov_pos_ = 0; + this->iov_count_ = 1; + this->sending_packet_ = false; +} + +void RTSPSession::handle_setup_(StringRef transport_header) { + if (!contains(transport_header, StringRef::from_lit("RTP/AVP/TCP"))) { + this->queue_response_(461, "Unsupported Transport"); + return; + } + // A SETUP while streaming stops playback, ending in READY like a first SETUP. + this->stop_playing_(); + // The single video track always uses interleaved channels 0 (RTP) and 1 (RTCP, unused). + this->queue_response_(200, "OK", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n"); +} + +void RTSPSession::handle_play_() { + if (this->state_ == State::INIT) { + this->queue_response_(455, "Method Not Valid In This State"); + return; + } + this->state_ = State::PLAYING; + this->frame_.reset(); // a repeated PLAY may arrive mid-frame + this->queue_response_(200, "OK", "Range: npt=0.000-\r\n"); +} + +void RTSPSession::stop_playing_() { + this->state_ = State::READY; + this->frame_.reset(); +} + +void RTSPSession::on_new_frame(const std::shared_ptr &image, const JpegInfo &jpeg, uint32_t now) { + // Pending output means a frame is in flight or a stalled one is still being padded out. + if (this->state_ != State::PLAYING || this->frame_ != nullptr || this->iov_pos_ < this->iov_count_ || + this->filler_len_ > 0) + return; + this->frame_ = image; + this->jpeg_ = jpeg; + this->frame_sent_ = 0; + this->frame_progress_ms_ = now; + + uint32_t elapsed_ms = this->last_frame_ms_ == 0 ? 0 : now - this->last_frame_ms_; + this->rtp_timestamp_ += static_cast((static_cast(elapsed_ms) * RTP_CLOCK_RATE) / 1000); + this->last_frame_ms_ = now; +} + +bool RTSPSession::queue_next_packet_() { + if (this->frame_ == nullptr) + return false; + if (this->frame_sent_ >= this->jpeg_.scan_len) { + this->frame_.reset(); // its last packet is written + return false; + } + + const bool first_fragment = this->frame_sent_ == 0; + const size_t header_len = + INTERLEAVED_HEADER_LEN + RTP_HEADER_LEN + JPEG_HEADER_LEN + (first_fragment ? QUANT_HEADER_LEN : 0); + const size_t qtables_len = first_fragment ? 2 * QTABLE_LEN : 0; + const size_t payload_len = + std::min(this->jpeg_.scan_len - this->frame_sent_, MAX_PACKET_SIZE - header_len - qtables_len); + const bool last_fragment = this->frame_sent_ + payload_len >= this->jpeg_.scan_len; + const size_t rtp_len = header_len - INTERLEAVED_HEADER_LEN + qtables_len + payload_len; + + auto *buf = reinterpret_cast(this->send_buf_.data()); + size_t pos = 0; + buf[pos++] = '$'; + buf[pos++] = 0; // interleaved channel 0 + buf[pos++] = static_cast(rtp_len >> 8); + buf[pos++] = static_cast(rtp_len); + + buf[pos++] = 0x80; // V=2, P=0, X=0, CC=0 + buf[pos++] = static_cast((last_fragment ? 0x80 : 0x00) | RTP_PAYLOAD_TYPE_JPEG); + buf[pos++] = static_cast(this->sequence_number_ >> 8); + buf[pos++] = static_cast(this->sequence_number_); + this->sequence_number_++; + buf[pos++] = static_cast(this->rtp_timestamp_ >> 24); + buf[pos++] = static_cast(this->rtp_timestamp_ >> 16); + buf[pos++] = static_cast(this->rtp_timestamp_ >> 8); + buf[pos++] = static_cast(this->rtp_timestamp_); + buf[pos++] = static_cast(this->ssrc_ >> 24); + buf[pos++] = static_cast(this->ssrc_ >> 16); + buf[pos++] = static_cast(this->ssrc_ >> 8); + buf[pos++] = static_cast(this->ssrc_); + + // RFC 2435 main JPEG header + buf[pos++] = 0; // type-specific + buf[pos++] = static_cast(this->frame_sent_ >> 16); + buf[pos++] = static_cast(this->frame_sent_ >> 8); + buf[pos++] = static_cast(this->frame_sent_); + buf[pos++] = this->jpeg_.type; + buf[pos++] = 255; // Q >= 128: the quantization tables follow in the first packet + buf[pos++] = static_cast(this->jpeg_.width / 8); + buf[pos++] = static_cast(this->jpeg_.height / 8); + + uint8_t count = 0; + if (first_fragment) { + buf[pos++] = 0; // MBZ + buf[pos++] = 0; // 8-bit precision + buf[pos++] = static_cast(qtables_len >> 8); + buf[pos++] = static_cast(qtables_len); + this->iov_[0] = {buf, header_len}; + this->iov_[1] = {const_cast(this->jpeg_.qtable0), QTABLE_LEN}; + this->iov_[2] = {const_cast(this->jpeg_.qtable1), QTABLE_LEN}; + count = 3; + } else { + this->iov_[0] = {buf, header_len}; + count = 1; + } + this->iov_[count++] = {const_cast(this->jpeg_.scan_data + this->frame_sent_), payload_len}; + this->iov_pos_ = 0; + this->iov_count_ = count; + this->sending_packet_ = true; + this->frame_sent_ += payload_len; + return true; +} + +void RTSPSession::drop_frame_() { + this->frame_.reset(); + if (!this->sending_packet_ || this->iov_pos_ >= this->iov_count_) + return; + // Even an unwritten packet is kept, so output stays pending and on_new_frame() takes nothing until the + // socket drains. Entries after the header point into the frame. + for (uint8_t i = std::max(this->iov_pos_, 1); i < this->iov_count_; i++) + this->filler_len_ += this->iov_[i].iov_len; + this->iov_count_ = std::max(this->iov_pos_, 1); + this->sending_packet_ = false; +} + +bool RTSPSession::flush_(uint32_t now) { + if (this->should_close_) + return false; + while (true) { + if (this->iov_pos_ >= this->iov_count_) { + if (this->filler_len_ == 0) + return true; + this->iov_pos_ = 0; + this->iov_count_ = 0; + while (this->filler_len_ > 0 && this->iov_count_ < this->iov_.size()) { + const size_t len = std::min(this->filler_len_, sizeof(ZERO_FILL)); + this->iov_[this->iov_count_++] = {const_cast(ZERO_FILL), len}; + this->filler_len_ -= len; + } + } + ssize_t written = this->socket_->writev(&this->iov_[this->iov_pos_], this->iov_count_ - this->iov_pos_); + if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return false; // retry next loop + if (written <= 0) { + if (written < 0) { + ESP_LOGW(TAG, "Write failed: errno %d", errno); + } + this->should_close_ = true; + return false; + } + this->last_activity_ms_ = now; + this->frame_progress_ms_ = now; + // Step over what was written; a partial write leaves the rest of the current entry pending. + auto remaining = static_cast(written); + while (remaining > 0) { + struct iovec &entry = this->iov_[this->iov_pos_]; + if (remaining < entry.iov_len) { + entry.iov_base = static_cast(entry.iov_base) + remaining; + entry.iov_len -= remaining; + break; + } + remaining -= entry.iov_len; + this->iov_pos_++; + } + } +} + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_session.h b/esphome/components/rtsp_server/rtsp_session.h new file mode 100644 index 00000000000..b34b7faecba --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_session.h @@ -0,0 +1,119 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CAMERA + +#include +#include + +#include "esphome/components/camera/camera.h" +#include "esphome/components/socket/socket.h" +#include "esphome/core/string_ref.h" + +namespace esphome::rtsp_server { + +static constexpr size_t INTERLEAVED_HEADER_LEN = 4; +static constexpr size_t RTP_HEADER_LEN = 12; +static constexpr size_t JPEG_HEADER_LEN = 8; +static constexpr size_t QUANT_HEADER_LEN = 4; +static constexpr size_t QTABLE_LEN = 64; // one 8-bit precision quantization table +static constexpr size_t RTP_JPEG_PAYLOAD_SIZE = 1024; +static constexpr size_t MAX_PACKET_SIZE = INTERLEAVED_HEADER_LEN + RTP_HEADER_LEN + JPEG_HEADER_LEN + QUANT_HEADER_LEN + + 2 * QTABLE_LEN + RTP_JPEG_PAYLOAD_SIZE; +// RFC 2435 stores width and height in units of 8 pixels in one byte each. +static constexpr uint16_t RTP_JPEG_MAX_DIMENSION = 255 * 8; +// A frame whose packets make no write progress for this long is dropped, so a stalled client cannot hold +// the camera image (esp32_camera captures nothing while any copy is held). +static constexpr uint32_t FRAME_STALL_MS = 500; + +/// The fields of a JPEG frame that an RFC 2435 RTP/JPEG payload needs. Pointers refer into the frame. +struct JpegInfo { + const uint8_t *qtable0{nullptr}; + const uint8_t *qtable1{nullptr}; // same as qtable0 when the frame has one table + const uint8_t *scan_data{nullptr}; + size_t scan_len{0}; // entropy-coded data, without the trailing EOI marker + uint16_t width{0}; + uint16_t height{0}; + uint8_t type{0}; // RFC 2435 type: 0 = 4:2:2 subsampling, 1 = 4:2:0 +}; + +bool parse_jpeg_info(const uint8_t *data, size_t len, JpegInfo &out); + +/** A single RTSP client: one TCP connection carrying both the RTSP control channel and, once playing, + * RTP/JPEG media interleaved on the same socket (RFC 2326 §10.12). No UDP transport is supported. + * The object is a reusable slot: open() starts a connection, and it closes itself when the connection ends. + */ +class RTSPSession { + public: + bool is_active() const { return this->socket_ != nullptr; } + bool is_playing() const { return this->state_ == State::PLAYING; } + + void open(std::unique_ptr socket, uint32_t now); + void loop(uint32_t now, uint16_t port); + /// Ignored unless playing with no output pending; packets of the frame being sent point into that frame. + void on_new_frame(const std::shared_ptr &image, const JpegInfo &jpeg, uint32_t now); + + protected: + enum class State : uint8_t { INIT, READY, PLAYING }; + + void service_(uint32_t now, uint16_t port); + void close_(); + void read_requests_(uint32_t now, uint16_t port); + /// Consumes buffered input up to and including the next request; true once a request was handled. + bool process_input_(uint16_t port); + void consume_input_(size_t len); + void handle_request_(StringRef request, uint16_t port); + void handle_setup_(StringRef transport_header); + void handle_play_(); + /// Queues "RTSP/1.0 ", CSeq, Session once set up, the extra headers and the blank line. + void queue_response_(uint16_t code, const char *reason, const char *extra_headers_fmt = nullptr, ...) + __attribute__((format(printf, 4, 5))); + void stop_playing_(); + bool queue_next_packet_(); + /// Releases the frame; the frame-backed rest of the current packet is sent as zeros to keep the framing. + void drop_frame_(); + /// Writes what is queued; true once all of it is written, false while backlogged or after a failure. + bool flush_(uint32_t now); + + std::unique_ptr socket_; + // Held until its last packet is written; esp32_camera captures nothing new while any copy is held. + std::shared_ptr frame_; + JpegInfo jpeg_; + size_t frame_sent_{0}; + + uint32_t cseq_{0}; + uint32_t rtp_timestamp_{0}; + uint32_t ssrc_{0}; + uint32_t last_activity_ms_{0}; + uint32_t last_frame_ms_{0}; + uint32_t frame_progress_ms_{0}; + // Bytes of an interleaved packet (RTCP) or request body from the client still to discard. + uint32_t skip_len_{0}; + uint16_t sequence_number_{0}; + uint16_t request_len_{0}; + // Zeros still owed for a packet whose frame was dropped part way through. + uint16_t filler_len_{0}; + + // Pending output: a response or one RTP packet. Packets point at the frame's tables and scan data. + std::array iov_{}; + uint8_t iov_pos_{0}; + uint8_t iov_count_{0}; + + State state_{State::INIT}; + bool should_close_{false}; + bool close_after_send_{false}; + bool sending_packet_{false}; // iov_ holds an RTP packet rather than a response + bool read_pending_{false}; // reading stopped before EAGAIN; read again without waiting for ready() + char session_id_[9]{}; + + // Requests and interleaved packets from the client. A request is NUL terminated in place before it is + // handled, for StringRef::find(), which uses strstr. + std::array request_buf_{}; + // Response text, or the header of the RTP packet being sent. + std::array send_buf_{}; +}; + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d17dc3f514..dbd6c8d8538 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -268,6 +268,7 @@ #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 +#define MAX_RTSP_CONNECTIONS 2 #define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M diff --git a/tests/components/rtsp_server/__init__.py b/tests/components/rtsp_server/__init__.py new file mode 100644 index 00000000000..25a08c171d9 --- /dev/null +++ b/tests/components/rtsp_server/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # No host camera platform emits USE_CAMERA, and the real to_code needs a configured server. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_CAMERA") + cg.add_define("MAX_RTSP_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + + manifest.to_code = to_code_testing diff --git a/tests/components/rtsp_server/common.yaml b/tests/components/rtsp_server/common.yaml new file mode 100644 index 00000000000..6f1e0dd078a --- /dev/null +++ b/tests/components/rtsp_server/common.yaml @@ -0,0 +1,10 @@ +rtsp_server: + port: 554 + max_connections: 2 + allowed_ips: + - 192.168.1.0/24 + - 10.0.0.5 + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/rtsp_server/test.esp32-idf.yaml b/tests/components/rtsp_server/test.esp32-idf.yaml new file mode 100644 index 00000000000..d186203c80d --- /dev/null +++ b/tests/components/rtsp_server/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml + rtsp_server: !include common.yaml diff --git a/tests/components/rtsp_server/test_rtsp_session_host.cpp b/tests/components/rtsp_server/test_rtsp_session_host.cpp new file mode 100644 index 00000000000..9d9154f587e --- /dev/null +++ b/tests/components/rtsp_server/test_rtsp_session_host.cpp @@ -0,0 +1,367 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/rtsp_server/rtsp_session.h" + +#if defined(USE_HOST) && defined(USE_CAMERA) + +namespace esphome::rtsp_server::testing { + +static constexpr uint16_t PORT = 554; +static constexpr size_t REQUEST_BUF_SIZE = 512; + +class StubImage : public camera::CameraImage { + public: + explicit StubImage(size_t len) : data_(len, 0x55) {} + uint8_t *get_data_buffer() override { return this->data_.data(); } + size_t get_data_length() override { return this->data_.size(); } + bool was_requested_by(camera::CameraRequester requester) const override { return true; } + + protected: + std::vector data_; +}; + +class RTSPSessionUnderTest : public RTSPSession { + public: + bool has_frame() const { return this->frame_ != nullptr; } +}; + +class RTSPSessionTest : public ::testing::Test { + protected: + void SetUp() override { + signal(SIGPIPE, SIG_IGN); + int fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + this->peer_fd_ = fds[1]; + this->session_.open(std::make_unique(fds[0]), this->now_); + } + void TearDown() override { ::close(this->peer_fd_); } + + // Reopens the session on a loopback TCP connection, which (unlike AF_UNIX) accepts partial writes. + void reopen_over_tcp() { + int listener = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(listener, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + ASSERT_EQ(::bind(listener, reinterpret_cast(&addr), sizeof(addr)), 0); + ASSERT_EQ(::listen(listener, 1), 0); + ASSERT_EQ(::getsockname(listener, reinterpret_cast(&addr), &len), 0); + int client = ::socket(AF_INET, SOCK_STREAM, 0); + int size = 8192; + ::setsockopt(client, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); + ASSERT_EQ(::connect(client, reinterpret_cast(&addr), sizeof(addr)), 0); + int server = ::accept(listener, nullptr, nullptr); + ::close(listener); + ASSERT_GE(server, 0); + ::setsockopt(server, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)); +#ifdef SO_SNDLOWAT + int lowat = 1; // macOS otherwise refuses writes smaller than its 2 KiB low-water mark + ::setsockopt(server, SOL_SOCKET, SO_SNDLOWAT, &lowat, sizeof(lowat)); +#endif + ::close(this->peer_fd_); + this->peer_fd_ = client; + this->session_.open(std::make_unique(server), this->now_); + } + + void send(const std::string &data) { + ASSERT_EQ(::write(this->peer_fd_, data.data(), data.size()), static_cast(data.size())); + } + void loops(int count) { + for (int i = 0; i < count; i++) { + usleep(1000); // let loopback TCP deliver + this->session_.loop(this->now_, PORT); + } + } + // Everything the session has written so far. + std::string receive() { + usleep(1000); + std::string out; + char buf[4096]; + while (true) { + ssize_t n = ::recv(this->peer_fd_, buf, sizeof(buf), MSG_DONTWAIT); + if (n <= 0) + break; + out.append(buf, n); + } + return out; + } + static std::string request(const char *method, int cseq, const std::string &extra = "") { + char line[48]; + snprintf(line, sizeof(line), "%s rtsp://host/ RTSP/1.0\r\nCSeq: %d\r\n", method, cseq); + return line + extra + "\r\n"; + } + static size_t count(const std::string &haystack, const std::string &needle) { + size_t n = 0; + for (size_t pos = haystack.find(needle); pos != std::string::npos; pos = haystack.find(needle, pos + 1)) + n++; + return n; + } + void start_playing() { + this->send(request("SETUP", 1, "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")); + this->loops(2); + this->send(request("PLAY", 2)); + this->loops(2); + std::string out = this->receive(); + ASSERT_NE(out.find("CSeq: 2\r\n"), std::string::npos); + ASSERT_TRUE(this->session_.is_playing()); + } + + RTSPSessionUnderTest session_; + int peer_fd_{-1}; + uint32_t now_{1000}; +}; + +TEST_F(RTSPSessionTest, InterleavedRtcpBeforeRequestIsSkipped) { + std::string rtcp("$\x01\x00\x08", 4); + rtcp.append("\x81\xc9\x00\x01\x00\x00\x00\x00", 8); // contains NUL bytes + this->send(rtcp + request("GET_PARAMETER", 7)); + this->loops(1); + std::string out = this->receive(); + EXPECT_EQ(out.rfind("RTSP/1.0 200 OK\r\nCSeq: 7\r\n", 0), 0u) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, InterleavedPacketSplitAcrossReads) { + this->send(std::string("$\x01\x01", 3)); + this->loops(1); + std::string rest("\x00", 1); + rest.append(256, '\0'); + this->send(rest + request("GET_PARAMETER", 8)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 8\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, MalformedRequestLineEchoesCSeq) { + this->send("OPTIONS\r\nCSeq: 9\r\n\r\n"); + this->loops(1); + std::string out = this->receive(); + EXPECT_EQ(out.rfind("RTSP/1.0 400 Bad Request\r\nCSeq: 9\r\n", 0), 0u) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, PipelinedRequestsBothAnswered) { + this->send(request("OPTIONS", 1) + request("GET_PARAMETER", 2)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + size_t first = out.find("CSeq: 1\r\n"); + size_t second = out.find("CSeq: 2\r\n"); + ASSERT_NE(first, std::string::npos); + ASSERT_NE(second, std::string::npos); + EXPECT_LT(first, second); +} + +TEST_F(RTSPSessionTest, RequestBodyIsConsumed) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 6\r\n") + "ping\r\n" + request("OPTIONS", 4)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + EXPECT_EQ(out.find("400"), std::string::npos) << out; + size_t first = out.find("CSeq: 3\r\n"); + size_t second = out.find("CSeq: 4\r\n"); + ASSERT_NE(first, std::string::npos); + ASSERT_NE(second, std::string::npos); + EXPECT_LT(first, second); +} + +TEST_F(RTSPSessionTest, RequestBodySplitAcrossReads) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 6\r\n") + "pi"); + this->loops(2); + this->send("ng\r\n" + request("OPTIONS", 4)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + EXPECT_EQ(out.find("400"), std::string::npos) << out; + EXPECT_NE(out.find("CSeq: 4\r\n"), std::string::npos) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, OversizedContentLengthClosesSession) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 100000\r\n")); + this->loops(2); + EXPECT_EQ(this->receive().rfind("RTSP/1.0 400 Bad Request\r\nCSeq: 3\r\n", 0), 0u); + EXPECT_FALSE(this->session_.is_active()); +} + +static std::string padded_request(size_t total_len) { + std::string head = "GET_PARAMETER rtsp://host/ RTSP/1.0\r\nCSeq: 9\r\nX-Pad: "; + std::string tail = "\r\n\r\n"; + return head + std::string(total_len - head.size() - tail.size(), 'a') + tail; +} + +TEST_F(RTSPSessionTest, RequestOfBufferSizeMinusOneIsHandled) { + this->send(padded_request(REQUEST_BUF_SIZE - 1)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 9\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, RequestFillingTheBufferIsHandled) { + this->send(padded_request(REQUEST_BUF_SIZE)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 9\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, OversizedRequestClosesSession) { + this->send(std::string(REQUEST_BUF_SIZE + 1, 'a')); + this->loops(1); + EXPECT_FALSE(this->session_.is_active()); +} + +// Parses '$' channel length packets after the RTSP responses; false if the stream is not well framed. +static bool parse_interleaved(const std::string &stream, size_t start, std::vector &sequences) { + size_t pos = start; + while (pos < stream.size()) { + if (pos + 4 > stream.size() || stream[pos] != '$') + return false; + size_t len = (static_cast(stream[pos + 2]) << 8) | static_cast(stream[pos + 3]); + if (pos + 4 + len > stream.size() || len < 12) + return false; + sequences.push_back((static_cast(stream[pos + 6]) << 8) | static_cast(stream[pos + 7])); + pos += 4 + len; + } + return true; +} + +static JpegInfo stub_jpeg(StubImage &image) { + JpegInfo jpeg; + jpeg.qtable0 = image.get_data_buffer(); + jpeg.qtable1 = image.get_data_buffer() + QTABLE_LEN; + jpeg.scan_data = image.get_data_buffer() + 2 * QTABLE_LEN; + jpeg.scan_len = image.get_data_length() - 2 * QTABLE_LEN; + jpeg.width = 640; + jpeg.height = 480; + return jpeg; +} + +TEST_F(RTSPSessionTest, StalledWriterDropsFrame) { + this->reopen_over_tcp(); + this->start_playing(); + auto image = std::make_shared(8 * 1024 * 1024); + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(100); // the peer does not read, so the socket buffers fill up + ASSERT_TRUE(this->session_.has_frame()); + EXPECT_GT(image.use_count(), 1); + + this->now_ += FRAME_STALL_MS; + this->loops(1); + EXPECT_TRUE(this->session_.has_frame()) << "dropped before the stall limit"; + + this->now_ += 1; + this->loops(1); + EXPECT_FALSE(this->session_.has_frame()); + EXPECT_EQ(image.use_count(), 1); + EXPECT_TRUE(this->session_.is_active()); + + // Until the socket drains, new frames are refused so the camera can keep capturing for other consumers. + for (int i = 0; i < 5; i++) { + this->now_ += 100; + auto next = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(next, stub_jpeg(*next), this->now_); + this->loops(1); + EXPECT_FALSE(this->session_.has_frame()); + EXPECT_EQ(next.use_count(), 1); + } + + // The packet in flight is completed with zeros, so the interleaved stream stays well framed. + std::string stream; + for (int i = 0; i < 100; i++) { + stream += this->receive(); + this->loops(1); + } + stream += this->receive(); + std::vector sequences; + ASSERT_TRUE(parse_interleaved(stream, 0, sequences)); + ASSERT_FALSE(sequences.empty()); + + // Once drained, the next frame is taken and follows on in sequence. + auto next = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(next, stub_jpeg(*next), this->now_); + EXPECT_TRUE(this->session_.has_frame()); + this->loops(2); + std::vector after; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, after)); + ASSERT_EQ(after.size(), 1u); + EXPECT_EQ(after[0], static_cast(sequences.back() + 1)); + EXPECT_EQ(next.use_count(), 1); +} + +TEST_F(RTSPSessionTest, RepeatPlayContinuesSequence) { + this->start_playing(); + auto image = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(1); + std::vector sequences; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, sequences)); + ASSERT_EQ(sequences.size(), 1u); + + this->send(request("PLAY", 3)); + this->loops(1); + std::string reply = this->receive(); + ASSERT_NE(reply.find("CSeq: 3\r\n"), std::string::npos); + this->now_ += 100; + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(1); + std::vector after; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, after)); + ASSERT_EQ(after.size(), 1u); + EXPECT_EQ(after[0], static_cast(sequences[0] + 1)); +} + +// Minimal JPEG: SOI, DQT (two tables), optional DRI, SOF0, SOS, scan byte, EOI. +static std::vector make_jpeg(uint8_t components, uint8_t y_sampling, bool dri) { + std::vector j = {0xFF, 0xD8, 0xFF, 0xDB, 0x00, 2 + 2 * 65}; + for (uint8_t id = 0; id < 2; id++) { + j.push_back(id); + j.insert(j.end(), 64, 1); + } + if (dri) + j.insert(j.end(), {0xFF, 0xDD, 0x00, 0x04, 0x00, 0x10}); + j.insert(j.end(), + {0xFF, 0xC0, 0x00, static_cast(8 + 3 * components), 8, 0x01, 0xE0, 0x02, 0x80, components}); + for (uint8_t c = 0; c < components; c++) + j.insert(j.end(), {static_cast(c + 1), c == 0 ? y_sampling : static_cast(0x11), + static_cast(c == 0 ? 0 : 1)}); + j.insert(j.end(), {0xFF, 0xDA, 0x00, 0x02, 0x12, 0xFF, 0xD9}); + return j; +} + +TEST(RTSPParseJpeg, AcceptsBaselineColor) { + JpegInfo info; + auto j420 = make_jpeg(3, 0x22, false); + ASSERT_TRUE(parse_jpeg_info(j420.data(), j420.size(), info)); + EXPECT_EQ(info.type, 1); + EXPECT_EQ(info.width, 640); + EXPECT_EQ(info.height, 480); + EXPECT_EQ(info.scan_len, 1u); + JpegInfo info422; + auto j422 = make_jpeg(3, 0x21, false); + ASSERT_TRUE(parse_jpeg_info(j422.data(), j422.size(), info422)); + EXPECT_EQ(info422.type, 0); +} + +TEST(RTSPParseJpeg, RejectsUnsupportedFrames) { + JpegInfo info; + auto gray = make_jpeg(1, 0x11, false); + EXPECT_FALSE(parse_jpeg_info(gray.data(), gray.size(), info)); + auto j444 = make_jpeg(3, 0x11, false); + EXPECT_FALSE(parse_jpeg_info(j444.data(), j444.size(), info)); + auto restart = make_jpeg(3, 0x22, true); + EXPECT_FALSE(parse_jpeg_info(restart.data(), restart.size(), info)); +} + +} // namespace esphome::rtsp_server::testing + +#endif From 4ef17cebdb8c024ee27f73c4ac7f5c2b380675cd Mon Sep 17 00:00:00 2001 From: Hasan Khalil Date: Sat, 17 Oct 2026 14:01:01 -0400 Subject: [PATCH 516/651] [api] Pre-reserve Home Assistant state subscriptions vector (#20986) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/api/api_server.cpp | 3 + esphome/components/homeassistant/__init__.py | 31 ++++++++- .../homeassistant/button/__init__.py | 3 +- .../homeassistant/number/__init__.py | 3 +- .../homeassistant/select/__init__.py | 3 +- .../components/homeassistant/text/__init__.py | 3 +- esphome/core/defines.h | 1 + .../homeassistant/mixed_entities.yaml | 65 +++++++++++++++++++ .../test_state_subscription_count.py | 51 +++++++++++++++ 9 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/homeassistant/mixed_entities.yaml create mode 100644 tests/component_tests/homeassistant/test_state_subscription_count.py diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index a8d14256dcb..99ce39670f2 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -65,6 +65,9 @@ void APIServer::socket_failed_(const LogString *msg) { } void APIServer::setup() { +#if defined(USE_API_HOMEASSISTANT_STATES) && defined(HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT) + this->state_subs_.reserve(HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT); +#endif #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 3851bc3c0bc..6f5360bfed1 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -1,8 +1,11 @@ from collections.abc import Callable, Iterable +from dataclasses import dataclass import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -59,7 +62,28 @@ def final_validate_entity_id(config: ConfigType) -> ConfigType: return config -def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: +@dataclass +class HomeAssistantData: + state_subscription_count: int = 0 + + +def _get_data() -> HomeAssistantData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = HomeAssistantData() + return CORE.data[DOMAIN] + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_state_subscription_count_define() -> None: + """Emit the HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT define with the total count.""" + count = _get_data().state_subscription_count + if count > 0: + cg.add_define("HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT", count) + + +def setup_home_assistant_entity( + var: MockObj, config: ConfigType, state_subscription_count: int = 1 +) -> None: from esphome.components.api import wizard if (buffer := wizard.wizard_input_buffer(config[CONF_ID])) is not None: @@ -70,3 +94,8 @@ def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) cg.add_define("USE_API_HOMEASSISTANT_STATES") + if state_subscription_count > 0: + data = _get_data() + if data.state_subscription_count == 0: + CORE.add_job(_emit_state_subscription_count_define) + data.state_subscription_count += state_subscription_count diff --git a/esphome/components/homeassistant/button/__init__.py b/esphome/components/homeassistant/button/__init__.py index f486a841263..7b4ad004e18 100644 --- a/esphome/components/homeassistant/button/__init__.py +++ b/esphome/components/homeassistant/button/__init__.py @@ -37,4 +37,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await button.new_button(config) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # Button only calls services, does not subscribe to state + setup_home_assistant_entity(var, config, state_subscription_count=0) diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 6288db68805..658cb8762c9 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -39,4 +39,5 @@ async def to_code(config: ConfigType) -> None: step=0, ) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + min/max/step fetches in HomeassistantNumber::setup() + setup_home_assistant_entity(var, config, state_subscription_count=4) diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py index 1bdf0f2cade..e6a129cda0d 100644 --- a/esphome/components/homeassistant/select/__init__.py +++ b/esphome/components/homeassistant/select/__init__.py @@ -55,4 +55,5 @@ async def to_code(config: ConfigType) -> None: options=[], ) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + options fetch in HomeassistantSelect::setup() + setup_home_assistant_entity(var, config, state_subscription_count=2) diff --git a/esphome/components/homeassistant/text/__init__.py b/esphome/components/homeassistant/text/__init__.py index 67377e02d05..eb8613f76d5 100644 --- a/esphome/components/homeassistant/text/__init__.py +++ b/esphome/components/homeassistant/text/__init__.py @@ -37,4 +37,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await text.new_text(config) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + min/max/mode fetches in HomeassistantText::setup() + setup_home_assistant_entity(var, config, state_subscription_count=4) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index dbd6c8d8538..de4dfd2607b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -319,6 +319,7 @@ #define USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #define USE_API_HOMEASSISTANT_SERVICES #define USE_API_HOMEASSISTANT_STATES +#define HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT 1 #define USE_API_NOISE #define USE_API_OUTGOING_CONNECTION #define API_OUTGOING_CONNECTION_PORT 6054 diff --git a/tests/component_tests/homeassistant/mixed_entities.yaml b/tests/component_tests/homeassistant/mixed_entities.yaml new file mode 100644 index 00000000000..8c96e1ad521 --- /dev/null +++ b/tests/component_tests/homeassistant/mixed_entities.yaml @@ -0,0 +1,65 @@ +esphome: + name: test-mixed-ha + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +sensor: + - platform: homeassistant + id: ha_sensor_1 + name: HA Sensor 1 + entity_id: sensor.temp_1 + - platform: homeassistant + id: ha_sensor_2 + name: HA Sensor 2 + entity_id: sensor.temp_2 + +binary_sensor: + - platform: homeassistant + id: ha_binary_sensor + name: HA Binary Sensor + entity_id: binary_sensor.motion + +text_sensor: + - platform: homeassistant + id: ha_text_sensor + name: HA Text Sensor + entity_id: sensor.status_text + +switch: + - platform: homeassistant + id: ha_switch + name: HA Switch + entity_id: switch.relay + +select: + - platform: homeassistant + id: ha_select + name: HA Select + entity_id: select.mode + +number: + - platform: homeassistant + id: ha_number + name: HA Number + entity_id: number.target_temp + +text: + - platform: homeassistant + id: ha_text + name: HA Text + entity_id: text.custom_note + +button: + - platform: homeassistant + id: ha_button + name: HA Button + entity_id: button.restart diff --git a/tests/component_tests/homeassistant/test_state_subscription_count.py b/tests/component_tests/homeassistant/test_state_subscription_count.py new file mode 100644 index 00000000000..c3832369117 --- /dev/null +++ b/tests/component_tests/homeassistant/test_state_subscription_count.py @@ -0,0 +1,51 @@ +"""Tests for Home Assistant state subscription count calculation and pre-reservation.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +DIR = Path(__file__).parent + + +def test_mixed_entities_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with mixed entities computes the exact weighted subscription count.""" + generate_main(DIR / "mixed_entities.yaml") + + # sensor (2*1) + binary_sensor (1) + text_sensor (1) + switch (1) + # + select (2) + number (4) + text (4) + button (0) = 15 + define_match = [ + d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" + ] + assert len(define_match) == 1 + assert str(define_match[0].value) == "15" + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + + +def test_button_only_no_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with only button entities has no state subscriptions.""" + generate_main(DIR / "button_valid.yaml") + + assert not any( + d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" for d in CORE.defines + ) + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines) + + +def test_select_only_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with 2 select entities calculates 2 * 2 = 4 subscriptions.""" + generate_main(DIR / "select_valid.yaml") + + define_match = [ + d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" + ] + assert len(define_match) == 1 + assert str(define_match[0].value) == "4" + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) From 4f192130f80ccb70813891d99d0a771c5f063fa0 Mon Sep 17 00:00:00 2001 From: Douwe <61123717+dhoeben@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:51:06 +0200 Subject: [PATCH 517/651] [water_heater] Hand the component to the core team (#20999) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/water_heater/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f23f3613032..65ed44ee30b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -688,7 +688,7 @@ esphome/components/version/* @esphome/core esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher -esphome/components/water_heater/* @dhoeben +esphome/components/water_heater/* @esphome/core esphome/components/waveshare_epaper/* @clydebarrow esphome/components/waveshare_io_ch32v003/* @latonita esphome/components/web_server/ota/* @esphome/core diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index a3a7f047069..9277f0ecbf6 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -17,7 +17,7 @@ from esphome.core.entity_helpers import ( from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType -CODEOWNERS = ["@dhoeben"] +CODEOWNERS = ["@esphome/core"] DOMAIN = "water_heater" IS_PLATFORM_COMPONENT = True From 37d6140193c7b215852834b54d05a50f0fe60d8f Mon Sep 17 00:00:00 2001 From: Dane Powell Date: Sat, 17 Oct 2026 11:55:37 -0700 Subject: [PATCH 518/651] [epaper_spi] Fix partial refresh and port waveshare 2.90inv2 (#18959) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: luar123 <49960470+luar123@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/epaper_spi/epaper_spi.cpp | 4 +- esphome/components/epaper_spi/epaper_spi.h | 2 + .../components/epaper_spi/epaper_spi_mono.cpp | 2 +- .../components/epaper_spi/epaper_spi_mono.h | 2 + .../epaper_spi/epaper_waveshare.cpp | 15 ++- .../components/epaper_spi/epaper_waveshare.h | 26 +++- .../components/epaper_spi/models/waveshare.py | 125 +++++++++++++++--- .../epaper_spi/test.esp32-s3-idf.yaml | 17 +++ 8 files changed, 167 insertions(+), 26 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index a0c90a0b686..e95d2fd2869 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -90,7 +90,9 @@ bool EPaperBase::is_idle_() const { return !this->busy_pin_->digital_read(); } -bool EPaperBase::reset() { +bool EPaperBase::reset() { return this->hardware_reset_(); } + +bool EPaperBase::hardware_reset_() { if (this->reset_pin_ != nullptr) { if (this->state_ == EPaperState::RESET) { this->reset_pin_->digital_write(false); diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index dccff3cbef1..046df97cccd 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -129,6 +129,8 @@ class EPaperBase : public Display, bool is_idle_() const; void setup_pins_() const; virtual bool reset(); + /// The reset pin part of reset(), for a driver that must skip what its parent adds to it + bool hardware_reset_(); virtual bool initialise(bool partial); void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index b6653038c33..a00104edeae 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -68,7 +68,7 @@ bool HOT EPaperMono::transfer_data() { while (this->current_data_index_ != this->y_high_) { size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8; for (size_t i = 0; i != row_length; i++) { - bytes_to_send[i] = this->send_red_ ? 0 : this->buffer_[data_idx++]; + bytes_to_send[i] = this->send_red_ && !this->send_red_as_image_ ? 0 : this->buffer_[data_idx++]; } ++this->current_data_index_; this->write_array(&bytes_to_send.front(), row_length); // NOLINT diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index 4dc54ae60ba..c7de7a309af 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -21,6 +21,8 @@ class EPaperMono : public EPaperBase { virtual void set_window(); bool transfer_data() override; bool send_red_{true}; + // When set, the red RAM pass writes the image rather than blank data. + bool send_red_as_image_{false}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare.cpp b/esphome/components/epaper_spi/epaper_waveshare.cpp index d4c7ba5fdb5..17808001aa6 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare.cpp @@ -8,15 +8,19 @@ bool EpaperWaveshare::initialise(bool partial) { EPaperBase::initialise(partial); if (partial) { this->cmd_data(0x32, this->partial_lut_, this->partial_lut_length_); + this->send_init_sequence_(this->partial_sequence_, this->partial_sequence_length_); this->cmd_data(0x3C, {0x80}); this->cmd_data(0x22, {0xC0}); this->command(0x20); this->next_delay_ = 100; } else { this->cmd_data(0x32, this->lut_, this->lut_length_); + this->send_init_sequence_(this->full_sequence_, this->full_sequence_length_); this->cmd_data(0x3C, {0x05}); } - this->send_red_ = true; + // The previous image must stay in the base RAM for partial updates to compare against, + // so models needing it write the base RAM on full updates only. + this->send_red_ = !(this->base_image_required_ && partial); return true; } @@ -45,4 +49,13 @@ void EpaperWaveshare::refresh_screen(bool partial) { } void EpaperWaveshare::deep_sleep() { this->cmd_data(0x10, {0x01}); } + +bool EpaperWaveshare::reset() { + // Skip the software reset (0x12) EPaperMono adds: it restarts the alternation between the two RAM + // banks, so a partial update would compare against the wrong bank. The RST pulse only wakes the + // controller from sleep. + if (this->must_keep_base_image_()) + return this->hardware_reset_(); + return EPaperMono::reset(); +} } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare.h b/esphome/components/epaper_spi/epaper_waveshare.h index d3ad313d925..b12aeb93822 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.h +++ b/esphome/components/epaper_spi/epaper_waveshare.h @@ -10,21 +10,39 @@ class EpaperWaveshare final : public EPaperMono { public: EpaperWaveshare(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length, const uint8_t *lut, size_t lut_length, const uint8_t *partial_lut, - uint16_t partial_lut_length) + uint16_t partial_lut_length, const uint8_t *full_sequence = nullptr, size_t full_sequence_length = 0, + const uint8_t *partial_sequence = nullptr, size_t partial_sequence_length = 0, + bool base_image_required = false) : EPaperMono(name, width, height, init_sequence, init_sequence_length), lut_(lut), - lut_length_(lut_length), partial_lut_(partial_lut), - partial_lut_length_(partial_lut_length) {} + full_sequence_(full_sequence), + partial_sequence_(partial_sequence), + lut_length_(lut_length), + full_sequence_length_(full_sequence_length), + partial_sequence_length_(partial_sequence_length), + partial_lut_length_(partial_lut_length), + base_image_required_(base_image_required) { + this->send_red_as_image_ = base_image_required; + } protected: bool initialise(bool partial) override; void set_window() override; void refresh_screen(bool partial) override; void deep_sleep() override; + bool reset() override; + // True while the base image in the controller RAM must be preserved for the next update. + bool must_keep_base_image_() const { return this->base_image_required_ && this->update_count_ != 0; } const uint8_t *lut_; - size_t lut_length_; const uint8_t *partial_lut_; + // Commands sent after the LUT, e.g. voltage settings that differ between full and partial updates. + const uint8_t *full_sequence_; + const uint8_t *partial_sequence_; + size_t lut_length_; + size_t full_sequence_length_; + size_t partial_sequence_length_; uint16_t partial_lut_length_; + bool base_image_required_; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare.py b/esphome/components/epaper_spi/models/waveshare.py index aecda723643..7594c9d0d0d 100644 --- a/esphome/components/epaper_spi/models/waveshare.py +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -1,5 +1,10 @@ +from collections.abc import Sequence +from typing import Any + import esphome.codegen as cg +from esphome.components.mipi import flatten_sequence from esphome.core import ID +from esphome.types import ConfigType from ..display import CONF_INIT_SEQUENCE_ID from . import EpaperModel @@ -7,34 +12,49 @@ from . import EpaperModel class WaveshareModel(EpaperModel): def __init__( - self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults - ): + self, + name: str, + lut: tuple[int, ...], + lut_partial: tuple[int, ...] | None = None, + sequence_full: tuple[tuple[int, ...], ...] = (), + sequence_partial: tuple[tuple[int, ...], ...] = (), + base_image_required: bool = False, + class_name: str = "EpaperWaveshare", + **defaults: Any, + ) -> None: # A partial LUT is what lets EpaperWaveshare do partial refresh defaults.setdefault("partial_update", lut_partial is not None) super().__init__(name, class_name=class_name, **defaults) self.lut = lut self.lut_partial = lut_partial + self.sequence_full = sequence_full + self.sequence_partial = sequence_partial + self.base_image_required = base_image_required - def get_constructor_args(self, config) -> tuple: - lut = ( + def _array_arg( + self, config: ConfigType, suffix: str, data: Sequence[int] | None + ) -> tuple: + if not data: + return cg.nullptr, 0 + return ( cg.static_const_array( - ID(config[CONF_INIT_SEQUENCE_ID].id + "_lut", type=cg.uint8), self.lut + ID(config[CONF_INIT_SEQUENCE_ID].id + suffix, type=cg.uint8), data ), - len(self.lut), + len(data), + ) + + def get_constructor_args(self, config: ConfigType) -> tuple: + return ( + *self._array_arg(config, "_lut", self.lut), + *self._array_arg(config, "_lut_partial", self.lut_partial), + *self._array_arg( + config, "_sequence_full", flatten_sequence(self.sequence_full) + ), + *self._array_arg( + config, "_sequence_partial", flatten_sequence(self.sequence_partial) + ), + self.base_image_required, ) - if self.lut_partial is None: - lut_partial = cg.nullptr, 0 - else: - lut_partial = ( - cg.static_const_array( - ID( - config[CONF_INIT_SEQUENCE_ID].id + "_lut_partial", type=cg.uint8 - ), - self.lut_partial, - ), - len(self.lut_partial), - ) - return *lut, *lut_partial # fmt: off @@ -90,3 +110,70 @@ WaveshareModel( 0x0, 0x0, 0x0, ), ) + +WaveshareModel( + "waveshare-2.9in-v2", + width=128, + height=296, + initsequence=( + (0x01, 0x27, 0x01, 0x00), # driver output control + (0x0C, 0xD7, 0xD6, 0x9D), # booster soft start control + (0x3A, 0x1A), # dummy line period + (0x3B, 0x08), # gate line width + (0x11, 0x03), # data entry mode + (0x21, 0x00, 0x80), # display update control + (0x18, 0x80), # select internal temperature sensor + ), + # The last 6 bytes of the LUTs in the Waveshare sample code are voltage settings + # rather than waveform data, and are sent as separate commands below. + lut=( + 0x80, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, + 0x00, 0x00, 0x10, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x80, 0x66, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x10, 0x66, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x14, 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0A, 0x0A, 0x00, + 0x0A, 0x0A, 0x00, 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, 0x14, 0x08, 0x00, 0x01, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, + 0x00, 0x00, 0x00, + ), + lut_partial=( + 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 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, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x00, 0x00, 0x00, + ), + sequence_full=( + (0x3F, 0x22), # option for LUT end + (0x03, 0x17), # gate voltage + (0x04, 0x41, 0x00, 0x32), # source voltage: VSH1, VSH2, VSL + (0x2C, 0x36), # VCOM + ), + sequence_partial=( + (0x3F, 0x22), # option for LUT end + (0x03, 0x17), # gate voltage + (0x04, 0x41, 0xB0, 0x32), # source voltage: VSH1, VSH2, VSL + (0x2C, 0x36), # VCOM + (0x37, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00), + ), + base_image_required=True, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index f6091e5715c..6a487064cbc 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -70,6 +70,23 @@ display: allow_other_uses: true number: GPIO4 + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.9in-v2 + full_update_every: 10 + 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 + - platform: epaper_spi spi_id: spi_bus model: waveshare-7.5in-H From 4324b7d084c69d3f6c4657ee3779217e8ee3ce67 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:58:49 +0200 Subject: [PATCH 519/651] [zigbee] bump esp-zigbee-sdk to 2.0.5 (#20998) --- esphome/components/zigbee/zigbee_esp32.cpp | 26 ----------------- esphome/components/zigbee/zigbee_esp32.h | 33 +--------------------- esphome/components/zigbee/zigbee_esp32.py | 12 ++------ esphome/idf_component.yml | 2 +- tests/component_tests/esp32/test_esp32.py | 16 ++++++----- 5 files changed, 14 insertions(+), 75 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 29bbdc2b0b5..35f12b3a4e3 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -388,15 +388,6 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); - // Finish zigbee data model - for (auto &attr_value : this->attr_values_) { - ezb_zcl_attr_desc_t attr_desc = attr_value.attr_desc; - void *value_p = &attr_value.value; - ezb_zcl_attr_desc_set_value(attr_desc, value_p); - } - // free memory - std::vector().swap(this->attr_values_); - // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network @@ -470,23 +461,6 @@ void ZigbeeComponent::dump_config() { esp_zigbee_lock_release(); } } - -bool ZigbeeComponent::string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id) { - ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); - if (ep_desc == NULL) { - return false; - } - ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); - if (cluster_desc == NULL) { - return false; - } - if (ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE) == NULL) { - return false; - } - ESP_LOGW(TAG, "Attribute 0x%04X already exists in endpoint %u cluster 0x%04X. Can't add new value", attr_id, - endpoint_id, cluster_id); - return true; -} } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index d5ed7439bd2..93a4f1ba780 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -29,11 +29,6 @@ static constexpr uint8_t MAX_ZB_QUEUE_SIZE = 32; uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); -struct AttrValue { - ezb_zcl_attr_desc_t attr_desc; - uint8_t value[4]; -}; - class ZigbeeAttribute; class ZigbeeComponent final : public Component { @@ -101,7 +96,6 @@ class ZigbeeComponent final : public Component { // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - std::vector attr_values_; ezb_af_device_desc_t dev_desc_ = ezb_af_create_device_desc(); CallbackManager join_cb_{}; template friend void enqueue_zb_event(Args... args); @@ -119,8 +113,6 @@ class ZigbeeComponent final : public Component { std::atomic joined_ = false; std::atomic join_pending_ = false; std::atomic factory_new_ = false; - // TODO: remove when esp-zigbee-lib fixes set_value before init - bool string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id); }; template @@ -135,22 +127,10 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 // The size byte of the zcl_str must be set to the maximum value, // even though the initial string may be shorter. if constexpr (std::is_same::value) { - if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { - if (attr != nullptr) { - this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; - } - return; - } auto zcl_str = get_zcl_string(value.c_str(), max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; } else if constexpr (std::is_convertible::value) { - if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { - if (attr != nullptr) { - this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; - } - return; - } auto zcl_str = get_zcl_string(value, max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; @@ -170,18 +150,7 @@ void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint if (cluster_desc == NULL) { return; } - // TODO: revert when esp-zigbee-lib fixes set_value before init - ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); - - if (attr_desc != NULL) { - static_assert(sizeof(*value_p) <= 4); - AttrValue attr_value; - attr_value.attr_desc = attr_desc; - memcpy(&attr_value.value, value_p, sizeof(*value_p)); - attr_values_.push_back(attr_value); - } else { - esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); - } + esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 99f075ecb2f..30c94cde6cb 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -13,7 +13,6 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, - require_mbedtls_ecp, require_mbedtls_tls_extras, require_vfs_select, ) @@ -395,17 +394,12 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.4", + ref="2.0.5", ) - # The esp-zigbee-lib blobs reference mbedtls_ecp_* (Zigbee Direct, install - # code ECDH); keep ECP without relying on esp_wifi's Kconfig select. - require_mbedtls_ecp() - # Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly. + # Zigbee's crypto platform uses AES-CCM directly. # Keep the esp32 component from trimming them out of mbedTLS. - require_mbedtls_tls_extras( - ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") - ) + require_mbedtls_tls_extras({"CONFIG_MBEDTLS_CCM_C"}) if CONF_WIFI in CORE.config: # zigbee_esp32.cpp uses esp_coexist.h when WiFi is present diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5ed84fa2a3a..dd837d61632 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -54,7 +54,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.4 + version: 2.0.5 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 3cde877f7ad..86b27776dc6 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1016,8 +1016,8 @@ def _tls_compiled_out(sdkconfig: dict[str, Any]) -> bool: "exclusion_reincludes_nextion.yaml", False, False, False, id="nextion" ), pytest.param("mbedtls_tls_wifi_eap.yaml", False, False, True, id="wifi_eap"), - # zigbee requests ECP for the esp-zigbee-lib blobs, without TLS. - pytest.param("tls_zigbee_c6.yaml", True, False, True, id="zigbee"), + # zigbee needs neither TLS nor ECP + pytest.param("tls_zigbee_c6.yaml", True, True, True, id="zigbee"), # A raw bundle keeps the TLS role but no longer compiles esp-tls. pytest.param( "certificate_bundle_sdkconfig.yaml", False, False, True, id="raw_bundle" @@ -2188,19 +2188,21 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) +_CCM_EXTRAS = {"CONFIG_MBEDTLS_CCM_C"} + + def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: """Nothing in the Zigbee config links TLS, so the stack is compiled out and - no role is written; the extras trim still runs and Zigbee keeps CCM and - deterministic ECDSA.""" + no role is written; the extras trim still runs and Zigbee keeps CCM.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] 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) + assert sdkconfig.get(name) is (None if name in _CCM_EXTRAS else False) def test_mbedtls_tls_opt_out_keeps_stack_and_trims_role( @@ -2252,10 +2254,10 @@ def test_mbedtls_tls_zigbee_requires_extras( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The Zigbee hooks mark the CCM/deterministic ECDSA as required.""" + """The Zigbee hooks mark the CCM as required.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] - assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS + assert mbedtls.tls_extras_required == _CCM_EXTRAS _VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} From e110613ef1120a20b0a6d730f7c4c6bbf96de709 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:00:37 +0200 Subject: [PATCH 520/651] [core] Construct App once in the C++ unit test main (#21001) Co-authored-by: J. Nick Koston --- tests/components/daikin/daikin_regression_tests.cpp | 3 --- tests/components/daikin/daikin_tests.cpp | 4 ---- tests/components/display/test_test_card.cpp | 5 ----- tests/components/emontx/common.h | 6 ------ tests/components/emontx/emontx_test.cpp | 1 - tests/components/emontx/text_sensor/emontx_version_test.cpp | 1 - tests/components/filter_lifetime/test_filter_lifetime.cpp | 5 ++++- .../haier_water_heater/water_heater/test_hon_controller.cpp | 4 ---- tests/components/i2c/test_i2c_chip_id.cpp | 4 +++- tests/components/main.cpp | 5 +++++ tests/components/mistral_ir/mistral_ir_tests.cpp | 3 --- tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp | 3 --- tests/components/snapshot/snapshot_test.cpp | 3 --- tests/components/tmp102/test_tmp102.cpp | 5 ++++- tests/components/xiaomi_body_scale/common.h | 3 --- 15 files changed, 16 insertions(+), 39 deletions(-) diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp index fc642a84e9d..6bb41d30f49 100644 --- a/tests/components/daikin/daikin_regression_tests.cpp +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include "esphome/components/daikin/daikin.h" @@ -124,8 +123,6 @@ struct Rig { // A second fixture in the same test would otherwise restore what the first saved 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); diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp index 0664a82571c..3cea96cce4b 100644 --- a/tests/components/daikin/daikin_tests.cpp +++ b/tests/components/daikin/daikin_tests.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include @@ -146,9 +145,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved 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); diff --git a/tests/components/display/test_test_card.cpp b/tests/components/display/test_test_card.cpp index 555611bc093..a97301ac1e9 100644 --- a/tests/components/display/test_test_card.cpp +++ b/tests/components/display/test_test_card.cpp @@ -31,11 +31,6 @@ class TestCardDisplay : public Display { namespace { class TestCard : public ::testing::Test { protected: - void SetUp() override { - // test_card() stops the poller, which needs the scheduler; the test main does not construct App. - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; - } int writer_calls_{0}; display_writer_t count_writer_() { return [this](Display &) { this->writer_calls_++; }; diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 678d582f62f..932fac91cd2 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -44,12 +44,6 @@ class MockUARTComponent : public uart::UARTComponent { #endif // defined(USE_ESP8266) || defined(USE_ESP32) }; -// The test main does not construct App as generated code does -inline void construct_app() { - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; -} - inline void cancel_timers(EmonTx *emontx) { for (uint32_t id = 0; id < 3; id++) App.scheduler.cancel_timeout(emontx, id); diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 71bb6c70d36..227fa91a599 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -14,7 +14,6 @@ namespace { class EmonTxTest : public ::testing::Test { protected: void SetUp() override { - construct_app(); 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_++; }); diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index 34f6315f3ab..fa51db63e5f 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -12,7 +12,6 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp index a4c89edd856..4f33afa24ef 100644 --- a/tests/components/filter_lifetime/test_filter_lifetime.cpp +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -39,7 +39,9 @@ class FilterLifetimeTest : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21); host::setup_preferences(); host::host_preferences->reset(); @@ -55,6 +57,7 @@ class FilterLifetimeTest : public ::testing::Test { void TearDown() override { host::host_preferences->reset(); App.~Application(); + new (&App) Application(); // later tests and the test listener use App } void attach(FilterLifetime &filter) { diff --git a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp index 41b40687d48..0de87d7a7da 100644 --- a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp +++ b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include "esphome/components/haier_water_heater/hon_water_heater.h" @@ -53,9 +52,6 @@ class TestableHeater : public HonWaterHeater { class HonControllerTest : public ::testing::Test { protected: void SetUp() override { - // The test runner skips generated setup(), which normally constructs App and its scheduler. - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; this->heater_.set_uart_parent(&this->uart_); this->heater_.set_network_pending(false); this->set_clock_(100); diff --git a/tests/components/i2c/test_i2c_chip_id.cpp b/tests/components/i2c/test_i2c_chip_id.cpp index c3e3fdd4907..a3b567d917d 100644 --- a/tests/components/i2c/test_i2c_chip_id.cpp +++ b/tests/components/i2c/test_i2c_chip_id.cpp @@ -33,7 +33,9 @@ class ChipDevice : public Component, public I2CDevice {}; class I2CChipIdTest : public ::testing::Test { protected: void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); static char name[] = "i2c-chip-id-tests"; static char friendly_name[] = "I2C chip ID tests"; App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1); diff --git a/tests/components/main.cpp b/tests/components/main.cpp index 4020dd9a505..cbf19f31ba4 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -32,6 +32,11 @@ class ResetAppListener : public ::testing::EmptyTestEventListener { }; void setup() { + // Construct App once, as generated code does, before the logger, the listener or any test touches it. + // A fixture that destroys App must construct it again before returning. + // Placement new needs , which the generated include block already provides + new (&esphome::App) esphome::Application; + // Log functions call global_logger->log_vprintf_() without a null check, // so we must set up a Logger before any test that triggers logging. static esphome::logger::Logger test_logger(0); diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp index 9b489154d02..b7260f95d88 100644 --- a/tests/components/mistral_ir/mistral_ir_tests.cpp +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -84,9 +84,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved 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); diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp index 293c363d5d6..76ffd5a70d4 100644 --- a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -72,9 +72,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved 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); diff --git a/tests/components/snapshot/snapshot_test.cpp b/tests/components/snapshot/snapshot_test.cpp index d63101357a2..92200814181 100644 --- a/tests/components/snapshot/snapshot_test.cpp +++ b/tests/components/snapshot/snapshot_test.cpp @@ -46,9 +46,6 @@ class SnapshotAnimationTest : public ::testing::Test { fs::remove_all(this->dir_); fs::create_directories(this->dir_); setenv("ESPHOME_SNAPSHOT_DIR", this->dir_.c_str(), 1); - // The test main does not construct App as generated code does, and recording needs its scheduler. - static const bool app_constructed = (new (&App) Application(), true); - (void) app_constructed; App.pre_setup("test_snapshot", 10, "", 0); } void TearDown() override { diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index a5b65b060d4..081dfba8d2c 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -71,7 +71,9 @@ class TMP102Test : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); App.pre_setup("tmp102-regression-tests", 23, "TMP102 tests", 12); host::setup_preferences(); host::host_preferences->reset(); @@ -93,6 +95,7 @@ class TMP102Test : public ::testing::Test { App.scheduler.call(millis()); host::host_preferences->reset(); App.~Application(); + new (&App) Application(); // later tests and the test listener use App } static void run_timers(uint32_t duration) { diff --git a/tests/components/xiaomi_body_scale/common.h b/tests/components/xiaomi_body_scale/common.h index 67f1eaae91a..e21b014efff 100644 --- a/tests/components/xiaomi_body_scale/common.h +++ b/tests/components/xiaomi_body_scale/common.h @@ -52,9 +52,6 @@ inline ble_device_base::ESPBTDevice advert(uint64_t address, const Frame &frame) struct Harness { Harness(uint64_t address, const char *key) : scale(address, key) { - // The test main does not construct App as generated code does; the stabilized reset needs its scheduler - static const bool app_constructed = (new (&App) Application(), true); - (void) app_constructed; App.pre_setup("test_scale", 10, "", 0); this->scale.set_weight(&this->weight); this->scale.set_impedance_low(&this->impedance_low); From 5c2b05d0eb5fe08cb1cbb4b7d133e30308c49835 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 09:00:51 -1000 Subject: [PATCH 521/651] [ci] Run the C++ unit tests on macOS without a sanitizer whose runtime aborts (#21000) --- script/cpp_unit_test.py | 57 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index c5179a1b1e9..c2b34e1b409 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -3,7 +3,9 @@ import argparse from functools import partial import os from pathlib import Path +import subprocess import sys +import tempfile from build_helpers import ( PLATFORMIO_GOOGLE_TEST_LIB, @@ -12,6 +14,9 @@ from build_helpers import ( ) from helpers import get_all_components, root_path +from esphome.core import EsphomeError +from esphome.host.toolchain import find_compilers + # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" @@ -20,11 +25,55 @@ BUILD_FLAGS = [ "-g3", # max debug info "-ggdb3", "-DESPHOME_DEBUG", # enable debug assertions - # Enable the address and undefined behavior sanitizers - "-fsanitize=address", - "-fsanitize=undefined", "-fno-omit-frame-pointer", ] +SANITIZER_FLAGS = ["-fsanitize=address", "-fsanitize=undefined"] + + +def sanitizer_flags() -> list[str]: + """The sanitizer flags to build with: all of them, except on macOS the ones whose runtime + does not start. Apple clang 15's AddressSanitizer aborts before main() on macOS 26, which + would fail every test for a toolchain problem, so each flag is tried on an empty program + first. A compiler that cannot build the empty program is an error, not a dropped flag. + CI keeps every flag. + """ + if sys.platform != "darwin": + return SANITIZER_FLAGS + try: + cxx = find_compilers().cxx + except EsphomeError as err: + sys.exit(str(err)) + flags: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + program = Path(tmp) / "probe" + for flag in SANITIZER_FLAGS: + compiled = subprocess.run( + [*cxx, flag, "-x", "c++", "-", "-o", program], + check=False, + input=b"int main() { return 0; }", + capture_output=True, + ) + if compiled.returncode != 0: + sys.exit( + f"{cxx[0]} cannot build an empty program with {flag}:\n" + f"{compiled.stderr.decode(errors='replace').strip()}" + ) + ran = subprocess.run([program], check=False, capture_output=True) + if ran.returncode == 0: + flags.append(flag) + continue + lines = [ + line + for line in ran.stderr.decode(errors="replace").splitlines() + if line.strip() + ] + print( + f"{flag} aborts at start with {cxx[0]}: " + f"{lines[0] if lines else f'exit code {ran.returncode}'}\n" + " building the tests without it; set CXX to a compiler whose runtime works to keep it", + file=sys.stderr, + ) + return flags def run_tests(selected_components: list[str]) -> int: @@ -39,7 +88,7 @@ def run_tests(selected_components: list[str]) -> int: config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, - build_flags=BUILD_FLAGS, + build_flags=BUILD_FLAGS + sanitizer_flags(), main_entry="main.cpp", label="unit tests", ) From c2c8cb5eb37eb244a82d65cfb319a92fd44756fa Mon Sep 17 00:00:00 2001 From: Matias Fernandez <7898112+MatiasFernandez@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:12:35 -0300 Subject: [PATCH 522/651] [climate_ir_lg] Fix swing command to not include climate state (#17417) Co-authored-by: J. Nick Koston --- .../climate_ir_lg/climate_ir_lg.cpp | 8 +- .../climate/lg_ir_climate_tests.cpp | 90 +++++++++++++++++++ tests/components/climate_ir_lg/common.h | 33 +++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp create mode 100644 tests/components/climate_ir_lg/common.h diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.cpp b/esphome/components/climate_ir_lg/climate_ir_lg.cpp index 042d1dcb21f..16ed9831fd7 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.cpp +++ b/esphome/components/climate_ir_lg/climate_ir_lg.cpp @@ -135,13 +135,14 @@ void LgIrClimate::transmit_state() { default: return; } - this->transmit_(remote_state); - this->publish_state(); - return; } else { // just toggle swing when advanced_commands_support is not set remote_state |= HEADER_BASIC; remote_state |= BASIC_SWING_TOGGLE; } + // The remote's swing button sends a fixed code with no fan speed or temperature bits, so the + // frame goes out as is instead of falling through to them + this->transmit_(remote_state); + return; } else { // Mode commands const bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF); switch (this->mode) { @@ -208,7 +209,6 @@ void LgIrClimate::transmit_state() { } this->transmit_(remote_state); - this->publish_state(); } bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) { diff --git a/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp b/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp new file mode 100644 index 00000000000..c8c8e79db65 --- /dev/null +++ b/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp @@ -0,0 +1,90 @@ +#include +#include "../common.h" + +namespace esphome::climate_ir_lg::testing { + +namespace { + +constexpr uint32_t BIT_ONE_LOW = 1600; +// A complete LG frame is header (2) + 28 bit pairs (56) + trailing mark (1) +constexpr size_t FRAME_SIZE = 2 + 28 * 2 + 1; + +// A climate with timings set, so transmit_() produces a frame decode_lg_frame() can read back +void configure(LgIrClimate &sut, MockRemoteTransmitter &transmitter) { + sut.set_transmitter(&transmitter); + sut.set_header_high(8000); + sut.set_header_low(4000); + sut.set_bit_high(600); + sut.set_bit_one_low(BIT_ONE_LOW); + sut.set_bit_zero_low(550); +} + +// COOL at 20 °C with the fan on HIGH: a distinctive state the swing tests must not leak into their frame +void turn_on(LgIrClimate &sut) { + climate::ClimateCall on_call(&sut); + on_call.set_mode(climate::CLIMATE_MODE_COOL); + on_call.set_target_temperature(20); + on_call.set_fan_mode(climate::CLIMATE_FAN_HIGH); + sut.control(on_call); +} + +uint32_t last_frame(const MockRemoteTransmitter &transmitter) { + // Guard against out-of-bounds reads in decode_lg_frame() if the transmission failed + if (transmitter.last_data.size() < FRAME_SIZE) { + ADD_FAILURE() << "no complete frame was transmitted"; + return 0; + } + return decode_lg_frame(transmitter.last_data, BIT_ONE_LOW); +} + +} // namespace + +// A mode command carries the mode, the fan speed and the temperature, with the checksum nibble +TEST(LgIrClimateTests, ModeCommandCarriesModeFanSpeedAndTemperature) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + + turn_on(sut); + + // From OFF: COMMAND_ON_COOL, FAN_MAX, 20 °C encoded as (20 - 15) << 8, checksum 9 + EXPECT_EQ(last_frame(transmitter), 0x8800549u); +} + +// The swing command is a distinct, self-contained IR code: the real remote transmits it +// on its own, independent of the unit's current mode, target temperature, or fan speed. +TEST(LgIrClimateTests, SwingCommandIsIndependentOfClimateState) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + turn_on(sut); + + // Toggle swing -- this must be the fixed swing code below, unaffected by the state set above. + climate::ClimateCall swing_call(&sut); + swing_call.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + sut.control(swing_call); + + // The swing command is always this exact fixed code, regardless of climate state. + EXPECT_EQ(last_frame(transmitter), 0x8810001u); +} + +// With advanced commands the swing frames are fixed codes too, one to start and one to stop the swing +TEST(LgIrClimateTests, AdvancedSwingCommandsAreIndependentOfClimateState) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + sut.set_advanced_commands_support(true); + turn_on(sut); + + climate::ClimateCall swing_on(&sut); + swing_on.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + sut.control(swing_on); + EXPECT_EQ(last_frame(transmitter), 0x8813149u); // HEADER_ADV_SWING | VERT_SWING_ON + + climate::ClimateCall swing_off(&sut); + swing_off.set_swing_mode(climate::CLIMATE_SWING_OFF); + sut.control(swing_off); + EXPECT_EQ(last_frame(transmitter), 0x881315Au); // HEADER_ADV_SWING | VERT_SWING_OFF +} + +} // namespace esphome::climate_ir_lg::testing diff --git a/tests/components/climate_ir_lg/common.h b/tests/components/climate_ir_lg/common.h new file mode 100644 index 00000000000..7e9a79cc615 --- /dev/null +++ b/tests/components/climate_ir_lg/common.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include "esphome/components/climate_ir_lg/climate_ir_lg.h" +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::climate_ir_lg::testing { + +// Records the raw mark/space timings instead of driving real IR hardware. +class MockRemoteTransmitter : public remote_base::RemoteTransmitterBase { + public: + MockRemoteTransmitter() : RemoteTransmitterBase(nullptr) {} + + std::vector last_data; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { this->last_data = this->temp_.get_data(); } +}; + +// Decodes the raw mark/space timings produced by LgIrClimate::transmit_() back into the +// 28-bit value that was transmitted (header + bits, ignoring the trailing mark). +inline uint32_t decode_lg_frame(const std::vector &data, uint32_t bit_one_low) { + uint32_t value = 0; + // data[0]/data[1] are the header mark/space; each bit thereafter is a mark/space pair. + for (size_t i = 0; i < 28; i++) { + int32_t space = data[2 + 2 * i + 1]; + value = (value << 1) | (static_cast(-space) == bit_one_low ? 1 : 0); + } + return value; +} + +} // namespace esphome::climate_ir_lg::testing From 5f8f075e767a7b07e16323dc4645304b38e4b71a Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 17 Oct 2026 21:22:28 +0200 Subject: [PATCH 523/651] [zephyr_coredump] Store a coredump in flash on nRF52 and print it to the next log reader (#13122) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/logger/logger_zephyr.cpp | 38 +++- esphome/components/nrf52/clang_tidy.py | 1 + .../components/zephyr_coredump/__init__.py | 111 ++++++++++ .../components/zephyr_coredump/coredump.cpp | 191 ++++++++++++++++++ esphome/components/zephyr_coredump/coredump.h | 43 ++++ .../components/zephyr_mcumgr/ota/__init__.py | 26 ++- esphome/core/defines.h | 1 + .../zephyr_coredump/__init__.py | 0 .../config/mcumgr_adafruit.yaml | 15 ++ .../zephyr_coredump/test_zephyr_coredump.py | 104 ++++++++++ tests/components/zephyr_coredump/common.yaml | 1 + .../zephyr_coredump/test.nrf52-adafruit.yaml | 2 + .../zephyr_coredump/test.nrf52-mcumgr.yaml | 2 + .../test.nrf52-nrf52840dongle.yaml | 2 + .../zephyr_coredump/test.nrf52-xiao-ble.yaml | 2 + 16 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 esphome/components/zephyr_coredump/__init__.py create mode 100644 esphome/components/zephyr_coredump/coredump.cpp create mode 100644 esphome/components/zephyr_coredump/coredump.h create mode 100644 tests/component_tests/zephyr_coredump/__init__.py create mode 100644 tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml create mode 100644 tests/component_tests/zephyr_coredump/test_zephyr_coredump.py create mode 100644 tests/components/zephyr_coredump/common.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-adafruit.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 65ed44ee30b..539730a4a67 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -727,6 +727,7 @@ esphome/components/xl9535/* @mreditor97 esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 +esphome/components/zephyr_coredump/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 esphome/components/zephyr_pwm/* @wiomoc esphome/components/zhlt01/* @cfeenstra1024 diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 95cc5d4a14b..c74943940c8 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -3,6 +3,9 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "logger.h" +#ifdef USE_CRASH_HANDLER +#include "esphome/components/zephyr_coredump/coredump.h" +#endif #include #include @@ -12,12 +15,6 @@ #include #endif -namespace esphome::zephyr_coredump { - -__attribute__((weak)) void print_coredump() {} - -} // namespace esphome::zephyr_coredump - namespace esphome::logger { // Zephyr 3.7 renamed z_arch_esf_t to struct arch_esf; the old name was later removed. @@ -56,6 +53,14 @@ void Logger::cdc_loop_() { if (!opened) { App.schedule_dump_config(); } +#ifdef USE_CRASH_HANDLER + // The new reader also gets a coredump stored by the last crash; a closed port keeps it for the next one + if (!opened) { + log_crash_to_client(); + } else { + zephyr_coredump::stop_coredump(); + } +#endif opened = !opened; } #endif @@ -103,7 +108,16 @@ void Logger::pre_setup() { const char *reset_reason = zephyr::get_reset_reason(std::span(reason_buffer)); ESP_LOGI(TAG, "Reset reason: %s", reset_reason); dump_crash_(); - zephyr_coredump::print_coredump(); +#endif +#ifdef USE_CRASH_HANDLER +#ifdef USE_LOGGER_UART_SELECTION_USB_CDC + // Nobody reads a USB port this early; cdc_loop_() reports the dump once the port is opened + if (this->uart_ == UART_SELECTION_USB_CDC) + return; +#endif + // Without a UART (baud_rate: 0, or one that did not come up) nothing reaches the console + if (this->uart_dev_ != nullptr) + log_crash_to_client(); #endif } @@ -195,6 +209,16 @@ void k_sys_fatal_error_handler(unsigned int reason, const FatalErrorEsf *esf) { arch_restart(); } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return zephyr_coredump::coredump_pending(); } +void log_crash_to_client() { + // A level that drops the lines would erase the dump unseen; keep it for a client that can see it + if (global_logger->get_log_level(zephyr_coredump::CRASH_LOG_TAG) < ESPHOME_LOG_LEVEL_ERROR) + return; + zephyr_coredump::print_coredump(); +} +#endif + } // namespace esphome::logger extern "C" { diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index d1f95979767..f4afafa0e49 100644 --- a/esphome/components/nrf52/clang_tidy.py +++ b/esphome/components/nrf52/clang_tidy.py @@ -93,6 +93,7 @@ CONFIG_NVS=y CONFIG_SETTINGS=y """, ), + _TidySubsystem("coredump", "CONFIG_DEBUG_COREDUMP=y\n"), ) _TIDY_PRJ_CONF = "".join( diff --git a/esphome/components/zephyr_coredump/__init__.py b/esphome/components/zephyr_coredump/__init__.py new file mode 100644 index 00000000000..cc36ac7dc4c --- /dev/null +++ b/esphome/components/zephyr_coredump/__init__.py @@ -0,0 +1,111 @@ +import esphome.codegen as cg +from esphome.components.nrf52 import NRF52840_FLASH_SIZE, TESTING_FLASH_SIZE +from esphome.components.nrf52.boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG +from esphome.components.zephyr import ( + Section, + zephyr_add_overlay, + zephyr_add_pm_static, + zephyr_add_prj_conf, + zephyr_data, +) +from esphome.components.zephyr.const import ( + BOOTLOADER_MCUBOOT, + KEY_BOARD, + KEY_BOOTLOADER, + KEY_PM_STATIC, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType + +CODEOWNERS = ["@tomaszduda23"] +DEPENDENCIES = ["logger"] +DOMAIN = "zephyr_coredump" + +Coredump = cg.esphome_ns.namespace(DOMAIN).class_("Coredump", cg.Component) + +# SDK 2.9.2 and later can store every thread's stack; older ones only the faulting thread +DUMP_THREADS_MIN_VERSION = cv.Version(2, 9, 2) +# Zephyr 4.0 (from SDK 3.0) carries the fault stack pointer fix (zephyr PR 79622) +SP_FIX_MIN_VERSION = cv.Version(3, 0, 0) +# One size for every SDK, so enabling the component moves the OTA image slots the same way everywhere +PARTITION_SIZE = 0x8000 + +CONFIG_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(): cv.declare_id(Coredump)}).extend(cv.COMPONENT_SCHEMA), + cv.only_on_nrf52, +) + + +def _framework_version() -> cv.Version: + return CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + + +def _partition_address() -> int: + """Place the partition directly below the lowest section reserved above the + SoftDevice (the bootloader), or at the end of flash with MCUboot.""" + data = zephyr_data() + # The SoftDevice (or MBR) is the only section that can start at 0 + reserved = [s.address for s in data[KEY_PM_STATIC] if s.address > 0] + if reserved: + return min(reserved) - PARTITION_SIZE + if (bootloader := data[KEY_BOOTLOADER]) != BOOTLOADER_MCUBOOT: + raise cv.Invalid( + f"{DOMAIN} does not know the flash layout of bootloader '{bootloader}', " + f"select one of: {', '.join(BOOTLOADER_CONFIG)}" + ) + # MCUboot reserves nothing, so the flash size has to come from the board + board = data[KEY_BOARD] + if "nrf52840" not in board and board not in BOARDS_ZEPHYR: + raise cv.Invalid( + f"{DOMAIN} does not know the flash size of board '{board}', " + "only nRF52840 boards are supported" + ) + flash_size = TESTING_FLASH_SIZE if CORE.testing_mode else NRF52840_FLASH_SIZE + return flash_size - PARTITION_SIZE + + +def _final_validate(config: ConfigType) -> ConfigType: + _partition_address() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +# Runs before zephyr_mcumgr OTA (OTA_UPDATES), which sizes its image slots below this partition +@coroutine_with_priority(CoroPriority.DIAGNOSTICS) +async def to_code(config: ConfigType) -> None: + # The logger's crash handler hooks: the dump reaches serial, API and MQTT log clients + cg.add_define("USE_CRASH_HANDLER") + zephyr_add_prj_conf("DEBUG_COREDUMP", True) + zephyr_add_prj_conf("DEBUG_COREDUMP_BACKEND_FLASH_PARTITION", True) + framework_ver = _framework_version() + if framework_ver >= DUMP_THREADS_MIN_VERSION: + zephyr_add_prj_conf("DEBUG_COREDUMP_MEMORY_DUMP_THREADS", True) + else: + zephyr_add_prj_conf("DEBUG_COREDUMP_MEMORY_DUMP_MIN", True) + if framework_ver < SP_FIX_MIN_VERSION: + cg.add_define("USE_ZEPHYR_COREDUMP_SP_FIX") + cg.add_build_flag("-Wl,--wrap=z_arm_fatal_error") + cg.add_build_flag("-Wl,--wrap=z_arm_fault") + zephyr_add_prj_conf("PARTITION_MANAGER_ENABLED", True) + addr = _partition_address() + zephyr_add_pm_static( + [Section("coredump_partition", addr, PARTITION_SIZE, "flash_primary")] + ) + zephyr_add_overlay( + f""" + &flash0 {{ + partitions {{ + coredump_partition: partition@{addr:x} {{ + label = "coredump-partition"; + reg = <{addr:#x} {PARTITION_SIZE:#x}>; + }}; + }}; + }}; + """ + ) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/esphome/components/zephyr_coredump/coredump.cpp b/esphome/components/zephyr_coredump/coredump.cpp new file mode 100644 index 00000000000..7bfbcc927a0 --- /dev/null +++ b/esphome/components/zephyr_coredump/coredump.cpp @@ -0,0 +1,191 @@ +#ifdef USE_ZEPHYR +#include "coredump.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include +#include +// The header has no C++ linkage guards of its own +extern "C" { +#include +} + +namespace esphome::zephyr_coredump { + +ESPHOME_LOG_TAG(TAG, "zephyr_coredump"); + +// Lines in the "#CD:" format that Zephyr's coredump_serial_log_parser.py reads: the log header (time, level, +// tag, line number and colour codes, under 64 characters), "#CD:" and two hex characters per byte must fit the +// logger's line buffer, so a small buffer gets shorter lines +static constexpr size_t LINE_OVERHEAD = 64; +static constexpr size_t CHUNK_SIZE = + std::min(64, (ESPHOME_LOGGER_TX_BUFFER_SIZE - LINE_OVERHEAD) / 2 / 16 * 16); +static_assert(CHUNK_SIZE >= 16, "logger line buffer too small for a coredump line"); +// A USB host starts reading the port only some 100 ms after opening it; until then the CDC driver discards output +static constexpr uint32_t START_DELAY_MS = 1000; +static constexpr uint32_t START_DELAY_ID = 0; +// Between lines, so a loop that runs faster than usual (a stepper moving) cannot overrun the USB CDC ring buffer +static constexpr uint32_t LINE_INTERVAL_MS = 10; + +// On a UART the logger reports the reader before this component exists, so the flag outlives the pointer +static Coredump *global_coredump = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool reader_present = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool coredump_pending() { return global_coredump != nullptr && global_coredump->stored_; } + +void print_coredump() { + reader_present = true; + if (global_coredump != nullptr) + global_coredump->start_print_(); +} + +void stop_coredump() { + reader_present = false; + if (global_coredump != nullptr) + global_coredump->stop_print_(); +} + +void Coredump::setup() { + global_coredump = this; + this->disable_loop(); + // The size comes from the header alone; "has stored dump" also checks the checksum and would hide a + // corrupt dump instead of letting it be reported and erased + const int size = coredump_query(COREDUMP_QUERY_GET_STORED_DUMP_SIZE, nullptr); + if (size <= 0) { + return; + } + this->stored_ = true; + this->total_ = size; + // A bad dump is reported and erased from loop() like a good one, once somebody can read the message + this->verify_ = coredump_cmd(COREDUMP_CMD_VERIFY_STORED_DUMP, nullptr); + if (reader_present) + this->start_print_(); +} + +// A new reader gets the whole dump: a print cut short by the port closing starts over +void Coredump::start_print_() { + if (!this->stored_ || this->printing_) + return; + this->printing_ = true; + this->offset_ = 0; + this->set_timeout(START_DELAY_ID, START_DELAY_MS, [this]() { this->enable_loop(); }); +} + +void Coredump::stop_print_() { + this->printing_ = false; + this->cancel_timeout(START_DELAY_ID); + this->disable_loop(); +} + +void Coredump::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_line_ms_ < LINE_INTERVAL_MS) + return; + this->last_line_ms_ = now; + if (this->verify_ != 1) { + ESP_LOGE(TAG, "Stored coredump verification failed: %d", this->verify_); + this->finish_(0); + return; + } + if (this->offset_ == 0) { + ESP_LOGE(TAG, "#CD:BEGIN#"); + } + uint8_t buf[CHUNK_SIZE]; + char hex[format_hex_size(CHUNK_SIZE)]; + coredump_cmd_copy_arg arg{static_cast(this->offset_), buf, + std::min(CHUNK_SIZE, this->total_ - this->offset_)}; + const int ret = coredump_cmd(COREDUMP_CMD_COPY_STORED_DUMP, &arg); + if (ret > 0) { + ESP_LOGE(TAG, "#CD:%s", format_hex_to(hex, buf, ret)); + this->offset_ += ret; + } + if (ret <= 0 || this->offset_ >= this->total_) { + ESP_LOGE(TAG, "#CD:END#"); + this->finish_(ret < 0 ? ret : 0); + } +} + +// Erase so the dump is not reported again; the next crash writes a new one. Erasing the partition halts the +// CPU for about 85 ms per 4 KB page, so this one call takes most of a second +void Coredump::finish_(int err) { + if (err < 0) { + ESP_LOGE(TAG, "Failed to copy coredump: %d", err); + } + const int erase = coredump_cmd(COREDUMP_CMD_ERASE_STORED_DUMP, nullptr); + if (erase < 0) { + ESP_LOGE(TAG, "Failed to erase coredump: %d", erase); + } + this->stored_ = false; + this->total_ = 0; + this->verify_ = 0; + this->printing_ = false; + this->disable_loop(); +} + +void Coredump::dump_config() { + ESP_LOGCONFIG(TAG, + "Coredump:\n" + " Stored dump: %s\n" + " Size: %" PRIu32 "\n" + " Verified: %s\n" + " Error: %d", + YESNO(this->stored_), this->total_, YESNO(this->verify_ == 1), + coredump_query(COREDUMP_QUERY_GET_ERROR, nullptr)); +} + +} // namespace esphome::zephyr_coredump + +// Backport of https://github.com/zephyrproject-rtos/zephyr/pull/79622 (Zephyr 4.0): give GDB the stack +// pointer from before the exception frame so it can unwind. The Python side links it in with --wrap. +#ifdef USE_ZEPHYR_COREDUMP_SP_FIX +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +#if KERNEL_VERSION_NUMBER >= 0x030700 +using esf_t = struct arch_esf; +#else +using esf_t = z_arch_esf_t; +#endif + +static constexpr uint8_t EXC_RETURN_STACK_FRAME_TYPE_POS = 4; +static constexpr uint32_t EXC_RETURN_STACK_FRAME_TYPE_EXTENDED = 0; + +static uint32_t global_exc_return; + +void __real_z_arm_fault(uint32_t msp, uint32_t psp, uint32_t exc_return, _callee_saved_t *callee_regs); +void __wrap_z_arm_fault(uint32_t msp, uint32_t psp, uint32_t exc_return, _callee_saved_t *callee_regs) { + global_exc_return = exc_return; + __real_z_arm_fault(msp, psp, exc_return, callee_regs); +} + +// Faults from z_arm_fault() come here with the frame z_arm_fault() saved; a spurious interrupt has none. +// Software fatal errors (k_panic, k_oops) reach z_arm_fatal_error() inside its own object, which --wrap cannot +// intercept, so their dumps keep Zephyr's stack pointer. +void __real_z_arm_fatal_error(unsigned int reason, const esf_t *esf); +void __wrap_z_arm_fatal_error(unsigned int reason, const esf_t *esf) { +#if defined(CONFIG_ARMV7_M_ARMV8_M_MAINLINE) || defined(CONFIG_ARMV6_M_ARMV8_M_BASELINE) + if (esf == nullptr) { + __real_z_arm_fatal_error(reason, esf); + return; + } + z_arm_coredump_fault_sp += sizeof(esf->basic); +#if defined(CONFIG_FPU) && defined(CONFIG_FPU_SHARING) + // The thread used the FP registers, so the frame also holds them + if ((global_exc_return & BIT(EXC_RETURN_STACK_FRAME_TYPE_POS)) == EXC_RETURN_STACK_FRAME_TYPE_EXTENDED) { + z_arm_coredump_fault_sp += sizeof(esf->fpu); + } +#endif +#ifndef CONFIG_ARMV8_M_MAINLINE + // PSR bit 9 set: the frame was padded for alignment + if ((esf->basic.xpsr & SCB_CCR_STKALIGN_Msk) == SCB_CCR_STKALIGN_Msk) { + z_arm_coredump_fault_sp |= 0x4; + } +#endif +#endif + __real_z_arm_fatal_error(reason, esf); +} +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif +#endif diff --git a/esphome/components/zephyr_coredump/coredump.h b/esphome/components/zephyr_coredump/coredump.h new file mode 100644 index 00000000000..f56568e3555 --- /dev/null +++ b/esphome/components/zephyr_coredump/coredump.h @@ -0,0 +1,43 @@ +#pragma once +#include "esphome/core/component.h" + +namespace esphome::zephyr_coredump { + +/// The tag the dump is logged under, so a client can check the level in force for it +inline constexpr const char *CRASH_LOG_TAG = "zephyr_coredump"; + +/// Whether a dump from before this boot is waiting to be printed. +bool coredump_pending(); +/// A log reader is present: serial at boot, USB CDC when the port opens, an API or MQTT client subscribing. +/// The stored dump is printed from the start and erased once it has gone out; a print already under way +/// carries on. +void print_coredump(); +/// The reader went away (the USB port was closed); the print stops and the dump stays for the next one. +/// Readers are not told apart: closing the USB port also stops a print an API or MQTT client is receiving, +/// and a client that disconnects does not stop one, the same as the other platforms' crash records. +void stop_coredump(); + +class Coredump : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + + protected: + friend bool coredump_pending(); + friend void print_coredump(); + friend void stop_coredump(); + /// Start printing after a short delay, one line per loop so the USB CDC transmit buffer and the main loop keep up + void start_print_(); + void stop_print_(); + void finish_(int err); + + uint32_t offset_{0}; + uint32_t total_{0}; + uint32_t last_line_ms_{0}; + int verify_{0}; // result of verifying the stored dump, 1 when it is good + bool stored_{false}; + bool printing_{false}; +}; + +} // namespace esphome::zephyr_coredump diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 5b438bbda1c..b6ef932e4f8 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components.nrf52.boards import MCUMGR_OTA_BOOTLOADERS @@ -31,8 +33,11 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@tomaszduda23"] DEPENDENCIES = ["zephyr"] @@ -114,6 +119,12 @@ def _validate_bootloader(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: _validate_ble_server(config) _validate_bootloader(config) + if "zephyr_coredump" in fv.full_config.get(): + _LOGGER.warning( + "zephyr_coredump moves the OTA image slots. A device that already runs mcumgr OTA " + "needs one flash over USB after enabling or disabling it; an OTA update alone leaves " + "the installed MCUboot with the old slot layout" + ) if (uart := config[CONF_TRANSPORT].get(CONF_HARDWARE_UART)) is not None: cdc_id = UARTS[uart][1] if cdc_id >= 0: @@ -210,17 +221,18 @@ async def to_code(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader != BOOTLOADER_MCUBOOT: - # 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. + # Derive partition addresses from the 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, + # which ends at the lowest section reserved above (bootloader, coredump). 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) + sd_end = next(s.end_address for s in sections if "SoftDevice" in s.name) + top = min(s.address for s in sections if s.address >= sd_end) slot0_start = sd_end + mcuboot_size # Align slot size down to a 4 KB sector boundary - slot_size = ((bl_start - slot0_start) // 2 // 0x1000) * 0x1000 + slot_size = ((top - slot0_start) // 2 // 0x1000) * 0x1000 slot1_start = slot0_start + slot_size def _mcuboot_partition_overlay() -> str: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index de4dfd2607b..331a637b6b3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -756,6 +756,7 @@ #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_SOFTDEVICE_ID 7 #define USE_SOFTDEVICE_VERSION 1 +#define USE_ZEPHYR_COREDUMP_SP_FIX #define USE_ZIGBEE #define USE_ZIGBEE_WIPE_ON_BOOT #define USE_ZIGBEE_WIPE_ON_BOOT_MAGIC 1 diff --git a/tests/component_tests/zephyr_coredump/__init__.py b/tests/component_tests/zephyr_coredump/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml b/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml new file mode 100644 index 00000000000..cb67b8ef9f7 --- /dev/null +++ b/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml @@ -0,0 +1,15 @@ +esphome: + name: coredumptest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr + +zephyr_coredump: diff --git a/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py b/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py new file mode 100644 index 00000000000..19252188cb8 --- /dev/null +++ b/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py @@ -0,0 +1,104 @@ +"""Tests for the placement of the zephyr_coredump flash partition.""" + +from collections.abc import Callable +import logging +from pathlib import Path +import re + +import pytest + +from esphome.components.nrf52 import set_core_data +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_OVERLAY, KEY_PM_STATIC +from esphome.components.zephyr_coredump import ( + DUMP_THREADS_MIN_VERSION, + PARTITION_SIZE, + _partition_address, +) +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + + +def _core_data( + board: str, + bootloader: str, + testing_mode: bool = False, + version: cv.Version = DUMP_THREADS_MIN_VERSION, +) -> None: + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: version} + CORE.testing_mode = testing_mode + set_core_data({"board": board, "bootloader": bootloader}) + + +@pytest.mark.parametrize( + ("board", "bootloader", "testing_mode", "expected"), + [ + # Below the Adafruit bootloader at 0xF4000 + ("adafruit_itsybitsy_nrf52840", "adafruit_nrf52_sd140_v6", False, 0xEC000), + # Below the factory Open DFU bootloader at 0xE0000 + ("nrf52840dongle", "nrf", False, 0xD8000), + # Nothing reserved: the end of the 1 MB flash + ("adafruit_feather_nrf52840", "mcuboot", False, 0xF8000), + # An nRF52840 board whose name does not say so + ("xiao_ble", "mcuboot", False, 0xF8000), + # Testing mode moves the bootloader to the end of the faked 4 MB flash + ("adafruit_itsybitsy_nrf52840", "adafruit_nrf52_sd140_v6", True, 0x3EC000), + ("adafruit_feather_nrf52840", "mcuboot", True, 0x3F8000), + ], +) +def test_partition_address( + board: str, bootloader: str, testing_mode: bool, expected: int +) -> None: + _core_data(board, bootloader, testing_mode) + assert _partition_address() == expected + assert PARTITION_SIZE % 0x1000 == 0 + + +def test_partition_size_is_the_same_on_every_sdk() -> None: + _core_data("adafruit_feather_nrf52840", "mcuboot", version=cv.Version(2, 6, 1)) + assert _partition_address() == 0x100000 - PARTITION_SIZE + + +def test_unknown_flash_size_rejected() -> None: + _core_data("bbc_microbit", "mcuboot") + with pytest.raises(cv.Invalid, match="only nRF52840 boards are supported"): + _partition_address() + + +def test_bootloader_without_layout_rejected() -> None: + """The generic adafruit bootloader registers no sections, so the partition would + land inside the bootloader if it were treated like MCUboot.""" + _core_data("adafruit_itsybitsy_nrf52840", "adafruit") + with pytest.raises(cv.Invalid, match="flash layout of bootloader 'adafruit'"): + _partition_address() + + +def test_mcumgr_slot_ends_below_partition( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OTA image slot1 must end at or before the coredump partition.""" + generate_main(component_config_path("mcumgr_adafruit.yaml")) + # The logger's crash handler hooks are compiled in for the dump + assert any(d.name == "USE_CRASH_HANDLER" for d in CORE.defines) + sections = {s.name: s for s in zephyr_data()[KEY_PM_STATIC]} + coredump = sections["coredump_partition"] + assert coredump.address == 0xEC000 + match = re.search( + r"slot1_partition: partition@\w+ \{\s*reg = <(0x\w+) (0x\w+)>", + zephyr_data()[KEY_OVERLAY][""], + ) + assert match is not None + start, size = (int(v, 16) for v in match.groups()) + assert start + size <= coredump.address + + +def test_mcumgr_warns_that_the_slots_move( + caplog: pytest.LogCaptureFixture, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("mcumgr_adafruit.yaml")) + assert "zephyr_coredump moves the OTA image slots" in caplog.text diff --git a/tests/components/zephyr_coredump/common.yaml b/tests/components/zephyr_coredump/common.yaml new file mode 100644 index 00000000000..e8d8a8b8954 --- /dev/null +++ b/tests/components/zephyr_coredump/common.yaml @@ -0,0 +1 @@ +zephyr_coredump: diff --git a/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml b/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml b/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml b/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml b/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml From f72857151af118e0a05cdf1bd7c99bb09fbedcb2 Mon Sep 17 00:00:00 2001 From: functionpointer Date: Sat, 17 Oct 2026 21:28:52 +0200 Subject: [PATCH 524/651] [pylontech] Add individual cell voltage and temperature sensors (#16852) Co-authored-by: Michael Co-authored-by: ritschys <117527311+ritschys@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pylontech/__init__.py | 32 ++- esphome/components/pylontech/pylontech.cpp | 225 +++++++++++---- esphome/components/pylontech/pylontech.h | 55 +++- .../components/pylontech/sensor/__init__.py | 48 +++- .../pylontech/sensor/pylontech_sensor.cpp | 23 ++ .../pylontech/sensor/pylontech_sensor.h | 16 +- esphome/core/defines.h | 2 + tests/components/pylontech/__init__.py | 4 +- tests/components/pylontech/common.yaml | 21 ++ tests/components/pylontech/pylontech_test.cpp | 260 +++++++++++++++++- tests/integration/test_uart_mock_pylontech.py | 2 +- 11 files changed, 630 insertions(+), 58 deletions(-) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 8bd57f252c1..e15f8f04c82 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -1,8 +1,10 @@ from dataclasses import dataclass, field import logging +from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE @@ -29,11 +31,13 @@ CV_NUM_BATTERIES = cv.int_range(1, 16) _request_listener_slot = cg.slot_counter("PYLONTECH_LISTENER_COUNT") _request_battery_slot = cg.slot_counter("PYLONTECH_BATTERY_COUNT") +_request_cell_battery_slot = cg.slot_counter("PYLONTECH_CELL_BATTERY_COUNT") @dataclass class PylontechData: batteries: set[tuple[str, int]] = field(default_factory=set) + cell_batteries: set[tuple[str, int]] = field(default_factory=set) def _get_data() -> PylontechData: @@ -42,15 +46,26 @@ def _get_data() -> PylontechData: return CORE.data[DOMAIN] -async def register_listener(config: ConfigType, listener: MockObj) -> None: - """Register a battery listener; the hub keeps one line slot per distinct battery.""" +async def register_listener( + config: ConfigType, listener: MockObj, *, cells: bool = False +) -> None: + """Register a battery listener; the hub keeps one line slot per distinct battery. + + With cells, the hub also reads that battery's cells after every pwr reply and keeps + one cell slot per distinct battery. + """ hub = await cg.get_variable(config[CONF_PYLONTECH_ID]) hub_key = str(hub) _request_listener_slot(hub_key) data = _get_data() - if (key := (hub_key, config[CONF_BATTERY])) not in data.batteries: + key = (hub_key, config[CONF_BATTERY]) + if key not in data.batteries: data.batteries.add(key) _request_battery_slot(hub_key) + if cells and key not in data.cell_batteries: + data.cell_batteries.add(key) + _request_cell_battery_slot(hub_key) + cg.add(hub.add_cell_battery(config[CONF_BATTERY])) cg.add(hub.register_listener(listener)) @@ -80,6 +95,17 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( stop_bits=1, ) +automation.register_apply_action( + "pylontech.set_cell_polling", + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(PylontechComponent), + cv.Required(CONF_ENABLED): cv.templatable(cv.boolean), + } + ), + automation.ApplyField(CONF_ENABLED, "set_cell_polling_enabled", cg.bool_), +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 5e48e8b726c..90cb4a8b855 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -1,7 +1,9 @@ #include "pylontech.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include #include @@ -20,13 +22,13 @@ #define PARSE_INT(field, field_name) \ { \ - get_token(token_buf); \ + get_token(cursor, token_buf); \ PARSE_TOKEN_INT(field, field_name) \ } #define PARSE_STR(field, field_name) \ { \ - get_token(field); \ + get_token(cursor, field); \ if (strlen(field) < 2) { \ ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \ this->reject_line_(); \ @@ -38,6 +40,11 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech"); +#ifdef PYLONTECH_CELL_BATTERY_COUNT +// The pack drops a command that follows its reply without a pause +static constexpr uint32_t BAT_COMMAND_DELAY_MS = 20; +#endif + // Rejects values that would wrap in a narrowed field template static bool in_range(int value) { if constexpr (sizeof(T) >= sizeof(int)) { @@ -47,6 +54,37 @@ template static bool in_range(int value) { } } +#ifdef PYLONTECH_LISTENER_COUNT +// Copies the next whitespace separated token, at most TEXT_SENSOR_MAX_LEN - 1 characters, and moves cursor past it +static void get_token(const char *&cursor, char *token_buf) { + // Skip leading whitespace + while (*cursor == ' ' || *cursor == '\t') { + cursor++; + } + + if (*cursor == '\0') { + token_buf[0] = 0; + return; + } + + const char *start = cursor; + + // Find end of field + while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { + cursor++; + } + + size_t token_len = std::min(static_cast(cursor - start), static_cast(TEXT_SENSOR_MAX_LEN - 1)); + memcpy(token_buf, start, token_len); + token_buf[token_len] = 0; +} + +// Slots are kept in battery order, so a battery's slot index is the number of lower batteries in the mask +static uint8_t slot_index(battery_mask_t batteries, uint8_t bat_index) { + return __builtin_popcount(batteries & ((1u << bat_index) - 1u)); +} +#endif + PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { @@ -67,13 +105,34 @@ void PylontechComponent::setup() { } } -void PylontechComponent::update() { this->write_str("pwr\n"); } +void PylontechComponent::update() { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // A "bat N" that was not sent yet, or whose reply never finished, must not get in the way of "pwr" + this->bat_command_due_ = false; + this->late_reply_ = this->bat_reply_ != 0; + this->bat_reply_ = 0; +#endif + this->write_str("pwr\n"); +} void PylontechComponent::loop() { // Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle. size_t avail = this->available(); #ifdef PYLONTECH_LISTENER_COUNT if (avail == 0) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->bat_command_due_ && + static_cast(App.get_loop_component_start_time() - this->bat_command_at_) >= 0) { + this->bat_command_due_ = false; + if (this->cell_polling_enabled_) { + char cmd[9]; // "bat 16\n" + snprintf(cmd, sizeof(cmd), "bat %u\n", this->bat_reply_); + this->write_str(cmd); + } else { + this->bat_reply_ = 0; // turned off during the pause: nothing is coming + } + } +#endif this->dispatch_one_(); return; } @@ -120,20 +179,59 @@ void PylontechComponent::dispatch_one_() { } return; } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->cells_pending_ != 0) { + const uint8_t bat_index = __builtin_ctz(this->cells_pending_); + this->cells_pending_ &= ~(1u << bat_index); + const uint8_t slot = slot_index(this->cell_batteries_, bat_index); + ESP_LOGV(TAG, "Publishing cells of battery %u", bat_index + 1); + for (PylontechListener *listener : this->listeners_) { + if (listener->get_battery() == bat_index + 1) { + listener->on_cells_read(this->cell_slots_[slot], this->cells_read_[slot]); + } + } + return; + } +#endif if (this->rejected_ != 0) { - ESP_LOGW(TAG, "Rejected %u malformed battery lines", this->rejected_); + ESP_LOGW(TAG, "Rejected %u malformed lines", this->rejected_); this->rejected_ = 0; } } void PylontechComponent::parse_header_(const char *buffer) { this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // The header opens a "pwr" reply, so what follows are battery lines even after a late "bat N" reply + this->bat_reply_ = 0; + this->late_reply_ = false; + this->pwr_reply_ = true; +#endif ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), buffer); } // Runs while the UART drains: no logging above VERBOSE and no publishing here void PylontechComponent::process_line_(const char *buffer) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (strcmp(buffer, "$$") == 0) { + // Every reply ends with "$$"; after the "pwr" reply one "bat N" follows per battery with cell sensors. + // A "$$" that ends neither of the two, such as a late reply to an abandoned "bat N", sends nothing. + if (this->bat_reply_ != 0) { + this->cells_pending_ |= 1u << (this->bat_reply_ - 1); + this->send_next_bat_(); + } else if (this->pwr_reply_) { + this->pwr_reply_ = false; + this->send_next_bat_(); + } + return; + } + // The "pwr" header always starts a "pwr" reply, whatever was expected + if (this->bat_reply_ != 0 && strncmp(buffer, "Power", 5) != 0) { + this->parse_cell_line_(buffer); + return; + } +#endif // clang-format off // example lines to parse: // Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St @@ -147,31 +245,7 @@ void PylontechComponent::process_line_(const char *buffer) { const char *cursor = buffer; char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; - // Helper Lambda to extract tokens - auto get_token = [&](char *token_buf) -> void { - // Skip leading whitespace - while (*cursor == ' ' || *cursor == '\t') { - cursor++; - } - - if (*cursor == '\0') { - token_buf[0] = 0; - return; - } - - const char *start = cursor; - - // Find end of field - while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { - cursor++; - } - - size_t token_len = std::min(static_cast(cursor - start), static_cast(TEXT_SENSOR_MAX_LEN - 1)); - memcpy(token_buf, start, token_len); - token_buf[token_len] = 0; - }; - - get_token(token_buf); + get_token(cursor, token_buf); auto bat = parse_number(token_buf); if (!bat.has_value()) { if (strcmp(token_buf, "Power") == 0) { @@ -182,20 +256,20 @@ void PylontechComponent::process_line_(const char *buffer) { } return; } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->late_reply_) { + ESP_LOGV(TAG, "dropped late line %s", buffer); // cell rows of the abandoned "bat N" look like battery lines + return; + } +#endif const unsigned bat_index = static_cast(*bat) - 1u; if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) { return; // no listener for this battery } - const battery_mask_t bat_bit = 1u << bat_index; - // The slot index is the number of listened batteries below this one - uint8_t slot = 0; - for (battery_mask_t below = this->batteries_ & (bat_bit - 1); below != 0; below &= below - 1) { - slot++; - } - const battery_mask_t slot_bit = 1u << slot; + const battery_mask_t slot_bit = 1u << slot_index(this->batteries_, bat_index); // Cleared until the whole line parses, so a bad line never publishes a half written slot this->pending_ &= ~slot_bit; - PylontechListener::LineContents &l = this->slots_[slot]; + PylontechListener::LineContents &l = this->slots_[__builtin_ctz(slot_bit)]; l.bat_num = *bat; PARSE_INT(l.volt, "Volt"); @@ -203,40 +277,99 @@ void PylontechComponent::process_line_(const char *buffer) { PARSE_INT(l.tempr, "Tempr"); PARSE_INT(l.tlow, "Tlow"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Tlow.Id + get_token(cursor, token_buf); // Skip Tlow.Id } PARSE_INT(l.thigh, "Thigh"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Thigh.Id + get_token(cursor, token_buf); // Skip Thigh.Id } PARSE_INT(l.vlow, "Vlow"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Vlow.Id + get_token(cursor, token_buf); // Skip Vlow.Id } PARSE_INT(l.vhigh, "Vhigh"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Vhigh.Id + get_token(cursor, token_buf); // Skip Vhigh.Id } PARSE_STR(l.base_st, "Base.St"); PARSE_STR(l.volt_st, "Volt.St"); PARSE_STR(l.curr_st, "Curr.St"); PARSE_STR(l.temp_st, "Temp.St"); - get_token(token_buf); + get_token(cursor, token_buf); if (char *pct = strchr(token_buf, '%')) { *pct = '\0'; } PARSE_TOKEN_INT(l.coulomb, "Coulomb"); - get_token(token_buf); // Skip Date - get_token(token_buf); // Skip Time - get_token(token_buf); // Skip B.V.St - get_token(token_buf); // Skip B.T.St + get_token(cursor, token_buf); // Skip Date + get_token(cursor, token_buf); // Skip Time + get_token(cursor, token_buf); // Skip B.V.St + get_token(cursor, token_buf); // Skip B.T.St PARSE_INT(l.mostempr, "Mostempr"); this->pending_ |= slot_bit; } + +#ifdef PYLONTECH_CELL_BATTERY_COUNT +// Picks the next cell battery above the one just read and schedules "bat N" for it; the cycle ends when none +// is left or cell polling was turned off +void PylontechComponent::send_next_bat_() { + // Battery n is bit n - 1, so clearing the low bat_reply_ bits leaves the batteries above it + const battery_mask_t remaining = + this->cell_batteries_ & static_cast(~((1u << this->bat_reply_) - 1u)); + if (remaining == 0 || !this->cell_polling_enabled_) { + this->bat_reply_ = 0; + return; + } + const uint8_t bat_index = __builtin_ctz(remaining); + this->bat_reply_ = bat_index + 1; + // The slot is about to be rewritten, so an unpublished record must not go out half filled + this->cells_pending_ &= ~(1u << bat_index); + this->cells_read_[slot_index(this->cell_batteries_, bat_index)] = 0; + // loop() writes the command once the pause has passed and the line is idle + this->bat_command_due_ = true; + this->bat_command_at_ = App.get_loop_component_start_time() + BAT_COMMAND_DELAY_MS; +} + +// Runs while the UART drains, like process_line_ +void PylontechComponent::parse_cell_line_(const char *buffer) { + // clang-format off + // example lines to parse, the reply to "bat N": + // Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL + // 0 3308 -914 18300 Dischg Normal Normal Normal 70% 34015 mAH N + // clang-format on + + const char *cursor = buffer; + char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; + + get_token(cursor, token_buf); + auto cell = parse_number(token_buf); + if (!cell.has_value() || *cell < 0 || *cell >= MAX_CELLS) { + // the header, the echoed command or "Command completed successfully" + ESP_LOGV(TAG, "unknown line %s", buffer); + return; + } + PylontechListener::CellContents c{}; + PARSE_INT(c.volt, "Volt"); + get_token(cursor, token_buf); // Skip Curr + int32_t tempr; // m°C + PARSE_INT(tempr, "Tempr"); + tempr /= 10; + if (!in_range(tempr)) { + ESP_LOGV(TAG, "invalid Tempr in line %s", buffer); + this->reject_line_(); + return; + } + c.tempr = tempr; + + const uint8_t slot = slot_index(this->cell_batteries_, this->bat_reply_ - 1); + this->cell_slots_[slot][*cell] = c; + this->cells_read_[slot] |= 1u << *cell; +} +#endif #endif } // namespace esphome::pylontech +#undef PARSE_TOKEN_INT #undef PARSE_INT #undef PARSE_STR diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index aa652056dca..77403478291 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -16,6 +16,10 @@ static constexpr uint8_t TEXT_SENSOR_MAX_LEN = 14; static constexpr uint8_t MAX_BATTERIES = 16; using battery_mask_t = uint16_t; static_assert(MAX_BATTERIES <= sizeof(battery_mask_t) * 8, "battery mask too narrow"); +// A battery reports cells 0 to MAX_CELLS - 1; bit c of a cell_mask_t stands for cell c +static constexpr uint8_t MAX_CELLS = 15; +using cell_mask_t = uint16_t; +static_assert(MAX_CELLS <= sizeof(cell_mask_t) * 8, "cell mask too narrow"); class PylontechListener { public: @@ -27,12 +31,22 @@ class PylontechListener { char base_st[TEXT_SENSOR_MAX_LEN], volt_st[TEXT_SENSOR_MAX_LEN], curr_st[TEXT_SENSOR_MAX_LEN], temp_st[TEXT_SENSOR_MAX_LEN]; }; + struct CellContents { + int16_t tempr; // 0.01 °C + uint16_t volt; // mV + }; + using CellArray = std::array; explicit PylontechListener(uint8_t bat_num) : bat_num_(bat_num) {} uint8_t get_battery() const { return this->bat_num_; } /// Called only with records for this listener's battery. virtual void on_line_read(LineContents *line) = 0; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + /// Called with the cells of this listener's battery after a "bat N" reply; bit c of read is set when cell c + /// was in the reply. + virtual void on_cells_read(const CellArray &cells, cell_mask_t read) {} +#endif virtual void dump_config() = 0; protected: @@ -51,11 +65,22 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic void setup() override; void dump_config() override; + /// Turn the "bat N" cell readings after each "pwr" reply off or on; nothing to turn off without cell sensors. + void set_cell_polling_enabled(bool enabled) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + this->cell_polling_enabled_ = enabled; +#endif + } + #ifdef PYLONTECH_LISTENER_COUNT void register_listener(PylontechListener *listener) { this->listeners_.push_back(listener); this->batteries_ |= 1u << (listener->get_battery() - 1); } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + /// Read the cells of this battery with "bat N" after every "pwr" reply. + void add_cell_battery(uint8_t bat_num) { this->cell_batteries_ |= 1u << (bat_num - 1); } +#endif protected: void process_line_(const char *buffer); @@ -67,17 +92,41 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic } } - // The longest line, the newer firmware header, is 182 characters - LineReader<200> line_reader_; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + void parse_cell_line_(const char *buffer); + void send_next_bat_(); +#endif + + // Members are ordered by alignment, widest first, so the small ones pack without padding StaticVector listeners_; // One slot per listened battery, in battery order; parsed while the UART drains, published one per // loop once it is idle. A newer line for a battery replaces the unpublished one. static_assert(PYLONTECH_BATTERY_COUNT <= MAX_BATTERIES, "more slots than batteries"); std::array slots_; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // One cell slot per battery with cell sensors, in battery order, filled by its "bat N" reply and published + // to that battery's listeners once the reply has ended and the UART is idle + std::array cell_slots_; + uint32_t bat_command_at_{0}; // loop time the pause before the next "bat N" ends at +#endif battery_mask_t batteries_{0}; // batteries with a listener battery_mask_t pending_{0}; // bit i set: slots_[i] is waiting to be published +#ifdef PYLONTECH_CELL_BATTERY_COUNT + std::array cells_read_; // cells the current reply has filled + battery_mask_t cell_batteries_{0}; // batteries whose cells are read + battery_mask_t cells_pending_{0}; // batteries whose cell slot is waiting to be published +#endif bool has_tlow_id_{false}; - uint8_t rejected_{0}; // malformed battery lines since the last idle pass + uint8_t rejected_{0}; // malformed lines since the last idle pass +#ifdef PYLONTECH_CELL_BATTERY_COUNT + uint8_t bat_reply_{0}; // battery whose "bat N" reply is arriving, 0 while the "pwr" reply arrives + bool pwr_reply_{false}; // a "pwr" header arrived and its "$$" has not + bool bat_command_due_{false}; // a "bat N" waits for the pause after the last reply + bool late_reply_{false}; // an abandoned "bat N" may still answer; its lines are dropped until "pwr" answers + bool cell_polling_enabled_{true}; +#endif + // The longest line, the newer firmware header, is 182 characters + LineReader<200> line_reader_; #endif }; diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 51aceace3e7..490331df70f 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -28,6 +28,13 @@ CONF_TEMPERATURE_HIGH = "temperature_high" CONF_VOLTAGE_LOW = "voltage_low" CONF_VOLTAGE_HIGH = "voltage_high" CONF_MOS_TEMPERATURE = "mos_temperature" +CONF_CELLS = "cells" +CONF_CELL = "cell" + +# A battery reports cells 0 to NUM_CELLS - 1 +NUM_CELLS = 15 + +_request_cell_slot = cg.slot_counter("PYLONTECH_SENSOR_CELL_COUNT") TYPES: dict[str, cv.Schema] = { CONF_VOLTAGE: sensor.sensor_schema( @@ -86,8 +93,34 @@ TYPES: dict[str, cv.Schema] = { ), } +CELL_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_CELL): cv.int_range(min=0, max=NUM_CELLS - 1), + cv.Optional(CONF_VOLTAGE): TYPES[CONF_VOLTAGE], + cv.Optional(CONF_TEMPERATURE): TYPES[CONF_TEMPERATURE], + } + ), + cv.has_at_least_one_key(CONF_VOLTAGE, CONF_TEMPERATURE), +) + + +def _unique_cells(cells: list[ConfigType]) -> list[ConfigType]: + seen: set[int] = set() + for cell in cells: + if (number := cell[CONF_CELL]) in seen: + raise cv.Invalid(f"Cell {number} is listed more than once") + seen.add(number) + return cells + + CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( - {cv.GenerateID(): cv.declare_id(PylontechSensor)} + { + cv.GenerateID(): cv.declare_id(PylontechSensor), + cv.Optional(CONF_CELLS): cv.All( + cv.ensure_list(CELL_SCHEMA), cv.Length(min=1), _unique_cells + ), + } ).extend({cv.Optional(marker): schema for marker, schema in TYPES.items()}) @@ -99,4 +132,15 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_sensor")(sens)) - await register_listener(config, bat) + cells = config.get(CONF_CELLS, []) + for cell_config in cells: + _request_cell_slot(str(bat)) + voltage = cg.nullptr + if voltage_config := cell_config.get(CONF_VOLTAGE): + voltage = await sensor.new_sensor(voltage_config) + temperature = cg.nullptr + if temperature_config := cell_config.get(CONF_TEMPERATURE): + temperature = await sensor.new_sensor(temperature_config) + cg.add(bat.add_cell(cell_config[CONF_CELL], voltage, temperature)) + + await register_listener(config, bat, cells=bool(cells)) diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index bead0387bef..c7612f46609 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -20,6 +20,13 @@ void PylontechSensor::dump_config() { LOG_SENSOR(" ", "Voltage high", this->voltage_high_sensor_); LOG_SENSOR(" ", "Coulomb", this->coulomb_sensor_); LOG_SENSOR(" ", "MOS Temperature", this->mos_temperature_sensor_); +#ifdef PYLONTECH_SENSOR_CELL_COUNT + for (const CellSensors &c : this->cells_) { + ESP_LOGCONFIG(TAG, " Cell %u", c.cell); + LOG_SENSOR(" ", "Voltage", c.voltage); + LOG_SENSOR(" ", "Temperature", c.temperature); + } +#endif } void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { @@ -52,4 +59,20 @@ void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { } } +#ifdef PYLONTECH_SENSOR_CELL_COUNT +void PylontechSensor::on_cells_read(const CellArray &cells, cell_mask_t read) { + for (const CellSensors &c : this->cells_) { + if ((read & (1u << c.cell)) == 0) { + continue; + } + if (c.voltage != nullptr) { + c.voltage->publish_state(((float) cells[c.cell].volt) / 1000.0f); + } + if (c.temperature != nullptr) { + c.temperature->publish_state(((float) cells[c.cell].tempr) / 100.0f); + } + } +} +#endif + } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 6b466b74664..75bc9ba01df 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -17,11 +17,25 @@ class PylontechSensor final : public PylontechListener { SUB_SENSOR(temperature_high) SUB_SENSOR(voltage_low) SUB_SENSOR(voltage_high) - SUB_SENSOR(coulomb) SUB_SENSOR(mos_temperature) void on_line_read(LineContents *line) override; + +#ifdef PYLONTECH_SENSOR_CELL_COUNT + void add_cell(uint8_t cell, sensor::Sensor *voltage, sensor::Sensor *temperature) { + this->cells_.push_back({voltage, temperature, cell}); + } + void on_cells_read(const CellArray &cells, cell_mask_t read) override; + + protected: + struct CellSensors { + sensor::Sensor *voltage; + sensor::Sensor *temperature; + uint8_t cell; + }; + StaticVector cells_; +#endif }; } // namespace esphome::pylontech diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 331a637b6b3..a2480108a72 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -179,7 +179,9 @@ #define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_TRIGGER_COUNT 1 #define PYLONTECH_BATTERY_COUNT 1 +#define PYLONTECH_CELL_BATTERY_COUNT 1 #define PYLONTECH_LISTENER_COUNT 2 +#define PYLONTECH_SENSOR_CELL_COUNT 2 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/tests/components/pylontech/__init__.py b/tests/components/pylontech/__init__.py index c16210128f6..15b9ef0e2a6 100644 --- a/tests/components/pylontech/__init__.py +++ b/tests/components/pylontech/__init__.py @@ -5,9 +5,11 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - # Storage is sized by code generation; the gtests register three batteries. + # Storage is sized by code generation; the gtests register three batteries and + # read the cells of two of them. cg.add_define("PYLONTECH_LISTENER_COUNT", 3) cg.add_define("PYLONTECH_BATTERY_COUNT", 3) + cg.add_define("PYLONTECH_CELL_BATTERY_COUNT", 2) cg.add_define("USE_LINE_READER") # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. diff --git a/tests/components/pylontech/common.yaml b/tests/components/pylontech/common.yaml index 537450a3b68..37a1c4514b8 100644 --- a/tests/components/pylontech/common.yaml +++ b/tests/components/pylontech/common.yaml @@ -14,6 +14,15 @@ sensor: id: pyl01_soc mos_temperature: id: pyl01_mos_temperature + cells: + - cell: 0 + voltage: + id: pyl01_cell_0_voltage + temperature: + id: pyl01_cell_0_temperature + - cell: 14 + voltage: + id: pyl01_cell_14_voltage - platform: pylontech pylontech_id: pylontech1 battery: 1 @@ -40,3 +49,15 @@ text_sensor: id: pyl0_current_state temperature_state: id: pyl0_temperature_state + +button: + - platform: template + name: Pylontech cell polling + on_press: + - pylontech.set_cell_polling: + id: pylontech0 + enabled: !lambda return false; + # pylontech1 has no cells, so this does nothing + - pylontech.set_cell_polling: + id: pylontech1 + enabled: true diff --git a/tests/components/pylontech/pylontech_test.cpp b/tests/components/pylontech/pylontech_test.cpp index 479530571c0..6041bd572b1 100644 --- a/tests/components/pylontech/pylontech_test.cpp +++ b/tests/components/pylontech/pylontech_test.cpp @@ -10,6 +10,7 @@ #include #include "esphome/components/pylontech/pylontech.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_component.h" namespace esphome::pylontech::testing { @@ -19,6 +20,7 @@ namespace { class MockUARTComponent : public uart::UARTComponent { public: std::vector rx; + std::string tx; // everything the hub wrote, in order void push_rx(std::initializer_list lines) { for (const std::string &line : lines) { @@ -26,7 +28,9 @@ class MockUARTComponent : public uart::UARTComponent { } } - void write_array(const uint8_t *data, size_t len) override {} + void write_array(const uint8_t *data, size_t len) override { + this->tx.append(reinterpret_cast(data), len); + } bool read_array(uint8_t *data, size_t len) override { if (this->rx.size() < len) { return false; @@ -48,6 +52,14 @@ class MockUARTComponent : public uart::UARTComponent { // Every record any listener gets, in publish order std::vector published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +struct PublishedCells { + uint8_t bat_num; + cell_mask_t read; + PylontechListener::CellArray cells; +}; +// Every cell record any listener gets, in publish order +std::vector published_cells; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + class TestListener final : public PylontechListener { public: explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {} @@ -55,6 +67,9 @@ class TestListener final : public PylontechListener { EXPECT_EQ(line->bat_num, this->bat_num_); published.push_back(*line); } + void on_cells_read(const CellArray &cells, cell_mask_t read) override { + published_cells.push_back({this->bat_num_, read, cells}); + } void dump_config() override {} }; @@ -74,8 +89,21 @@ constexpr const char *NEW_HEADER = constexpr const char *NEW_BAT1 = "1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal " "60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n"; +constexpr const char *REPLY_END = "Command completed successfully\r\n$$\r\n"; +constexpr const char *CELL_HEADER = + "Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL\r\n"; // clang-format on +// A line of the "bat N" reply for cell cell with volt (mV) and tempr (m°C); the record keeps 0.01 °C +std::string cell_line(int cell, int volt, int tempr) { + char buf[160]; + snprintf(buf, sizeof(buf), + "%d %d -914 %d Dischg Normal Normal Normal 70%% " + "34015 mAH N\r\n", + cell, volt, tempr); + return buf; +} + // An older firmware line for battery bat with pack voltage volt (mV) std::string bat_line(int bat, int volt) { char buf[192]; @@ -103,6 +131,34 @@ class PylontechTest : public ::testing::Test { TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)}; }; +// The cells of batteries 1 and 3 are read +class PylontechCellTest : public PylontechTest { + protected: + void SetUp() override { + PylontechTest::SetUp(); + published_cells.clear(); + this->hub_.add_cell_battery(1); + this->hub_.add_cell_battery(3); + } + + // Lets the hub drain what was pushed and publish everything pending + void drain_and_publish_() { + for (int i = 0; i < 8; i++) { + this->hub_.loop(); + } + } + + // Lets the pause before a bat command pass: moves the loop clock on and runs one idle loop, which also + // publishes one pending record + void let_pause_pass_() { + this->now_ += 50; + LoopBlockingGuard{nullptr, nullptr, this->now_}; + this->hub_.loop(); + } + + uint32_t now_{1000}; +}; + } // namespace TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) { @@ -249,4 +305,206 @@ TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) { EXPECT_EQ(this->published_()[0].bat_num, 2); } +TEST_F(PylontechCellTest, CellsAreReadAfterThePwrReplyOneBatteryAtATime) { + // Each pause's idle loop publishes one pending record: the battery record, then one battery's cells + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + EXPECT_TRUE(this->uart_.tx.empty()); // the pack needs a pause after its reply + EXPECT_TRUE(this->published_().empty()); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + + this->uart_.push_rx({"pylon>bat 1\r\n@\r\n", CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400), + cell_line(14, 3299, 17900), REPLY_END}); + this->hub_.loop(); + EXPECT_TRUE(published_cells.empty()); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n"); + ASSERT_EQ(published_cells.size(), 1u); + + this->uart_.push_rx({"pylon>bat 3\r\n@\r\n", CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n"); + ASSERT_EQ(published_cells.size(), 2u); + this->hub_.loop(); + ASSERT_EQ(published_cells.size(), 2u); + + const PublishedCells &b1 = published_cells[0]; + EXPECT_EQ(b1.bat_num, 1); + EXPECT_EQ(b1.read, (1u << 0) | (1u << 1) | (1u << 14)); + EXPECT_EQ(b1.cells[0].volt, 3308); + EXPECT_EQ(b1.cells[0].tempr, 1830); + EXPECT_EQ(b1.cells[1].volt, 3310); + EXPECT_EQ(b1.cells[14].volt, 3299); + EXPECT_EQ(b1.cells[14].tempr, 1790); + const PublishedCells &b3 = published_cells[1]; + EXPECT_EQ(b3.bat_num, 3); + EXPECT_EQ(b3.read, 1u << 5); + EXPECT_EQ(b3.cells[5].volt, 3250); + EXPECT_EQ(b3.cells[5].tempr, 1600); + + // The next pwr reply starts the cycle again + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\nbat 1\n"); +} + +TEST_F(PylontechCellTest, CellPollingCanBeTurnedOffAtAnyReplyEnd) { + this->hub_.set_cell_polling_enabled(false); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_EQ(this->published_().size(), 1u); + + this->hub_.set_cell_polling_enabled(true); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + + // Turned off while bat 1 is answering: its cells are still published, bat 3 is not sent + this->hub_.set_cell_polling_enabled(false); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(published_cells.size(), 1u); + EXPECT_EQ(published_cells[0].bat_num, 1); + + // Turned off during the pause before bat 1: the queued command is dropped and the next pwr lines are + // battery lines + this->hub_.set_cell_polling_enabled(true); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->hub_.set_cell_polling_enabled(false); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + this->uart_.push_rx({HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(this->published_().size(), 4u); + EXPECT_EQ(this->published_()[3].bat_num, 2); +} + +TEST_F(PylontechCellTest, UpdateAbandonsAnUnansweredBatReply) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + + // No reply ever comes; the next pwr lines are battery lines again + this->hub_.update(); + this->uart_.push_rx({HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n"); + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[1].bat_num, 2); + EXPECT_TRUE(published_cells.empty()); +} + +TEST_F(PylontechCellTest, UpdateCancelsABatCommandThatWasNotSentYet) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->hub_.update(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "pwr\n"); +} + +TEST_F(PylontechCellTest, LateBatReplyAfterUpdateIsNotMistakenForThePwrReply) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(this->published_().size(), 1u); + + // Battery 1's next record is still unpublished when update() abandons bat 1; the pack answers it only + // after the next pwr went out, with cell rows that look like battery lines, then answers pwr + this->uart_.push_rx({HEADER, bat_line(1, 49100), REPLY_END}); + this->hub_.loop(); + this->hub_.update(); + this->uart_.push_rx( + {CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400), cell_line(2, 3312, 18500), REPLY_END}); + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + + // The late rows are dropped without touching battery 1's record, its "$$" sends nothing, and the pwr + // reply starts the cycle once + EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n"); + ASSERT_EQ(this->published_().size(), 3u); + EXPECT_EQ(this->published_()[1].bat_num, 1); + EXPECT_EQ(this->published_()[1].volt, 49100); + EXPECT_EQ(this->published_()[2].bat_num, 2); + EXPECT_EQ(this->published_()[2].volt, 46012); + EXPECT_TRUE(published_cells.empty()); +} + +TEST_F(PylontechCellTest, BadCellLinesAreIgnoredAndTheNextReplyStartsEmpty) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), "0 abc -914 18300 Dischg\r\n", + cell_line(15, 3300, 18000), cell_line(14, 70000, 18000), cell_line(13, 3300, 400000), + REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), cell_line(5, 3251, 16100), REPLY_END}); + this->drain_and_publish_(); + + // The bad line for cell 0 leaves the good one; cell 15 does not exist; 70000 mV and 400 °C are out of + // range; the second cell 5 line replaces the first + ASSERT_EQ(published_cells.size(), 2u); + EXPECT_EQ(published_cells[0].read, 1u << 0); + EXPECT_EQ(published_cells[0].cells[0].volt, 3308); + EXPECT_EQ(published_cells[1].bat_num, 3); + EXPECT_EQ(published_cells[1].read, 1u << 5); + EXPECT_EQ(published_cells[1].cells[5].volt, 3251); + + // A reply that read nothing publishes an empty record, not the old cells + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(published_cells.size(), 3u); + EXPECT_EQ(published_cells[2].bat_num, 1); + EXPECT_EQ(published_cells[2].read, 0u); +} + +TEST_F(PylontechCellTest, ANewReplyUnmarksTheUnpublishedRecordOfItsBattery) { + // A whole cycle arrives with only the idle loops of the pauses: the battery record and battery 1's + // cells are published, battery 3's cells stay pending + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END}); + this->hub_.loop(); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + ASSERT_EQ(published_cells.size(), 1u); + EXPECT_EQ(published_cells[0].bat_num, 1); + + // The next bat 1 reply is cut short by a gap: battery 3's cells go out, battery 1's wait for "$$" + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3400, 19000)}); + this->hub_.loop(); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(published_cells.size(), 2u); + EXPECT_EQ(published_cells[1].bat_num, 3); + this->uart_.push_rx({REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(published_cells.size(), 3u); + EXPECT_EQ(published_cells[2].bat_num, 1); + EXPECT_EQ(published_cells[2].cells[0].volt, 3400); +} + } // namespace esphome::pylontech::testing diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py index fffaa902072..a434ed5bdee 100644 --- a/tests/integration/test_uart_mock_pylontech.py +++ b/tests/integration/test_uart_mock_pylontech.py @@ -79,7 +79,7 @@ async def test_uart_mock_pylontech( def line_callback(line: str) -> None: if "Line over 199 chars dropped" in line: overflow_lines.append(line) - if "malformed battery lines" in line: + if "malformed lines" in line: rejected_lines.append(line) collector = SensorStateCollector( From 792d650fb2076b64ed4a0d243fbcba22497d6787 Mon Sep 17 00:00:00 2001 From: Simonas Kazlauskas Date: Sat, 17 Oct 2026 22:44:25 +0300 Subject: [PATCH 525/651] [remote_base] Add a protocol for 433MHz A-OK remotes (#20991) --- esphome/components/remote_base/__init__.py | 54 +++++++ .../components/remote_base/aok_protocol.cpp | 80 +++++++++++ esphome/components/remote_base/aok_protocol.h | 26 ++++ esphome/core/defines.h | 1 + tests/components/remote_base/__init__.py | 1 + tests/components/remote_base/aok_test.cpp | 135 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 + .../remote_transmitter/common-buttons.yaml | 17 +++ 8 files changed, 319 insertions(+) create mode 100644 esphome/components/remote_base/aok_protocol.cpp create mode 100644 esphome/components/remote_base/aok_protocol.h create mode 100644 tests/components/remote_base/aok_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 0e0a63e9d94..abb216aad7d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_BUTTON, CONF_CARRIER_FREQUENCY, CONF_CHANNEL, + CONF_CHANNELS, CONF_CHECK, CONF_CODE, CONF_COMMAND, @@ -71,6 +72,7 @@ CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" CONF_EXTENSION = "extension" +CONF_REMOTE_ID = "remote_id" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -2911,3 +2913,55 @@ register_protocol_action( (CONF_MODE, "mode", IRA211ModeEnum), (CONF_FAN_MODE, "fan", IRA211FanEnum), ) + + +# A-OK +( + AOKData, + AOKBinarySensor, + AOKTrigger, + _, + AOKDumper, +) = declare_protocol("AOK") + +AOK_SCHEMA = cv.Schema( + { + cv.Required(CONF_REMOTE_ID): cv.hex_int_range(0, 0xFFFFFF), + cv.Required(CONF_CHANNELS): cv.hex_uint16_t, + cv.Required(CONF_COMMAND): cv.hex_uint8_t, + } +) + + +@register_binary_sensor("aok", AOKBinarySensor, AOK_SCHEMA) +def aok_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + AOKData, + ("remote_id", config[CONF_REMOTE_ID]), + ("channels", config[CONF_CHANNELS]), + ("command", config[CONF_COMMAND]), + ) + ) + ) + + +@register_trigger("aok", AOKTrigger, AOKData) +def aok_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("aok", AOKDumper) +def aok_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "aok", + "AOK", + AOK_SCHEMA, + (CONF_REMOTE_ID, "remote_id", cg.uint32), + (CONF_CHANNELS, "channels", cg.uint16), + (CONF_COMMAND, "command", cg.uint8), +) diff --git a/esphome/components/remote_base/aok_protocol.cpp b/esphome/components/remote_base/aok_protocol.cpp new file mode 100644 index 00000000000..e0abc1eb351 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.cpp @@ -0,0 +1,80 @@ +#include "aok_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.aok"); + +// A frame is a long start mark, then 64 bits, then a long gap. A one bit is a long mark and a short space, +// a zero bit is a short mark and a long space. The bits are: header (8), remote id (24), channels (16), +// command (8) and checksum (8), most significant bit first. +static constexpr uint8_t NBITS = 64; +static constexpr uint32_t START_HIGH_US = 5000; +static constexpr uint32_t LONG_US = 600; +static constexpr uint32_t SHORT_US = 285; +static constexpr uint8_t HEADER = 0xA3; +static constexpr uint32_t REMOTE_ID_MASK = 0xFFFFFF; + +// The sum of the six bytes between the header and the checksum +static uint8_t checksum(uint64_t frame) { + uint8_t sum = 0; + for (uint8_t shift = 8; shift < 56; shift += 8) { + sum += frame >> shift; + } + return sum; +} + +void AOKProtocol::encode(RemoteTransmitData *dst, const AOKData &data) { + uint64_t frame = (uint64_t(HEADER) << 56) | (uint64_t(data.remote_id & REMOTE_ID_MASK) << 32) | + (uint64_t(data.channels) << 16) | (uint64_t(data.command) << 8); + frame |= checksum(frame); + + dst->reserve((NBITS + 2) * 2); + dst->item(START_HIGH_US, LONG_US); + for (uint64_t mask = 1ULL << (NBITS - 1); mask; mask >>= 1) { + if (frame & mask) { + dst->item(LONG_US, SHORT_US); + } else { + dst->item(SHORT_US, LONG_US); + } + } + dst->item(LONG_US, START_HIGH_US); +} + +optional AOKProtocol::decode(RemoteReceiveData src) { + // A buffer can hold noise and several repeats of a frame, so look for the first good one. After any + // failure the loop restarts at the next timing, or where the failed frame stopped. + while (src.is_valid()) { + if (!src.expect_pulse_with_gap(START_HIGH_US, LONG_US)) { + src.advance(); + continue; + } + + uint64_t frame = 0; + uint8_t bits = 0; + for (; bits < NBITS; bits++) { + frame <<= 1; + if (src.expect_item(LONG_US, SHORT_US)) { + frame |= 1; + } else if (!src.expect_item(SHORT_US, LONG_US)) { + break; + } + } + + if (bits == NBITS && uint8_t(frame >> 56) == HEADER && uint8_t(frame) == checksum(frame)) { + return AOKData{ + .remote_id = uint32_t(frame >> 32) & REMOTE_ID_MASK, + .channels = uint16_t(frame >> 16), + .command = uint8_t(frame >> 8), + }; + } + } + return {}; +} + +void AOKProtocol::dump(const AOKData &data) { + ESP_LOGI(TAG, "Received A-OK: remote_id=0x%06" PRIX32 ", channels=0x%04X, command=0x%02X", data.remote_id, + data.channels, data.command); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aok_protocol.h b/esphome/components/remote_base/aok_protocol.h new file mode 100644 index 00000000000..19967fa40b6 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.h @@ -0,0 +1,26 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct AOKData { + uint32_t remote_id; // 24 bits + uint16_t channels; + uint8_t command; + + bool operator==(const AOKData &rhs) const { + return this->remote_id == rhs.remote_id && this->channels == rhs.channels && this->command == rhs.command; + } +}; + +class AOKProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const AOKData &data); + optional decode(RemoteReceiveData src); + void dump(const AOKData &data); +}; + +DECLARE_REMOTE_PROTOCOL(AOK) + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a2480108a72..13dc3492f29 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,7 @@ #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME #define USE_REMOTE_PROTOCOL_AEHA +#define USE_REMOTE_PROTOCOL_AOK #define USE_REMOTE_PROTOCOL_BEO4 #define USE_REMOTE_PROTOCOL_BRENNENSTUHL #define USE_REMOTE_PROTOCOL_BYRONSX diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index f1045572656..fed957d3378 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -12,6 +12,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "haier", "mirage", "aeha", + "aok", "keeloq", "nexus", "ira211", diff --git a/tests/components/remote_base/aok_test.cpp b/tests/components/remote_base/aok_test.cpp new file mode 100644 index 00000000000..d4fd33d0992 --- /dev/null +++ b/tests/components/remote_base/aok_test.cpp @@ -0,0 +1,135 @@ +#include + +#include "esphome/components/remote_base/aok_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint64_t FRAME = 0xA312345600A10B48ULL; +// The start mark and space, then 64 bits of a mark and a space each, then the closing mark and space +constexpr size_t TIMINGS_SIZE = (1 + 64 + 1) * 2; + +RawTimings encode(const AOKData &data) { + RemoteTransmitData tx; + AOKProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return AOKProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +// Bit `n` counted from the first one sent starts at timing 2 + 2 * n +RawTimings frame_timings(uint64_t frame) { + RawTimings timings{5000, -600}; + for (int i = 63; i >= 0; i--) { + if ((frame >> i) & 1) { + timings.insert(timings.end(), {600, -285}); + } else { + timings.insert(timings.end(), {285, -600}); + } + } + timings.insert(timings.end(), {600, -5000}); + return timings; +} + +} // namespace + +TEST(AOKProtocolTest, EncodesKnownFrame) { + EXPECT_EQ(encode(AOKData{.remote_id = 0x123456, .channels = 0x00A1, .command = 0x0B}), frame_timings(FRAME)); +} + +TEST(AOKProtocolTest, DecodesKnownFrame) { + auto decoded = decode(frame_timings(FRAME)); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->channels, 0x00A1); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, RoundTrips) { + for (const AOKData &sent : + {AOKData{0x000000, 0x0000, 0x00}, AOKData{0xFFFFFF, 0xFFFF, 0xFF}, AOKData{0xABCDEF, 0x8001, 0x7E}}) { + const RawTimings timings = encode(sent); + ASSERT_EQ(timings.size(), TIMINGS_SIZE); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_TRUE(*decoded == sent); + } +} + +TEST(AOKProtocolTest, EncodingKeepsRemoteIdToItsFieldWidth) { + auto decoded = decode(encode(AOKData{.remote_id = 0xFF123456, .channels = 0x00A1, .command = 0x0B})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); +} + +TEST(AOKProtocolTest, RejectsBadChecksum) { EXPECT_FALSE(decode(frame_timings(FRAME ^ 1)).has_value()); } + +TEST(AOKProtocolTest, RejectsBadHeader) { + // The header is not part of the checksum, so only the header check can reject this + EXPECT_FALSE(decode(frame_timings(FRAME ^ (1ULL << 56))).has_value()); +} + +TEST(AOKProtocolTest, SkipsLeadingNoise) { + RawTimings timings{300, -1200, 150, -90}; + const RawTimings frame = frame_timings(FRAME); + timings.insert(timings.end(), frame.begin(), frame.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCorrupt) { + RawTimings timings = frame_timings(FRAME ^ 1); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCutShort) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 40); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + EXPECT_TRUE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadStart) { + RawTimings timings = frame_timings(FRAME); + timings[0] = 2000; + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsTruncatedFrame) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 6); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadBitTiming) { + RawTimings timings = frame_timings(FRAME); + timings[2 + 2 * 20] = 1200; + 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 da88548d2ab..833de19ff4f 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -187,6 +187,11 @@ on_toto: - logger.log: format: "on_toto: %u %u %u" args: ["x.rc_code_1", "x.rc_code_2", "x.command"] +on_aok: + then: + - logger.log: + format: "on_aok: %u %u %u" + args: ["x.remote_id", "x.channels", "x.command"] on_hob2hood: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 66ee803d4f9..f9f1beceae9 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -395,6 +395,23 @@ button: repeat: times: 3 wait_time: 10ms + - platform: template + name: A-OK + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: 0x00A1 + command: 0x0B + - platform: template + name: A-OK lambda with repeat + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: !lambda return 0x1000; + command: 0x43 + repeat: + times: 8 + wait_time: 5ms - platform: template name: Toto with repeat on_press: From a576092f2577bc2ffb362e8754b70cd6fda03c0b Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:52:01 +0200 Subject: [PATCH 526/651] [remote_base] Send the 72-bit Jarolift TDEF group frame with transmit_keeloq (#20927) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 19 +++- .../remote_base/keeloq_protocol.cpp | 17 +++- .../components/remote_base/keeloq_protocol.h | 12 ++- .../config/keeloq_suffix.yaml | 8 ++ .../remote_transmitter/test_keeloq_action.py | 10 +- tests/components/remote_base/keeloq_test.cpp | 91 ++++++++++++++++++- .../remote_receiver/common-actions.yaml | 4 +- .../remote_transmitter/common-buttons.yaml | 11 +++ .../components/remote_base/test_keeloq.py | 19 ++++ 9 files changed, 181 insertions(+), 10 deletions(-) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index abb216aad7d..990d2b87e4d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1044,6 +1044,21 @@ KEELOQ_SCHEMA = cv.Schema( ) +def _keeloq_group_alone(config: ConfigType) -> ConfigType: + # The group bits take the places of level and the suffix. + if CONF_GROUP not in config: + return config + if CONF_SUFFIX in config or CONF_SUFFIX_BITS in config: + raise cv.Invalid( + "group cannot be combined with suffix or suffix_bits", [CONF_GROUP] + ) + if config[CONF_LEVEL] is not False: + raise cv.Invalid( + "group sets the bit that level sends; remove level", [CONF_LEVEL] + ) + return config + + 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.get(CONF_SUFFIX, 0) @@ -1060,6 +1075,7 @@ KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( # 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), + cv.Optional(CONF_GROUP): cv.hex_uint8_t, } ) @@ -1097,7 +1113,8 @@ register_protocol_action( (CONF_LEVEL, "vlow", cg.bool_), (CONF_SUFFIX, "suffix", cg.uint16), (CONF_SUFFIX_BITS, "suffix_bits", cg.uint8), - validate=_keeloq_suffix_fits, + automation.ApplyField(CONF_GROUP, "data.set_group", cg.uint8), + validate=cv.All(_keeloq_group_alone, _keeloq_suffix_fits), ) diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index fff8eb897f0..b91f70227e9 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -93,9 +93,14 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->space(1 * BIT_TIME_US); } - // repeat flag - always sent as a '1' - dst->mark(1 * BIT_TIME_US); - dst->space(2 * BIT_TIME_US); + // repeat flag + if (data.repeat) { + 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); + } const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; for (uint8_t i = 0; i < extra; i++) { @@ -234,6 +239,12 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { } void KeeloqProtocol::dump(const KeeloqData &data) { + if (data.suffix_bits == 6) { + // Jarolift TDEF frame: show the group as transmit_keeloq takes it. + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", group=0x%02x", + data.address, data.command, data.encrypted, data.group()); + return; + } 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); } diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index e391212e42d..43675eccaa6 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -9,11 +9,21 @@ struct KeeloqData { uint32_t encrypted; // 32 bit encrypted field uint32_t address; // 28 bit serial number uint8_t command; // Button Status S2-S1-S0-S3 - bool repeat; // Repeated command bit + bool repeat{true}; // 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}; + // Jarolift TDEF sends 8 group bits after the 64-bit word where the HCS301 has vlow and repeat. + void set_group(uint8_t group) { + this->vlow = group & 0x01; + this->repeat = group & 0x02; + this->suffix = group >> 2; + this->suffix_bits = 6; + } + // Only a Jarolift TDEF frame (suffix_bits == 6) carries a group; check suffix_bits before using it. + uint8_t group() const { return static_cast((this->suffix << 2) | (this->repeat << 1) | this->vlow); } + bool operator==(const KeeloqData &rhs) const { // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { diff --git a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml index f14bc75f305..a24a50cf8b8 100644 --- a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml +++ b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml @@ -31,3 +31,11 @@ button: code: 0xd19ef0a9 suffix: !lambda return 0x2A; suffix_bits: !lambda return 6; + - platform: template + name: Group frame + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py index cbc1efb0a69..c5137da1b4f 100644 --- a/tests/component_tests/remote_transmitter/test_keeloq_action.py +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -12,12 +12,18 @@ def test_keeloq_suffix_is_set_only_when_configured( ) -> None: main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) bodies = protocol_call_bodies(main_cpp) - assert len(bodies) == 3 - plain, constant, lambda_ = bodies + assert len(bodies) == 4 + plain, constant, lambda_, group = bodies # The plain word fills only the HCS301 fields. assert "data.suffix" not in plain + assert "set_group" 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_ + # group fills vlow, repeat and the suffix on the device. + assert "apply_call.data.set_group(0x0F);" in group + # It comes after the default level, so the group's bit 0 is what is sent. + assert group.index("data.vlow") < group.index("set_group") + assert "data.suffix" not in group diff --git a/tests/components/remote_base/keeloq_test.cpp b/tests/components/remote_base/keeloq_test.cpp index e96d8b9d01d..c2303b23d35 100644 --- a/tests/components/remote_base/keeloq_test.cpp +++ b/tests/components/remote_base/keeloq_test.cpp @@ -1,4 +1,5 @@ #include +#include #include "esphome/components/remote_base/keeloq_protocol.h" namespace esphome::remote_base::testing { @@ -42,6 +43,40 @@ RawTimings as_received(RawTimings timings, bool drop_tail = false) { return merged; } +// Jarolift TDEF group UP for the channel mask 0x0F0F, from a published test vector. The mask's low byte is +// inside the hopping code, the high byte is the group sent after the 64-bit word. +KeeloqData tdef_frame(uint8_t group) { + KeeloqData data{}; + data.encrypted = 0xF94A09F2; + data.address = 0x1234500; + data.command = 0x8; + data.set_group(group); + return data; +} + +// The data bits after the 10-bit-time header: a 1-bit-time mark is a 1, a 2-bit-time mark a 0. +std::vector sent_bits(const RawTimings &timings) { + std::vector bits; + size_t i = 0; + while (i < timings.size() && timings[i] != -static_cast(10 * BIT_US)) { + i++; + } + for (i++; i < timings.size(); i++) { + if (timings[i] > 0) { + bits.push_back(timings[i] == static_cast(BIT_US)); + } + } + return bits; +} + +uint32_t field(const std::vector &bits, size_t first, size_t count) { + uint32_t value = 0; + for (size_t i = 0; i < count; i++) { + value |= static_cast(bits[first + i]) << i; + } + return value; +} + optional decode(const RawTimings &timings) { return KeeloqProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); } @@ -146,7 +181,7 @@ TEST(KeeloqProtocolTest, ASpaceThatIsNeitherDataNorAGapDropsTheSuffix) { } TEST(KeeloqProtocolTest, ASuffixFollowsARepeatBitOfZero) { - // The encoder always sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. + // frame() 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; @@ -174,4 +209,58 @@ TEST(KeeloqProtocolTest, ASuffixEndsAtAGap) { } } +TEST(KeeloqProtocolTest, HcsWordStillEndsWithRepeatOne) { + // 32 code bits, 28 address bits, 4 command bits, vlow, repeat. + std::vector bits = sent_bits(encode(frame(0, 0))); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[64]); + EXPECT_TRUE(bits[65]); +} + +TEST(KeeloqProtocolTest, TdefGroupFrameSendsTheGroupAfterThe64BitWord) { + std::vector bits = sent_bits(encode(tdef_frame(0x0F))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 0, 32), 0xF94A09F2u); + EXPECT_EQ(field(bits, 32, 28), 0x1234500u); + EXPECT_EQ(field(bits, 60, 4), 0x8u); + EXPECT_EQ(field(bits, 64, 8), 0x0Fu); +} + +TEST(KeeloqProtocolTest, TdefChannelOneSendsNoChannelTenBit) { + // Channels 1 to 8 have no bit in the group. Bit 65 is the HCS301 repeat bit, which is always 1 + // without a group; a Jarolift receiver reads it as channel 10. + std::vector bits = sent_bits(encode(tdef_frame(0x00))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 64, 8), 0x00u); +} + +TEST(KeeloqProtocolTest, TdefGroupRoundTrips) { + // 0x0F is channels 9 to 12, 0x80 is channel 16, the last bit of the frame. + for (uint8_t group : {0x0F, 0x80}) { + auto decoded = decode(as_received(encode(tdef_frame(group)))); + ASSERT_TRUE(decoded.has_value()) << int(group); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234500u); + EXPECT_EQ(decoded->command, 0x8); + EXPECT_EQ(decoded->encrypted, 0xF94A09F2u); + EXPECT_EQ(decoded->suffix_bits, 6); + EXPECT_EQ(decoded->group(), group); + } + } +} + +TEST(KeeloqProtocolTest, AReceivedRepeatBitOfZeroIsSentAgainAsZero) { + KeeloqData received = frame(0, 0); + received.repeat = false; + std::vector bits = sent_bits(encode(received)); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[65]); + + auto decoded = decode(as_received(encode(received))); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_FALSE(decoded->repeat); + } +} + } // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 833de19ff4f..b37f838b811 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 %u %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits"] + format: "on_keeloq: %lu %lu %u %u %u %u" + args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits", "x.suffix_bits == 6 ? x.group() : 0"] on_haier: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index f9f1beceae9..cc3ff4d4e1a 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -385,6 +385,17 @@ button: code: 0xd19ef0a9 suffix: 0x00 suffix_bits: 8 + - platform: template + name: KeeLoq TDEF group + on_press: + remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F + repeat: + times: 2 + wait_time: 0ms - platform: template name: KeeLoq lambda with repeat on_press: diff --git a/tests/unit_tests/components/remote_base/test_keeloq.py b/tests/unit_tests/components/remote_base/test_keeloq.py index bd0f817dd1f..57204d17d84 100644 --- a/tests/unit_tests/components/remote_base/test_keeloq.py +++ b/tests/unit_tests/components/remote_base/test_keeloq.py @@ -12,6 +12,7 @@ from esphome.components.remote_base import ( _keeloq_suffix_fits, ) import esphome.config_validation as cv +from esphome.const import CONF_GROUP from esphome.core import Lambda @@ -43,3 +44,21 @@ def test_transmit_action_checks_the_width() -> None: ) with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): schema({**base, CONF_SUFFIX: 0x100}) + + +def test_group_takes_the_place_of_level_and_suffix() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + base = {"address": 0x1234500, "code": 0xF94A09F2, CONF_GROUP: 0x0F} + assert schema(base)[CONF_GROUP] == 0x0F + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX_BITS: 6}) + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX: 0}) + with pytest.raises(cv.Invalid, match="remove level"): + schema({**base, "level": True}) + + +def test_group_is_one_byte() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + with pytest.raises(cv.Invalid): + schema({"address": 0x1, "code": 0x2, CONF_GROUP: 0x100}) From d02da3c8dede540dba7415b2d08ea90df9b9e849 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:54:55 -0500 Subject: [PATCH 527/651] [wifi] Support hidden option in single-network shorthand (#18157) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 11 +++- tests/component_tests/wifi/test_wifi.py | 51 +++++++++++++++++++ .../validate-hidden-shorthand.esp32-idf.yaml | 5 ++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/wifi/test_wifi.py create mode 100644 tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5fceb891573..5660b1189b8 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -515,10 +515,16 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_PASSWORD in config and CONF_SSID not in config: raise cv.Invalid("Cannot have WiFi password without SSID!") + if CONF_HIDDEN in config and CONF_SSID not in config: + raise cv.Invalid( + "Cannot have 'hidden' without 'ssid'. If you are using the 'networks' " + "list, put 'hidden' inside the network entry instead." + ) + if CONF_SSID in config: # Automatically move single network to 'networks' section config = config.copy() @@ -527,6 +533,8 @@ def _validate(config): network[CONF_PASSWORD] = config.pop(CONF_PASSWORD) if CONF_EAP in config: network[CONF_EAP] = config.pop(CONF_EAP) + if CONF_HIDDEN in config: + network[CONF_HIDDEN] = config.pop(CONF_HIDDEN) if CONF_NETWORKS in config: # In testing mode, merged component tests may have both ssid and networks # Just use the networks list and ignore the single ssid @@ -643,6 +651,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), + cv.Optional(CONF_HIDDEN): cv.boolean, cv.Optional( CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED ): STA_MANUAL_IP_SCHEMA, diff --git a/tests/component_tests/wifi/test_wifi.py b/tests/component_tests/wifi/test_wifi.py new file mode 100644 index 00000000000..3513ee88a69 --- /dev/null +++ b/tests/component_tests/wifi/test_wifi.py @@ -0,0 +1,51 @@ +"""Config-validation tests for the wifi component.""" + +import pytest +from voluptuous import Invalid + +from esphome.components.wifi import _validate +from esphome.const import ( + CONF_HIDDEN, + CONF_NETWORKS, + CONF_PASSWORD, + CONF_SSID, + CONF_USE_ADDRESS, +) + + +def test_hidden_folded_into_networks() -> None: + """The shorthand 'hidden' option is moved into the generated networks entry.""" + config = _validate( + { + CONF_SSID: "MyHomeNetwork", + CONF_PASSWORD: "password1", + CONF_HIDDEN: True, + CONF_USE_ADDRESS: "test.local", + } + ) + assert CONF_SSID not in config + assert CONF_PASSWORD not in config + assert CONF_HIDDEN not in config + networks = config[CONF_NETWORKS] + assert len(networks) == 1 + assert networks[0][CONF_SSID] == "MyHomeNetwork" + assert networks[0][CONF_PASSWORD] == "password1" + assert networks[0][CONF_HIDDEN] is True + + +def test_shorthand_without_hidden_has_no_hidden_key() -> None: + """No 'hidden' key is set on the networks entry unless explicitly configured.""" + config = _validate( + { + CONF_SSID: "MyHomeNetwork", + CONF_PASSWORD: "password1", + CONF_USE_ADDRESS: "test.local", + } + ) + assert CONF_HIDDEN not in config[CONF_NETWORKS][0] + + +def test_hidden_without_ssid_rejected() -> None: + """A 'hidden' option without an SSID has nothing to apply to and is rejected.""" + with pytest.raises(Invalid, match="hidden"): + _validate({CONF_HIDDEN: True, CONF_USE_ADDRESS: "test.local"}) diff --git a/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml b/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml new file mode 100644 index 00000000000..8482c4e2022 --- /dev/null +++ b/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml @@ -0,0 +1,5 @@ +# Exercises the hidden option in the single-network ssid/password shorthand. +wifi: + ssid: MyHiddenNetwork + password: password1 + hidden: true From c9ba6c355c92693e5790bdbc6d2461c094db499c Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:00:31 +0200 Subject: [PATCH 528/651] [hoermann_hcp] Add an actuator error binary sensor (#20299) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../hoermann_hcp/binary_sensor/__init__.py | 19 +++-- .../components/hoermann_hcp/hoermann_hcp.cpp | 20 +++++- .../components/hoermann_hcp/hoermann_hcp.h | 8 +++ .../hoermann_hcp_binary_sensor_test.cpp | 70 +++++++++++++++++++ tests/components/hoermann_hcp/common.yaml | 2 + 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py index 3de6a161e65..ec03e3256f6 100644 --- a/esphome/components/hoermann_hcp/binary_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -1,13 +1,18 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.const import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PROBLEM, + ENTITY_CATEGORY_DIAGNOSTIC, +) from esphome.types import ConfigType from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns DEPENDENCIES = ["hoermann_hcp"] +CONF_ACTUATOR_ERROR = "actuator_error" CONF_IS_CONNECTED = "is_connected" HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( @@ -23,14 +28,20 @@ CONFIG_SCHEMA = cv.All( device_class=DEVICE_CLASS_CONNECTIVITY, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend(cv.COMPONENT_SCHEMA), + cv.Optional(CONF_ACTUATOR_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), } ), - cv.has_at_least_one_key(CONF_IS_CONNECTED), + cv.has_at_least_one_key(CONF_IS_CONNECTED, CONF_ACTUATOR_ERROR), ) async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) if (conf := config.get(CONF_IS_CONNECTED)) is not None: - parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) - var = await binary_sensor.new_binary_sensor(conf, parent) + var = await binary_sensor.new_binary_sensor(conf, hub) await cg.register_component(var, conf) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_ACTUATOR_ERROR, hub.set_actuator_error_binary_sensor) diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index bbf1380d178..6c573f0a047 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -187,6 +187,9 @@ void HoermannHcp::dump_config() { "Hoermann HCP bridge:\n" " Modbus server address: 0x%02X", this->get_address()); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Actuator Error", this->actuator_error_binary_sensor_); +#endif #ifdef USE_HOERMANN_HCP_IDENTITY LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_); log_identity_value(this->serial_number_text_sensor_); @@ -551,10 +554,25 @@ void HoermannHcp::on_state_reg_(uint16_t value) { } // Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records -// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. In the high byte, Hoermann's own bus accessory +// treats either bit of 0x30 as an actuator error; what sets them is not known. void HoermannHcp::on_light_reg_(uint16_t value) { this->set_light_seen_(true); this->set_light_on_((value & 0x0010) != 0); + const bool actuator_error = (value & 0x3000) != 0; +#ifdef USE_BINARY_SENSOR + // Unknown until first reported, then kept like the other entities here while the bus is quiet. + if (this->actuator_error_binary_sensor_ != nullptr) + this->actuator_error_binary_sensor_->publish_state(actuator_error); +#endif + if (this->actuator_error_ == actuator_error) + return; + this->actuator_error_ = actuator_error; + if (actuator_error) { + ESP_LOGW(TAG, "Motor reports an actuator error (0x%04X)", value); + } else { + ESP_LOGI(TAG, "Actuator error cleared"); + } } bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index c16a0890ef9..2fca12f11b4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -6,6 +6,9 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_HOERMANN_HCP_IDENTITY #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -55,6 +58,9 @@ struct HoermannHcpCommand { }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(actuator_error) +#endif #ifdef USE_HOERMANN_HCP_IDENTITY // The motor is asked for these only when one of them is configured. SUB_TEXT_SENSOR(serial_number) @@ -186,6 +192,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool light_requested_{false}; bool light_command_sent_{false}; bool light_target_{false}; + // Last reported, kept across a quiet bus so a reconnect logs only a real change. + bool actuator_error_{false}; bool door_state_seen_{false}; bool short_broadcast_logged_{false}; // Only the read half right after a 0x17 write carries a command, so a second read without a new write does not. diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp index 6e9b5670805..d22d030770d 100644 --- a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -1,3 +1,5 @@ +#include + #include #include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" @@ -49,4 +51,72 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { EXPECT_EQ(publishes, 1); } +// Nothing has reported the flag yet, so the sensor stays unknown rather than claiming the motor is fine. +TEST(HoermannHcpActuatorErrorTest, UnknownUntilReported) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + connect_controller(door); + door.update(); + EXPECT_FALSE(sensor.has_state()); + // A broadcast without register 6 does not report it either. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + door.update(); + EXPECT_FALSE(sensor.has_state()); +} + +// Either bit of 0x30 in the high byte of register 6 is an error. Nothing else in the register is: 0x0200 is set on +// some installations, and the bits above 0x30 are not part of the flag. +TEST(HoermannHcpActuatorErrorTest, FollowsTheErrorBits) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + const std::pair cases[] = { + {0x0000, false}, {0x1000, true}, {0x0010, false}, {0x2000, true}, {0x0200, false}, + {0x3014, true}, {0x0214, false}, {0x4000, false}, {0x8000, false}, {0xC0FF, false}, + }; + for (const auto &[reg, error] : cases) { + door.on_write_registers(BROADCAST_REG, lamp_broadcast(reg)); + ASSERT_TRUE(sensor.has_state()) << std::hex << reg; + EXPECT_EQ(sensor.state, error) << std::hex << reg; + } +} + +// A quiet bus or a broadcast without register 6 keeps the last value, which the next report replaces. +TEST(HoermannHcpActuatorErrorTest, KeepsTheLastValueWhileNotReported) { + TestableHoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1000)); + ASSERT_TRUE(sensor.state); + door.set_valid_(false); + door.update(); + EXPECT_TRUE(sensor.has_state()); + EXPECT_TRUE(sensor.state); + + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + door.update(); + EXPECT_TRUE(sensor.state); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(sensor.state); +} + +// An unchanged flag is not reported again while the lamp next to it switches. +TEST(HoermannHcpActuatorErrorTest, LampChangeDoesNotRepublish) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + int publishes = 0; + sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1000)); + ASSERT_EQ(publishes, 1); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1010)); + EXPECT_EQ(publishes, 1); +} + } // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 21d3d2b8485..e663d5d3e45 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -11,6 +11,8 @@ binary_sensor: - platform: hoermann_hcp is_connected: name: Garage Connected + actuator_error: + name: Garage Actuator Error button: - platform: hoermann_hcp From 5759c6b8e6cce4a5542c27dc320abe56f407b8b0 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:25:40 -0500 Subject: [PATCH 529/651] [deep_sleep] Honor nested wakeup_pin_mode on ESP32 single-pin configs (#16618) Co-authored-by: J. Nick Koston --- esphome/components/deep_sleep/__init__.py | 17 ++++++++-- .../deep_sleep/test_deep_sleep.py | 32 +++++++++++++++++++ ...est_deep_sleep_nested_wakeup_pin_mode.yaml | 13 ++++++++ .../deep_sleep/validate.esp32-idf.yaml | 14 ++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml create mode 100644 tests/components/deep_sleep/validate.esp32-idf.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index d60a51c3cf7..c9a0ec2196f 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -155,6 +155,12 @@ def validate_wakeup_pin( return processed_pins +_WAKEUP_PIN_MODE_BOTH_LEVELS = ( + "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " + "or under the pin entry, not both" +) + + def validate_config(config: ConfigType) -> ConfigType: wakeup_pins = config.get(CONF_WAKEUP_PIN) has_pin_mode = CONF_WAKEUP_PIN_MODE in config @@ -170,6 +176,14 @@ def validate_config(config: ConfigType) -> ConfigType: "ESP32 does not support multiple entries for 'wakeup_pin'", path=[CONF_WAKEUP_PIN], ) + if wakeup_pins and CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): + if has_pin_mode: + raise cv.Invalid( + _WAKEUP_PIN_MODE_BOTH_LEVELS, + path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], + ) + # ESP32 has one global mode, which to_code() reads from the top level + config[CONF_WAKEUP_PIN_MODE] = pin.pop(CONF_WAKEUP_PIN_MODE) elif has_pin_mode: if multiple_pins: raise cv.Invalid( @@ -178,8 +192,7 @@ def validate_config(config: ConfigType) -> ConfigType: ) if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): raise cv.Invalid( - "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " - "or under the pin entry, not both", + _WAKEUP_PIN_MODE_BOTH_LEVELS, path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], ) # Move the global mode into the single pin entry, which to_code() reads diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index e05109f246a..4671efc7b9f 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -1,5 +1,8 @@ """Tests for the deep sleep component.""" +from collections.abc import Callable +from pathlib import Path + import pytest from esphome import config_validation as cv @@ -186,3 +189,32 @@ def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected( with pytest.raises(cv.Invalid, match="define it per pin") as exc: deep_sleep.validate_config(config) assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE] + + +def test_deep_sleep_esp32_nested_wakeup_pin_mode( + generate_main: Callable[[str | Path], str], +) -> None: + """On ESP32, a wakeup_pin_mode nested under the pin entry is applied, not dropped.""" + main_cpp = generate_main( + "tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml" + ) + + assert "deepsleep->set_wakeup_pin(" in main_cpp + assert "deepsleep->set_wakeup_pin_mode(" in main_cpp + assert "WAKEUP_PIN_MODE_KEEP_AWAKE" in main_cpp + + +def test_deep_sleep_esp32_wakeup_pin_mode_at_both_levels_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On ESP32, wakeup_pin_mode at the top level and under the pin entry is an error.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = { + CONF_WAKEUP_PIN: [ + {"pin": "GPIO4", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + ], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + with pytest.raises(cv.Invalid, match="not both") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [CONF_WAKEUP_PIN, 0, deep_sleep.CONF_WAKEUP_PIN_MODE] diff --git a/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml b/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml new file mode 100644 index 00000000000..4bab75ea40f --- /dev/null +++ b/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +deep_sleep: + id: deepsleep + sleep_duration: 1min + wakeup_pin: + - pin: GPIO4 + wakeup_pin_mode: KEEP_AWAKE diff --git a/tests/components/deep_sleep/validate.esp32-idf.yaml b/tests/components/deep_sleep/validate.esp32-idf.yaml new file mode 100644 index 00000000000..3488914c074 --- /dev/null +++ b/tests/components/deep_sleep/validate.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + wakeup_pin: GPIO4 + +packages: + deep_sleep: !include common.yaml + +# Nested per-pin wakeup_pin_mode on ESP32 is hoisted to the top level +deep_sleep: + id: deep_sleep_1 + run_duration: 10s + sleep_duration: 50s + wakeup_pin: + - pin: ${wakeup_pin} + wakeup_pin_mode: KEEP_AWAKE From 325c747dcd19fad67b2bf62fd74bec7e1356f82b Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:41:13 -0500 Subject: [PATCH 530/651] [sen6x] Add CO2 configuration options (#18780) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 143 +++++++++++++++++++++++------ esphome/components/sen6x/sen6x.h | 29 ++++++ esphome/components/sen6x/sensor.py | 32 +++++++ tests/components/sen6x/common.yaml | 8 ++ 4 files changed, 183 insertions(+), 29 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 5087b112a8d..dabf16d760b 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -22,6 +22,9 @@ static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; static constexpr uint32_t FAN_CLEANING_DELAY = 10000; static constexpr uint32_t SHT_HEATER_DELAY = 20000; static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +// Accepted range of a runtime pressure source, the same as the schema's ambient_pressure_compensation +static constexpr float AMBIENT_PRESSURE_MIN_HPA = 700.0f; +static constexpr float AMBIENT_PRESSURE_MAX_HPA = 1200.0f; static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; @@ -42,6 +45,9 @@ static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static constexpr uint16_t SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL = 0x6711; +static constexpr uint16_t SEN6X_CMD_AMBIENT_PRESSURE = 0x6720; +static constexpr uint16_t SEN6X_CMD_SENSOR_ALTITUDE = 0x6736; static inline void set_read_command_and_words(SEN6XComponent::Sen6xType type, uint16_t &read_cmd, uint8_t &read_words) { read_cmd = SEN6X_CMD_READ_MEASUREMENT; @@ -168,32 +174,54 @@ void SEN6XComponent::setup() { }); } -// One configuration write per invocation, spaced by CMD_EXEC_DELAY. Cases without a -// configured value fall through; each taken case must advance setup_step_index_ so the -// next invocation resumes at the following step. These writes are optional, so a failure -// only warns and the chain continues to the mandatory start-measurements write. +// One configuration write per invocation, spaced by CMD_EXEC_DELAY, then the start command. +// These writes are optional, so a failure only warns and the chain carries on. void SEN6XComponent::run_next_setup_step_() { - switch (this->setup_step_index_) { - // Tuning writes are skipped when setup() disabled the sensor for this variant - case 0: - this->setup_step_index_++; - if (this->voc_sensor_ != nullptr && this->voc_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); - break; - } - [[fallthrough]]; - case 1: - this->setup_step_index_++; - if (this->nox_sensor_ != nullptr && this->nox_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); - break; - } - [[fallthrough]]; - default: - this->finish_setup_(); + while (this->setup_step_index_ < SETUP_STEP_COUNT) { + if (this->write_setup_step_(static_cast(this->setup_step_index_++))) { + this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); return; + } } - this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); + this->finish_setup_(); +} + +// Returns false when the step has nothing to write: the setting is not configured, or setup() +// disabled its sensor for this variant +bool SEN6XComponent::write_setup_step_(SetupStep step) { + switch (step) { + case SETUP_STEP_VOC_TUNING: + if (this->voc_sensor_ == nullptr || !this->voc_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); + return true; + case SETUP_STEP_NOX_TUNING: + if (this->nox_sensor_ == nullptr || !this->nox_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); + return true; + case SETUP_STEP_CO2_ASC: + if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) + return false; + this->write_setup_register_(SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL, this->co2_asc_.value() ? 1 : 0); + return true; + case SETUP_STEP_ALTITUDE: + if (this->co2_sensor_ == nullptr || this->altitude_compensation_ == ALTITUDE_UNSET) + return false; + this->write_setup_register_(SEN6X_CMD_SENSOR_ALTITUDE, this->altitude_compensation_); + return true; + case SETUP_STEP_AMBIENT_PRESSURE: + if (this->co2_sensor_ == nullptr || this->ambient_pressure_ == PRESSURE_UNSET) + return false; + this->write_ambient_pressure_(this->ambient_pressure_); + return true; + default: + return false; + } +} + +bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { + return this->write_config_words_(i2c_command, &value, 1); } void SEN6XComponent::finish_setup_() { @@ -208,14 +236,15 @@ void SEN6XComponent::finish_setup_() { ESP_LOGD(TAG, "Initialized"); } -// Writes one optional configuration command. A failure warns and returns false, but does -// not stop setup: the sensor still measures with that setting left at its default. +// Writes one optional configuration command and reserves its execution time. A failure warns and +// returns false, but does not stop setup: the sensor still measures with that setting left at its default. bool SEN6XComponent::write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len) { if (!this->write_command(i2c_command, data, len)) { ESP_LOGE(TAG, "Write 0x%04X failed, error %d", i2c_command, this->last_error_); this->status_set_warning(); return false; } + this->set_command_wait_(CMD_EXEC_DELAY); return true; } @@ -239,6 +268,22 @@ void SEN6XComponent::dump_config() { this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, this->firmware_version_minor_, this->address_); LOG_UPDATE_INTERVAL(this); + // setup() clears co2_sensor_ once it detects a variant without CO2 + if (this->co2_sensor_ != nullptr) { + if (this->co2_asc_.has_value()) { + ESP_LOGCONFIG(TAG, " CO2 automatic self-calibration: %s", ONOFF(this->co2_asc_.value())); + } + if (this->altitude_compensation_ != ALTITUDE_UNSET) { + ESP_LOGCONFIG(TAG, " Altitude compensation: %u m", this->altitude_compensation_); + } + if (this->ambient_pressure_ != PRESSURE_UNSET) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation: %u hPa", this->ambient_pressure_); + } + if (this->ambient_pressure_source_ != nullptr) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation source: %s", + this->ambient_pressure_source_->get_name().c_str()); + } + } LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); @@ -255,9 +300,17 @@ void SEN6XComponent::update() { if (!this->initialized_ || !this->measuring_) { return; } - - // Cancel any in-flight polling from a previous update() cycle. + // Cancel any in-flight polling from a previous update() cycle before touching the bus. this->cancel_timeout(TIMEOUT_POLL); + // A command is still executing (an action moments ago): run this cycle once its window has passed + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->update(); }); + return; + } + + if (this->ambient_pressure_source_ != nullptr && this->co2_sensor_ != nullptr) { + this->update_ambient_pressure_compensation_(this->ambient_pressure_source_->state); + } set_read_command_and_words(this->sen6x_type_, this->read_cmd_, this->read_words_); @@ -286,6 +339,11 @@ void SEN6XComponent::poll_data_ready_() { ESP_LOGD(TAG, "Data not ready"); return; } + // A set command (the pressure write above all) gets its execution time before the chain writes again + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->poll_data_ready_(); }); + return; + } ESP_LOGV(TAG, "Data ready polling attempt %u", static_cast(POLL_RETRIES - this->poll_retries_remaining_ + 1)); this->poll_retries_remaining_--; @@ -454,8 +512,35 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } -// True while the previous command's execution or settling time has not elapsed -bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } +void SEN6XComponent::write_ambient_pressure_(uint16_t pressure_hpa) { + if (this->write_setup_register_(SEN6X_CMD_AMBIENT_PRESSURE, pressure_hpa)) + this->last_ambient_pressure_ = pressure_hpa; +} + +void SEN6XComponent::update_ambient_pressure_compensation_(float pressure_hpa) { + if (std::isnan(pressure_hpa)) + return; + // Range-check before narrowing so out-of-unit sources (e.g. Pa) can't wrap into range + if (pressure_hpa < AMBIENT_PRESSURE_MIN_HPA || pressure_hpa > AMBIENT_PRESSURE_MAX_HPA) { + if (!this->pressure_range_warned_) { + ESP_LOGW(TAG, "Ambient pressure out of range: %.0f hPa", pressure_hpa); + this->pressure_range_warned_ = true; + } + return; + } + this->pressure_range_warned_ = false; + const auto value = static_cast(lroundf(pressure_hpa)); + if (value != this->last_ambient_pressure_) + this->write_ambient_pressure_(value); +} + +// Time left in the previous command's execution or settling window, 0 once it has passed +uint32_t SEN6XComponent::command_wait_remaining_() const { + const uint32_t elapsed = millis() - this->command_started_at_; + return elapsed < this->command_wait_ ? this->command_wait_ - elapsed : 0; +} + +bool SEN6XComponent::command_blocked_() const { return this->command_wait_remaining_() != 0; } void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { this->command_started_at_ = millis(); diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 588a466d714..43d0ce00120 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,10 @@ struct GasTuning { uint16_t gain_factor; }; +// The CO2 settings a config may leave out: altitude is 0 to 3000 m, pressure 700 to 1200 hPa +inline constexpr uint16_t ALTITUDE_UNSET = 0xFFFF; +inline constexpr uint16_t PRESSURE_UNSET = 0; + class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) @@ -58,17 +62,35 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } + void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } + void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } + void set_ambient_pressure_source(sensor::Sensor *pressure) { this->ambient_pressure_source_ = pressure; } void start_measurement(); void stop_measurement(); void start_fan_cleaning(); void activate_sht_heater(); protected: + enum SetupStep : uint8_t { + SETUP_STEP_VOC_TUNING, + SETUP_STEP_NOX_TUNING, + SETUP_STEP_CO2_ASC, + SETUP_STEP_ALTITUDE, + SETUP_STEP_AMBIENT_PRESSURE, + SETUP_STEP_COUNT, + }; + Sen6xType infer_type_from_product_name_(const std::string &product_name); void run_next_setup_step_(); + bool write_setup_step_(SetupStep step); void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool write_setup_register_(uint16_t i2c_command, uint16_t value); + void write_ambient_pressure_(uint16_t pressure_hpa); + void update_ambient_pressure_compensation_(float pressure_hpa); + uint32_t command_wait_remaining_() const; bool command_blocked_() const; void set_command_wait_(uint32_t wait_ms); void on_measurement_started_(); @@ -80,6 +102,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + sensor::Sensor *ambient_pressure_source_{nullptr}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap uint32_t command_started_at_{0}; @@ -87,12 +110,18 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint32_t co2_started_at_{0}; uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; + uint16_t altitude_compensation_{ALTITUDE_UNSET}; + uint16_t ambient_pressure_{PRESSURE_UNSET}; + // Last pressure written to the device, used to skip redundant writes + uint16_t last_ambient_pressure_{PRESSURE_UNSET}; + optional co2_asc_; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; uint8_t firmware_version_minor_{0}; uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool pressure_range_warned_{false}; bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 9893ef80233..b219caafa09 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -6,6 +6,10 @@ from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, + CONF_ALTITUDE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, + CONF_AUTOMATIC_SELF_CALIBRATION, CONF_CO2, CONF_FORMALDEHYDE, CONF_GAIN_FACTOR, @@ -155,6 +159,22 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, device_class=DEVICE_CLASS_CARBON_DIOXIDE, state_class=STATE_CLASS_MEASUREMENT, + ).extend( + { + cv.Optional(CONF_AUTOMATIC_SELF_CALIBRATION): cv.boolean, + cv.Optional(CONF_ALTITUDE_COMPENSATION): cv.All( + cv.float_with_unit( + "altitude", "(m|m a.s.l.|MAMSL|MASL)", optional_unit=True + ), + cv.int_range(min=0, max=3000), + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.int_range( + min=700, max=1200 + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( + sensor.Sensor + ), + } ), cv.Optional(CONF_FORMALDEHYDE): sensor.sensor_schema( unit_of_measurement="ppb", @@ -209,6 +229,18 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + if co2_cfg := config.get(CONF_CO2): + if (asc := co2_cfg.get(CONF_AUTOMATIC_SELF_CALIBRATION)) is not None: + cg.add(var.set_automatic_self_calibration(asc)) + if (altitude := co2_cfg.get(CONF_ALTITUDE_COMPENSATION)) is not None: + cg.add(var.set_altitude_compensation(altitude)) + if (pressure := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION)) is not None: + cg.add(var.set_ambient_pressure_compensation(pressure)) + if ( + source := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE) + ) is not None: + sens = await cg.get_variable(source) + cg.add(var.set_ambient_pressure_source(sens)) SEN6X_ACTION_SCHEMA = maybe_simple_id( diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 6ab80a76b21..eed0dd51d67 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -1,4 +1,8 @@ sensor: + - platform: template + id: pressure_source + name: Pressure Source + unit_of_measurement: hPa # Test with explicit type parameter - platform: sen6x id: sen6x_sensor @@ -45,6 +49,10 @@ sensor: gain_factor: 180 co2: name: Carbon Dioxide + automatic_self_calibration: true + altitude_compensation: 100 + ambient_pressure_compensation: 1013 + ambient_pressure_compensation_source: pressure_source formaldehyde: name: Formaldehyde address: 0x6B From ae6296019303e7492a0f584cf5c011241e62cf2e Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 17 Oct 2026 15:51:07 -0500 Subject: [PATCH 531/651] [epaper_spi] Add the UC8279 controller and the Xteink X4 Pro (#20545) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/display.py | 14 +- esphome/components/epaper_spi/epaper_spi.cpp | 9 +- esphome/components/epaper_spi/epaper_spi.h | 8 + .../epaper_spi/epaper_spi_uc8179.cpp | 37 +-- .../components/epaper_spi/epaper_spi_uc8179.h | 10 +- .../epaper_spi/epaper_spi_uc8279.cpp | 144 +++++++++++ .../components/epaper_spi/epaper_spi_uc8279.h | 46 ++++ .../epaper_spi/epaper_spi_ultrachip.cpp | 33 +++ .../epaper_spi/epaper_spi_ultrachip.h | 43 ++++ .../components/epaper_spi/models/__init__.py | 14 +- .../components/epaper_spi/models/uc8179.py | 10 +- .../components/epaper_spi/models/uc8279.py | 74 ++++++ .../epaper_spi/config/uc8279_x4pro_test.yaml | 15 ++ tests/component_tests/epaper_spi/test_init.py | 141 ++++++++++- .../display/test_uc8279_transfer.cpp | 237 ++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 24 ++ 16 files changed, 811 insertions(+), 48 deletions(-) create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8279.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8279.h create mode 100644 esphome/components/epaper_spi/epaper_spi_ultrachip.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_ultrachip.h create mode 100644 esphome/components/epaper_spi/models/uc8279.py create mode 100644 tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml create mode 100644 tests/components/epaper_spi/display/test_uc8279_transfer.cpp diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index d04ffd33be8..6a391811112 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -30,6 +30,7 @@ from esphome.const import ( CONF_MIRROR_X, CONF_MIRROR_Y, CONF_MODEL, + CONF_OFFSET_HEIGHT, CONF_PAGES, CONF_RESET_DURATION, CONF_RESET_PIN, @@ -73,12 +74,17 @@ for module_info in pkgutil.iter_modules(models.__path__): MODELS = models.EpaperModel.models -DIMENSION_SCHEMA = cv.Schema( - { + +def dimension_schema(model: models.EpaperModel) -> cv.Schema: + schema = { cv.Required(CONF_WIDTH): cv.int_, cv.Required(CONF_HEIGHT): cv.int_, } -) + # Only models with an offset_height default use it, and the default must survive explicit dimensions + if (offset_height := model.get_default(CONF_OFFSET_HEIGHT, None)) is not None: + schema[cv.Optional(CONF_OFFSET_HEIGHT, default=offset_height)] = cv.positive_int + return cv.Schema(schema) + TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} @@ -135,7 +141,7 @@ def model_schema(config): model.option(CONF_RESET_PIN): pins.gpio_output_pin_schema, cv.GenerateID(): cv.declare_id(class_name), cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8), - cv_dimensions(CONF_DIMENSIONS): DIMENSION_SCHEMA, + cv_dimensions(CONF_DIMENSIONS): dimension_schema(model), model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema), model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list( map_sequence diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index e95d2fd2869..71e87e038b6 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -227,10 +227,12 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::IDLE); return; } + // A full update resets update_count_ to zero, so a non-zero count marks a partial one + this->partial_ = this->update_count_ != 0; this->set_state_(EPaperState::RESET); break; case EPaperState::INITIALISE: - if (!this->initialise(this->update_count_ != 0)) { + if (!this->initialise(this->partial_)) { return; // Not done yet, come back next loop } this->set_state_(EPaperState::TRANSFER_DATA); @@ -250,11 +252,14 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::REFRESH_SCREEN); break; case EPaperState::REFRESH_SCREEN: - this->refresh_screen(this->update_count_ != 0); + this->refresh_screen(this->partial_); this->update_count_ = (this->update_count_ + 1) % this->full_update_every_; this->set_state_(EPaperState::POWER_OFF); break; case EPaperState::POWER_OFF: + if (!this->after_refresh()) { + return; // Not done yet, come back next loop + } this->power_off(); this->set_state_(EPaperState::DEEP_SLEEP); break; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 046df97cccd..d953bf3df81 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -167,6 +167,13 @@ class EPaperBase : public Display, */ virtual void power_off() = 0; + /** + * Called after the refresh, before power off, repeatedly until it returns true. + * Lets a driver do extra work spread over several loops, such as rewriting the previous image. + * @return true when done, false to be called again next loop + */ + virtual bool after_refresh() { return true; } + /** * Place the display into deep sleep */ @@ -201,6 +208,7 @@ class EPaperBase : public Display, uint8_t effective_transform_{}; uint8_t update_count_{}; bool full_update_requested_{}; + bool partial_{}; // the update in progress is a partial one; set when UPDATE moves to RESET // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp index 328cd833299..60cc80e91bd 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp @@ -10,12 +10,11 @@ ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8179"); bool EPaperUC8179::initialise(bool partial) { EPaperBase::initialise(partial); // send the model init sequence - this->partial_ = partial; ESP_LOGV(TAG, "Power on"); // POWER ON must precede the waveform/mode registers and the data transfer // (the original driver powers on and busy-waits before writing them). // The state machine busy-waits before entering TRANSFER_DATA. - this->command(0x04); + this->command(CMD_POWER_ON); // Give the busy line time to assert before the state machine polls it this->next_delay_ = 100; return true; @@ -27,21 +26,17 @@ void EPaperUC8179::set_refresh_mode_() { return; // plain full refresh uses the mode set by the init sequence } // Fast and partial refresh use flipped data polarity and a floating border - this->cmd_data(0x50, {0xA9, 0x07}); + this->cmd_data(CMD_CDI, {0xA9, 0x07}); // Force the waveform via the temperature registers: 0x5A selects the fast // full-refresh waveform, 0x6E the partial-refresh waveform - this->cmd_data(0xE0, {0x02}); + this->cmd_data(CMD_CCSET, {0x02}); if (this->partial_) { - this->cmd_data(0xE5, {0x6E}); - this->command(0x91); // enter partial mode - // Set the partial window to the full screen - const uint16_t x_end = this->width_ - 1; - const uint16_t y_end = this->height_ - 1; - this->cmd_data(0x90, {0x00, 0x00, static_cast(x_end >> 8), static_cast(x_end & 0xFF), 0x00, 0x00, - static_cast(y_end >> 8), static_cast(y_end & 0xFF), 0x01}); + this->cmd_data(CMD_TSSET, {0x6E}); + // Partial mode, with the window set to the full screen + this->set_partial_window_(0, this->width_ - 1, 0, this->height_ - 1); } else { - this->cmd_data(0xE5, {0x5A}); - this->command(0x92); // exit partial mode + this->cmd_data(CMD_TSSET, {0x5A}); + this->command(CMD_PARTIAL_OUT); // exit partial mode } } @@ -63,7 +58,7 @@ bool HOT EPaperUC8179::transfer_data() { // Phase 1 (fast full refresh only): previous image via 0x10 (DTM1), inverse of the new image if (two_pass && this->current_data_index_ < buffer_length) { if (this->current_data_index_ == 0) { - this->command(0x10); // DATA START TRANSMISSION 1 (previous image) + this->command(CMD_DTM1); // DATA START TRANSMISSION 1 (previous image) } this->start_data_(); while (this->current_data_index_ < buffer_length) { @@ -86,7 +81,7 @@ bool HOT EPaperUC8179::transfer_data() { const size_t total = offset + buffer_length; if (this->current_data_index_ < total) { if (this->current_data_index_ == offset) { - this->command(0x13); // DATA START TRANSMISSION 2 (new image) + this->command(CMD_DTM2); // DATA START TRANSMISSION 2 (new image) } this->start_data_(); while (this->current_data_index_ < total) { @@ -117,7 +112,7 @@ void EPaperUC8179::power_on() { void EPaperUC8179::refresh_screen(bool /*partial*/) { ESP_LOGV(TAG, "Refresh"); - this->command(0x12); // DISPLAY REFRESH + this->command(CMD_REFRESH); // DISPLAY REFRESH // Delay the next busy poll: the busy line takes a short time to assert after // the refresh command, and polling too early would read it as already idle this->next_delay_ = 100; @@ -125,15 +120,7 @@ void EPaperUC8179::refresh_screen(bool /*partial*/) { void EPaperUC8179::power_off() { ESP_LOGV(TAG, "Power off"); - this->command(0x02); // POWER OFF -} - -void EPaperUC8179::deep_sleep() { - // Deep sleep loses the previous-image RAM that partial refresh compares against - if (!this->is_using_partial_update_()) { - ESP_LOGV(TAG, "Deep sleep"); - this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code - } + this->command(CMD_POWER_OFF); // POWER OFF } } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index fda4297802b..5d85c6bd04a 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -1,6 +1,6 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_ultrachip.h" namespace esphome::epaper_spi { @@ -28,11 +28,11 @@ namespace esphome::epaper_spi { * waveform is forced, partial mode is entered with a full-screen window and * only DTM2 is sent; the controller compares against its previous-image RAM. */ -class EPaperUC8179 final : public EPaperBase { +class EPaperUC8179 final : public EPaperUltraChip { public: EPaperUC8179(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} protected: bool initialise(bool partial) override; @@ -40,11 +40,7 @@ class EPaperUC8179 final : public EPaperBase { void refresh_screen(bool partial) override; void power_on() override; void power_off() override; - void deep_sleep() override; void set_refresh_mode_(); - - // Set by initialise() so transfer_data() knows which planes to send - bool partial_{}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.cpp b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp new file mode 100644 index 00000000000..096b2e54ae7 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp @@ -0,0 +1,144 @@ +#include "epaper_spi_uc8279.h" + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8279"); + +static constexpr uint8_t CMD_PSR = 0x00; +static constexpr uint8_t CMD_PFS = 0x03; +static constexpr uint8_t CMD_GATE_SCAN = 0xE1; + +static constexpr uint8_t PSR0_OTP_LUT = 0x17; +static constexpr uint8_t PSR1 = 0x4D; +static constexpr uint8_t PFS = 0x20; +static constexpr uint8_t GATE_SCAN = 0x02; +static constexpr uint8_t CCSET = 0x02; +static constexpr uint8_t TSSET_FULL = 0x1E; +static constexpr uint8_t TSSET_PARTIAL = 0x5A; +static constexpr uint8_t CDI_FULL = 0x97; +static constexpr uint8_t CDI_PARTIAL = 0xD7; + +static constexpr uint16_t RESET_SETTLE_MS = 50; +static constexpr uint16_t BUSY_ASSERT_MS = 10; +static constexpr uint16_t REFRESH_START_MS = 50; + +bool EPaperUC8279::reset() { + // A partial update follows one that left the controller configured, in standby, with DTM1 holding the image + if (this->partial_) + return true; + if (!EPaperBase::reset()) + return false; + this->next_delay_ = RESET_SETTLE_MS; + return true; +} + +bool EPaperUC8279::initialise(bool partial) { + if (partial) + return true; + return EPaperBase::initialise(partial); +} + +void EPaperUC8279::write_gate_(size_t gate, bool white) { + const size_t row_width = this->row_width_; + if (white || gate < this->offset_height_) { + uint8_t white_bytes[MAX_TRANSFER_SIZE]; + memset(white_bytes, 0xFF, sizeof(white_bytes)); + for (size_t written = 0; written != row_width;) { + const size_t length = std::min(row_width - written, MAX_TRANSFER_SIZE); + this->write_array(white_bytes, length); + written += length; + } + return; + } + size_t index = (gate - this->offset_height_) * row_width; + for (size_t remaining = row_width; remaining != 0;) { + size_t length; + const uint8_t *data = this->buffer_.get_span(index, length); + length = std::min(length, remaining); + this->write_array(data, length); + index += length; + remaining -= length; + } +} + +// One plane of one gate's row per step, time-sliced; `base` is the value of current_data_index_ at its first gate +bool HOT EPaperUC8279::write_plane_(uint8_t command, bool white, size_t base) { + const uint32_t start_time = millis(); + const size_t gates = this->gate_count_(); + if (this->current_data_index_ == base) + this->command(command); + this->start_data_(); + while (this->current_data_index_ - base != gates) { + this->write_gate_(this->current_data_index_ - base, white); + ++this->current_data_index_; + if (this->current_data_index_ - base != gates && millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + return true; +} + +// DTM2 gets the new image; a full refresh also writes a white DTM1, so every pixel transitions +bool HOT EPaperUC8279::transfer_data() { + const size_t gates = this->gate_count_(); + if (this->current_data_index_ < gates) { + if (!this->write_plane_(CMD_DTM2, false, 0)) + return false; + // Start DTM1 on the next pass, so this one stays within its time slice + if (!this->partial_) + return false; + } + if (!this->partial_ && !this->write_plane_(CMD_DTM1, true, gates)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_on() { + ESP_LOGV(TAG, "Power on"); + this->cmd_data(CMD_CDI, {this->partial_ ? CDI_PARTIAL : CDI_FULL}); + this->cmd_data(CMD_CCSET, {CCSET}); + this->cmd_data(CMD_TSSET, {this->partial_ ? TSSET_PARTIAL : TSSET_FULL}); + if (this->partial_) { + this->cmd_data(CMD_PFS, {PFS}); + this->cmd_data(CMD_GATE_SCAN, {GATE_SCAN}); + } + this->command(CMD_POWER_ON); + this->next_delay_ = BUSY_ASSERT_MS; +} + +void EPaperUC8279::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen (%s)", partial ? LOG_STR_LITERAL("partial") : LOG_STR_LITERAL("full")); + if (partial) + this->set_partial_window_(0, this->width_ - 1, this->offset_height_, this->gate_count_() - 1); + // Power-on reloads the panel's stored settings, so the panel setting only takes when written after it + this->cmd_data(CMD_PSR, {PSR0_OTP_LUT, PSR1}); + this->command(CMD_REFRESH); + this->next_delay_ = REFRESH_START_MS; +} + +bool HOT EPaperUC8279::after_refresh() { + // While partial updates are in use, DTM1 holds the image on the panel for the next partial comparison + if (!this->is_using_partial_update_()) + return true; + if (this->current_data_index_ == 0 && this->partial_) + this->command(CMD_PARTIAL_OUT); + if (!this->write_plane_(CMD_DTM1, false, 0)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_off() { + this->command(CMD_POWER_OFF); + this->next_delay_ = BUSY_ASSERT_MS; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.h b/esphome/components/epaper_spi/epaper_spi_uc8279.h new file mode 100644 index 00000000000..fb430855516 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.h @@ -0,0 +1,46 @@ +#pragma once + +#include "epaper_spi_ultrachip.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome e-paper displays using the UltraChip UC8279 controller, such as the Xteink X4 Pro. + * + * Buffer layout: 1 bit per pixel, 1=white, 0=black (the base class default). + * + * The controller scans offset_height + height gates; the panel is bonded to the last height of them, + * so every image plane is written for all gates, white outside the panel. + * + * A full refresh writes the new image to DTM2 and white to DTM1. A partial refresh writes only DTM2 + * and drives the pixels that differ from DTM1, which therefore has to hold the image on the panel: + * while partial updates are in use it is rewritten after every refresh, in time slices like the + * image transfer. The panel loses both planes in its own deep sleep, so it then waits in standby + * between updates instead. + */ +class EPaperUC8279 : public EPaperUltraChip { + public: + EPaperUC8279(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, uint16_t offset_height) + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY), + offset_height_(offset_height) {} + + protected: + bool reset() override; + bool initialise(bool partial) override; + bool transfer_data() override; + void power_on() override; + void refresh_screen(bool partial) override; + void power_off() override; + bool after_refresh() override; + + uint16_t gate_count_() const { return this->offset_height_ + this->height_; } + // Writes one gate's row of the image, or white for a gate outside the panel or when white is true + void write_gate_(size_t gate, bool white); + // Writes one plane in time slices, sending `command` at its first gate; true once all gates are written + bool write_plane_(uint8_t command, bool white, size_t base); + + uint16_t offset_height_; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp new file mode 100644 index 00000000000..d34e022380e --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp @@ -0,0 +1,33 @@ +#include "epaper_spi_ultrachip.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +ESPHOME_LOG_TAG(TAG, "epaper_spi.ultrachip"); + +static constexpr uint8_t hi(uint16_t v) { return static_cast(v >> 8); } +static constexpr uint8_t lo(uint16_t v) { return static_cast(v & 0xFF); } + +void EPaperUltraChip::deep_sleep() { + // Partial updates compare against the previous image, which deep sleep would lose; standby keeps it + if (this->is_using_partial_update_()) + return; + this->send_deep_sleep_(); +} + +void EPaperUltraChip::send_deep_sleep_() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(CMD_DEEP_SLEEP, {DEEP_SLEEP_CHECK}); +} + +void EPaperUltraChip::on_safe_shutdown() { this->send_deep_sleep_(); } + +void EPaperUltraChip::set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end) { + this->command(CMD_PARTIAL_IN); + this->cmd_data(CMD_PARTIAL_WINDOW, + {hi(x_start), static_cast(lo(x_start) & 0xF8), hi(x_end), + static_cast(lo(x_end) | 0x07), hi(y_start), lo(y_start), hi(y_end), lo(y_end), 0x01}); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.h b/esphome/components/epaper_spi/epaper_spi_ultrachip.h new file mode 100644 index 00000000000..4dfd732fa24 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.h @@ -0,0 +1,43 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Shared base for the UltraChip UC81xx mono controllers (UC8179, UC8279). + * + * They use the same command set and the same deep-sleep and partial-window handling; the + * concrete drivers differ only in how each refresh is configured and which planes are sent. + */ +class EPaperUltraChip : public EPaperBase { + public: + using EPaperBase::EPaperBase; + + // Nothing carries the image across a restart or the controller's deep sleep, so sleep the panel rather than + // leaving it waiting in standby + void on_safe_shutdown() override; + + protected: + static constexpr uint8_t CMD_POWER_OFF = 0x02; + static constexpr uint8_t CMD_POWER_ON = 0x04; + static constexpr uint8_t CMD_DEEP_SLEEP = 0x07; + static constexpr uint8_t CMD_DTM1 = 0x10; + static constexpr uint8_t CMD_REFRESH = 0x12; + static constexpr uint8_t CMD_DTM2 = 0x13; + static constexpr uint8_t CMD_CDI = 0x50; + static constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90; + static constexpr uint8_t CMD_PARTIAL_IN = 0x91; + static constexpr uint8_t CMD_PARTIAL_OUT = 0x92; + static constexpr uint8_t CMD_CCSET = 0xE0; + static constexpr uint8_t CMD_TSSET = 0xE5; + static constexpr uint8_t DEEP_SLEEP_CHECK = 0xA5; + + void deep_sleep() override; + // Sends the deep-sleep command unconditionally; deep_sleep() skips it while partial updates are in use + void send_deep_sleep_(); + // Enters partial mode and sets the refresh window; x is rounded out to whole bytes + void set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end); +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index fbedd6371d1..ba8ebb6c873 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -1,12 +1,17 @@ from typing import Any, Self import esphome.config_validation as cv -from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType +def ultrachip_resolution(width: int, gates: int) -> tuple: + """The UC81xx resolution command (0x61): source (width) and gate counts, each big-endian.""" + return (0x61, width >> 8, width & 0xFF, gates >> 8, gates & 0xFF) + + class EpaperModel: models: dict[str, Self] = {} @@ -86,6 +91,13 @@ class EpaperModel: height = self.get_default(CONF_HEIGHT) return width, height + def get_offset_height(self, config) -> int: + # The panel's first row within the controller's addressable area; from explicit dimensions or the model default + dimensions = config.get(CONF_DIMENSIONS, {}) + return dimensions.get( + CONF_OFFSET_HEIGHT, self.get_default(CONF_OFFSET_HEIGHT, 0) + ) + def extend(self, name, **kwargs) -> "EpaperModel": """ Extend the current model with additional parameters or a modified init sequence. diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index 91c649808a8..84b736081fb 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -18,7 +18,7 @@ from typing import Any from esphome.const import CONF_DATA_RATE -from . import EpaperModel +from . import EpaperModel, ultrachip_resolution class UC8179(EpaperModel): @@ -51,13 +51,7 @@ class UC8179(EpaperModel): # PANEL SETTING (black/white mode, LUT from OTP) (0x00, 0x1F), # RESOLUTION SETTING (width x height) - ( - 0x61, - (width >> 8) & 0xFF, - width & 0xFF, - (height >> 8) & 0xFF, - height & 0xFF, - ), + ultrachip_resolution(width, height), # DUAL SPI MODE (disabled) (0x15, 0x00), # VCOM AND DATA INTERVAL SETTING diff --git a/esphome/components/epaper_spi/models/uc8279.py b/esphome/components/epaper_spi/models/uc8279.py new file mode 100644 index 00000000000..deb487883de --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8279.py @@ -0,0 +1,74 @@ +"""Monochrome e-paper displays using the UltraChip UC8279 controller. + +Supported models: +- uc8279: the controller on its own; dimensions and pins must be configured +- xteink-x4pro-uc8279: the Xteink X4 Pro e-reader (ESP32-S3) with its 800x480 UC8279 panel; + the X4 Pro also ships with other panel controllers + +The controller scans offset_height + height gates, and the panel is bonded to the last +height of them; offset_height (under dimensions) is a property of the panel. +""" + +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DATA_RATE, CONF_DIMENSIONS, CONF_OFFSET_HEIGHT +from esphome.types import ConfigType + +from . import EpaperModel, ultrachip_resolution + + +class UC8279(EpaperModel): + def __init__( + self, + name: str, + class_name: str = "EPaperUC8279", + data_rate: str = "10MHz", + **defaults: Any, + ) -> None: + defaults.setdefault(CONF_DATA_RATE, data_rate) + defaults.setdefault("partial_update", True) + defaults.setdefault(CONF_OFFSET_HEIGHT, 0) + defaults.setdefault("width_multiple", 8) + super().__init__(name, class_name=class_name, **defaults) + + def validate_config(self, config: ConfigType) -> ConfigType: + _width, height = self.get_dimensions(config) + if self.get_offset_height(config) + height > 0xFFFF: + raise cv.Invalid( + f"{CONF_OFFSET_HEIGHT} plus the panel height must not exceed {0xFFFF} gates", + path=[CONF_DIMENSIONS, CONF_OFFSET_HEIGHT], + ) + return config + + def get_constructor_args(self, config) -> tuple: + return (self.get_offset_height(config),) + + # fmt: off + def get_init_sequence(self, config: dict) -> tuple: + width, height = self.get_dimensions(config) + gates = self.get_offset_height(config) + height + return ( + (0x00, 0x37, 0x4D), # panel setting; the driver switches it to the OTP LUT before each refresh + ultrachip_resolution(width, gates), # resolution: sources, gates + (0x65, 0x00, 0x00, 0x00, 0x00), # gate and source start + (0x03, 0x20), # power off sequence + (0x30, 0x0E), # PLL + (0xE1, 0x02), # gate scan + ) + # fmt: on + + +uc8279 = UC8279("uc8279") + +uc8279.extend( + "xteink-x4pro-uc8279", + width=800, + height=480, + offset_height=120, + cs_pin=13, + dc_pin=18, + reset_pin=14, + busy_pin={"number": 6, "inverted": True}, + reset_duration="50ms", +) diff --git a/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml new file mode 100644 index 00000000000..34b55ab4456 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +spi: + clk_pin: GPIO12 + mosi_pin: GPIO11 + +display: + - platform: epaper_spi + id: epaper_display + model: xteink-x4pro-uc8279 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 81e197abf81..644a505bf82 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -13,7 +13,12 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, @@ -516,6 +521,140 @@ def test_uc8179_e1001_code_generation( assert "97, 4, 3, 32, 1, 224" in main_cpp +def test_uc8279_width_must_be_a_multiple_of_8( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The UC8279 takes whole bytes of sources, so the width is a multiple of 8.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="multiple of 8"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 804, "height": 480}, + } + ) + + +def test_uc8279_x4pro_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Xteink X4 Pro generates the UC8279 driver with the panel's gate offset and gate count.""" + main_cpp = generate_main(component_config_path("uc8279_x4pro_test.yaml")) + + assert "epaper_spi::EPaperUC8279" in main_cpp + # 800 sources, 480 rows, and the panel on the last 480 of 600 gates + assert re.search( + r'"XTEINK-X4PRO-UC8279",\s*800,\s*480,.*?,\s*120\)', main_cpp, re.DOTALL + ) + resolution = next( + cmd + for cmd in MODELS["XTEINK-X4PRO-UC8279"].get_init_sequence({}) + if cmd[0] == 0x61 + ) + assert resolution == (0x61, 0x03, 0x20, 0x02, 0x58) + + +def test_uc8279_offset_height_configurable( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The generic uc8279 model takes offset_height under dimensions, reaching the constructor and gate count.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 8}, + } + ) + model = MODELS["UC8279"] + assert model.get_constructor_args(config) == (8,) + resolution = next(cmd for cmd in model.get_init_sequence(config) if cmd[0] == 0x61) + # 16 sources, and 8 + 2 = 10 gates + assert resolution == (0x61, 0x00, 0x10, 0x00, 0x0A) + + +def test_uc8279_offset_height_plus_height_must_fit_16_bits( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """offset_height plus the panel height is the gate count, which the controller holds in 16 bits.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="must not exceed"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 0xFFFF}, + } + ) + + +def test_uc8279_x4pro_explicit_dimensions_keep_the_model_offset( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Writing out the X4 Pro's dimensions without offset_height keeps the panel's offset of 120.""" + 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": 12, "mosi_pin": 11}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "xteink-x4pro-uc8279", + "dimensions": {"width": 800, "height": 480}, + } + ) + assert MODELS["XTEINK-X4PRO-UC8279"].get_constructor_args(config) == (120,) + + +def test_offset_height_rejected_by_models_without_it( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model that does not use offset_height rejects it rather than ignoring it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="offset_height"): + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "dimensions": {"width": 200, "height": 200, "offset_height": 8}, + } + ) + + def test_enable_pin_code_generation( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/epaper_spi/display/test_uc8279_transfer.cpp b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp new file mode 100644 index 00000000000..a0a1e1258c5 --- /dev/null +++ b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp @@ -0,0 +1,237 @@ +#include + +#include +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_uc8279.h" + +namespace esphome::epaper_spi::testing { + +/// A reset pin that counts how often it is driven low. +class CountingPin : public RecordingPin { + public: + void digital_write(bool value) override { + if (!value) + this->pulses++; + RecordingPin::digital_write(value); + } + int pulses{0}; +}; + +/// A 16x2 panel bonded to gates 1 and 2 of three, so gate 0 is outside the panel. +class TestableUC8279 : public EPaperUC8279 { + public: + TestableUC8279() : EPaperUC8279("test", 16, 2, nullptr, 0, 1) {} + + void install(spi::SPIDelegate *delegate, uint8_t full_update_every) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_reset_pin(&this->reset_pin); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->set_full_update_every(full_update_every); + } + + void set_frame(std::initializer_list bytes) { + size_t i = 0; + for (const uint8_t byte : bytes) + this->buffer_[i++] = byte; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + /// A whole update with the frame already drawn, run through loop() as the main loop would. + void run_update() { + this->set_auto_clear(false); + this->state_ = EPaperState::UPDATE; + const uint32_t deadline = millis() + 2000; + while (this->state_ != EPaperState::IDLE) { + ASSERT_LT(millis(), deadline) << "update did not finish"; + this->loop(); + } + } + + bool transfer() { return this->transfer_data(); } + + using EPaperUC8279::on_safe_shutdown; + + RecordingPin dc; + CountingPin reset_pin; +}; + +using Bytes = std::vector; + +static size_t count(const Bytes &commands, uint8_t command) { + return std::count(commands.begin(), commands.end(), command); +} + +/// A full refresh writes the new image to DTM2 and white to DTM1, for every gate the controller scans: +/// white on the gate outside the panel, then the panel's rows. +TEST(EPaperUC8279, FullRefreshWritesTheImageAndAWhitePreviousImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_TRUE(display.reset_pin.level) << "reset pin left low"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x10].size(), 12u) << "DTM1 is not the white previous image plus the image written after"; + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "a full refresh entered partial mode"; + EXPECT_EQ(bus.data[0x00], (Bytes{0x17, 0x4D})) << "the OTP LUT was not selected before the refresh"; +} + +/// While partial updates are in use, DTM1 is rewritten with the image after every refresh, so the next +/// partial one compares against the image on the panel. The panel then waits in standby, since its own +/// deep sleep loses both planes. +TEST(EPaperUC8279, PreviousImageIsRewrittenAfterEveryRefresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02) << "the panel was not powered off after DTM1 was written"; + EXPECT_EQ(count(bus.commands, 0x07), 0u) << "the panel was put into deep sleep"; +} + +/// A partial refresh writes only DTM2, without a reset or the configuration, inside a partial window +/// covering the panel's gates. +TEST(EPaperUC8279, PartialRefreshWritesOnlyTheNewImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + + EXPECT_EQ(display.reset_pin.pulses, pulses) << "the partial refresh reset the panel"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})) + << "DTM1 was written before the refresh, or not rewritten after it"; + EXPECT_EQ(bus.data[0x90], (Bytes{0x00, 0x00, 0x00, 0x0F, 0x00, 0x01, 0x00, 0x02, 0x01})) + << "the partial window does not cover the panel's gates"; + const auto refresh = std::find(bus.commands.begin(), bus.commands.end(), 0x12); + const auto dtm1 = std::find(bus.commands.begin(), bus.commands.end(), 0x10); + EXPECT_LT(refresh, dtm1) << "DTM1 was written before the refresh"; +} + +/// Without partial updates the panel goes into its deep sleep after every update, and the next update +/// resets it and writes both planes again. +TEST(EPaperUC8279, EveryUpdateFullSleepsAndResetsEachTime) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))) << "DTM1 was rewritten without partial updates"; + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u); +} + +/// The rewrite of DTM1 is spread over loop iterations like the image transfer, and the state machine +/// waits for it: the panel is powered off and the update finishes only once all of it is written. +TEST(EPaperUC8279, PreviousImageRewriteIsTimeSliced) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); // every row overruns the time slice + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02); +} + +/// In a full update the white DTM1 plane starts on the pass after DTM2 ends, so one pass never writes both planes. +TEST(EPaperUC8279, FullTransferWritesOnePlanePerPass) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + + EXPECT_FALSE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(count(bus.commands, 0x10), 0u) << "DTM1 started in the same pass as DTM2"; + + EXPECT_TRUE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); +} + +/// The full refresh that comes round after partial ones resets the controller, configures it again and writes a white +/// DTM1 over the image it held, as the first full refresh does. +TEST(EPaperUC8279, FullRefreshAfterPartialOnesStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 2); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x00, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1) << "the full refresh did not reset the controller"; + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "the full refresh entered partial mode"; + ASSERT_EQ(bus.data[0x10].size(), 12u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x00, 0x00})); +} + +/// A requested full update, between partial ones, is a full refresh from scratch too. +TEST(EPaperUC8279, RequestedFullUpdateStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.request_full_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1); + EXPECT_EQ(count(bus.commands, 0x91), 0u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); +} + +/// Before a restart or the controller's deep sleep the panel goes into its own deep sleep, even while partial updates +/// would otherwise leave it in standby. +TEST(EPaperUC8279, ShutdownPutsThePanelToSleep) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + display.on_safe_shutdown(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 6a487064cbc..144c60dd2ff 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -435,3 +435,27 @@ display: allow_other_uses: true number: GPIO4 inverted: true + + # Xteink X4 Pro (800x480, UC8279) + # Pins overridden to avoid conflicts with the other display configs + # full_update_every > 1 exercises the partial refresh path + - platform: epaper_spi + spi_id: spi_bus + model: xteink-x4pro-uc8279 + full_update_every: 10 + 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, 100, Color::BLACK); From 0f6fc503bd0b62cace4ebce4fe202f62462fc2b1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:01:14 -0500 Subject: [PATCH 532/651] [sen6x] Add temperature compensation, acceleration and startup delay (#18781) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 43 +++++++++++-- esphome/components/sen6x/sen6x.h | 31 ++++++++- esphome/components/sen6x/sensor.py | 63 +++++++++++++++++++ tests/components/sen6x/common.yaml | 10 +++ .../components/sen6x/validate.esp32-idf.yaml | 8 ++- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index dabf16d760b..42bc3aea6a6 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -1,6 +1,7 @@ #include "sen6x.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include #include namespace esphome::sen6x { @@ -25,7 +26,6 @@ static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; // Accepted range of a runtime pressure source, the same as the schema's ambient_pressure_compensation static constexpr float AMBIENT_PRESSURE_MIN_HPA = 700.0f; static constexpr float AMBIENT_PRESSURE_MAX_HPA = 1200.0f; -static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -45,6 +45,8 @@ static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static constexpr uint16_t SEN6X_CMD_TEMPERATURE_COMPENSATION = 0x60B2; +static constexpr uint16_t SEN6X_CMD_TEMPERATURE_ACCELERATION = 0x6100; static constexpr uint16_t SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL = 0x6711; static constexpr uint16_t SEN6X_CMD_AMBIENT_PRESSURE = 0x6720; static constexpr uint16_t SEN6X_CMD_SENSOR_ALTITUDE = 0x6736; @@ -200,6 +202,16 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { return false; this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); return true; + case SETUP_STEP_TEMPERATURE_COMPENSATION: + if (!this->temperature_compensation_.has_value()) + return false; + this->write_temperature_compensation_(this->temperature_compensation_.value()); + return true; + case SETUP_STEP_TEMPERATURE_ACCELERATION: + if (!this->temperature_acceleration_.has_value()) + return false; + this->write_temperature_acceleration_(this->temperature_acceleration_.value()); + return true; case SETUP_STEP_CO2_ASC: if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) return false; @@ -220,6 +232,18 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { } } +bool SEN6XComponent::write_temperature_compensation_(const TemperatureCompensation &compensation) { + // Word 4 selects compensation slot 0; other slots are not exposed + uint16_t params[4] = {static_cast(compensation.offset), + static_cast(compensation.normalized_offset_slope), compensation.time_constant, 0}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_COMPENSATION, params, 4); +} + +bool SEN6XComponent::write_temperature_acceleration_(const TemperatureAcceleration &acceleration) { + uint16_t params[4] = {acceleration.k, acceleration.p, acceleration.t1, acceleration.t2}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_ACCELERATION, params, 4); +} + bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { return this->write_config_words_(i2c_command, &value, 1); } @@ -264,10 +288,21 @@ void SEN6XComponent::dump_config() { " Product: %s\n" " Serial: %s\n" " Firmware: %u.%u\n" - " Address: 0x%02X", + " Address: 0x%02X\n" + " Startup delay: %" PRIu32 " ms", this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, - this->firmware_version_minor_, this->address_); + this->firmware_version_minor_, this->address_, this->startup_delay_ms_); LOG_UPDATE_INTERVAL(this); + if (this->temperature_compensation_.has_value()) { + const auto &comp = this->temperature_compensation_.value(); + ESP_LOGCONFIG(TAG, " Temperature compensation: offset=%.2f slope=%.4f time_constant=%us", comp.offset / 200.0f, + comp.normalized_offset_slope / 10000.0f, comp.time_constant); + } + if (this->temperature_acceleration_.has_value()) { + const auto &accel = this->temperature_acceleration_.value(); + ESP_LOGCONFIG(TAG, " Temperature acceleration: K=%.1f P=%.1f T1=%.1f T2=%.1f", accel.k / 10.0f, accel.p / 10.0f, + accel.t1 / 10.0f, accel.t2 / 10.0f); + } // setup() clears co2_sensor_ once it detects a variant without CO2 if (this->co2_sensor_ != nullptr) { if (this->co2_asc_.has_value()) { @@ -557,7 +592,7 @@ void SEN6XComponent::on_measurement_started_() { } // Values need the warm-up period again after every start this->startup_complete_ = false; - this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); + this->set_timeout(TIMEOUT_STARTUP, this->startup_delay_ms_, [this]() { this->startup_complete_ = true; }); } void SEN6XComponent::start_measurement() { diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 43d0ce00120..8cc187ebf2b 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,21 @@ struct GasTuning { uint16_t gain_factor; }; +// Raw values in the device's fixed-point encoding (offset x200, slope x10000) +struct TemperatureCompensation { + int16_t offset; + int16_t normalized_offset_slope; + uint16_t time_constant; +}; + +// Raw values in the device's fixed-point encoding (all x10) +struct TemperatureAcceleration { + uint16_t k; + uint16_t p; + uint16_t t1; + uint16_t t2; +}; + // The CO2 settings a config may leave out: altitude is 0 to 3000 m, pressure 700 to 1200 hPa inline constexpr uint16_t ALTITUDE_UNSET = 0xFFFF; inline constexpr uint16_t PRESSURE_UNSET = 0; @@ -62,6 +77,13 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_startup_delay(uint32_t delay_ms) { this->startup_delay_ms_ = delay_ms; } + void set_temperature_compensation(int16_t offset, int16_t normalized_offset_slope, uint16_t time_constant) { + this->temperature_compensation_ = TemperatureCompensation{offset, normalized_offset_slope, time_constant}; + } + void set_temperature_acceleration(uint16_t k, uint16_t p, uint16_t t1, uint16_t t2) { + this->temperature_acceleration_ = TemperatureAcceleration{k, p, t1, t2}; + } void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } @@ -75,6 +97,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S enum SetupStep : uint8_t { SETUP_STEP_VOC_TUNING, SETUP_STEP_NOX_TUNING, + SETUP_STEP_TEMPERATURE_COMPENSATION, + SETUP_STEP_TEMPERATURE_ACCELERATION, SETUP_STEP_CO2_ASC, SETUP_STEP_ALTITUDE, SETUP_STEP_AMBIENT_PRESSURE, @@ -88,6 +112,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); bool write_setup_register_(uint16_t i2c_command, uint16_t value); + bool write_temperature_compensation_(const TemperatureCompensation &compensation); + bool write_temperature_acceleration_(const TemperatureAcceleration &acceleration); void write_ambient_pressure_(uint16_t pressure_hpa); void update_ambient_pressure_compensation_(float pressure_hpa); uint32_t command_wait_remaining_() const; @@ -102,14 +128,17 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + optional temperature_acceleration_; + optional temperature_compensation_; + uint16_t read_cmd_{0}; sensor::Sensor *ambient_pressure_source_{nullptr}; + uint32_t startup_delay_ms_{60000}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap uint32_t command_started_at_{0}; uint32_t command_wait_{0}; uint32_t co2_started_at_{0}; uint32_t co2_wait_{0}; - uint16_t read_cmd_{0}; uint16_t altitude_compensation_{ALTITUDE_UNSET}; uint16_t ambient_pressure_{PRESSURE_UNSET}; // Last pressure written to the device, used to skip redundant writes diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index b219caafa09..c62aa5af4cd 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -19,13 +19,18 @@ from esphome.const import ( CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_NORMALIZED_OFFSET_SLOPE, CONF_NOX, + CONF_OFFSET, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_STARTUP_DELAY, CONF_STD_INITIAL, CONF_TEMPERATURE, + CONF_TEMPERATURE_COMPENSATION, + CONF_TIME_CONSTANT, CONF_TYPE, CONF_VOC, DEVICE_CLASS_CARBON_DIOXIDE, @@ -47,6 +52,17 @@ from esphome.const import ( ) from esphome.types import ConfigType +CONF_TEMPERATURE_ACCELERATION = "temperature_acceleration" +CONF_K = "k" +CONF_P = "p" +CONF_T1 = "t1" +CONF_T2 = "t2" + +# The device takes these as fixed-point words: offset x200, slope x10000, acceleration x10 +TEMPERATURE_OFFSET_SCALE = 200 +TEMPERATURE_SLOPE_SCALE = 10000 +TEMPERATURE_ACCELERATION_SCALE = 10 + CODEOWNERS = ["@martgras", "@mebner86", "@tuct"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] @@ -93,6 +109,8 @@ def _gas_index_schema( ).extend({cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema(tuning_schema)}) +_acceleration_parameter = cv.float_range(min=0.1, max=6553.5) + CONFIG_SCHEMA = cv.All( cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sen6x"), cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sen6x"), @@ -182,6 +200,31 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_STARTUP_DELAY, default="60s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(hours=1)), + ), + cv.Optional(CONF_TEMPERATURE_COMPENSATION): cv.Schema( + { + cv.Optional(CONF_OFFSET, default=0): cv.float_range( + min=-163.84, max=163.835 + ), + cv.Optional( + CONF_NORMALIZED_OFFSET_SLOPE, default=0 + ): cv.float_range(min=-3.2768, max=3.2767), + cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_range( + min=0, max=65535 + ), + } + ), + cv.Optional(CONF_TEMPERATURE_ACCELERATION): cv.Schema( + { + cv.Optional(CONF_K, default=20.0): _acceleration_parameter, + cv.Optional(CONF_P, default=20.0): _acceleration_parameter, + cv.Optional(CONF_T1, default=100.0): _acceleration_parameter, + cv.Optional(CONF_T2, default=300.0): _acceleration_parameter, + } + ), } ) .extend(cv.polling_component_schema("60s")) @@ -208,6 +251,26 @@ async def to_code(config: ConfigType) -> None: if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) + cg.add(var.set_startup_delay(config[CONF_STARTUP_DELAY])) + + if (comp := config.get(CONF_TEMPERATURE_COMPENSATION)) is not None: + cg.add( + var.set_temperature_compensation( + round(comp[CONF_OFFSET] * TEMPERATURE_OFFSET_SCALE), + round(comp[CONF_NORMALIZED_OFFSET_SLOPE] * TEMPERATURE_SLOPE_SCALE), + comp[CONF_TIME_CONSTANT], + ) + ) + if (accel := config.get(CONF_TEMPERATURE_ACCELERATION)) is not None: + cg.add( + var.set_temperature_acceleration( + *( + round(accel[key] * TEMPERATURE_ACCELERATION_SCALE) + for key in (CONF_K, CONF_P, CONF_T1, CONF_T2) + ) + ) + ) + for key, func_name in SENSOR_MAP.items(): if cfg := config.get(key): sens = await sensor.new_sensor(cfg) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index eed0dd51d67..d276cecbc7c 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -8,6 +8,16 @@ sensor: id: sen6x_sensor type: SEN69C i2c_id: i2c_bus + startup_delay: 30s + temperature_compensation: + offset: -2.5 + normalized_offset_slope: 0.01 + time_constant: 60 + temperature_acceleration: + k: 25.0 + p: 15.0 + t1: 120.0 + t2: 240.0 temperature: name: Temperature accuracy_decimals: 1 diff --git a/tests/components/sen6x/validate.esp32-idf.yaml b/tests/components/sen6x/validate.esp32-idf.yaml index 3ae23af4ac4..927a8d478a9 100644 --- a/tests/components/sen6x/validate.esp32-idf.yaml +++ b/tests/components/sen6x/validate.esp32-idf.yaml @@ -1,5 +1,5 @@ -# Config-only: partial algorithm_tuning blocks, so the schema defaults fill in the -# keys that are left out. +# Config-only: partial algorithm_tuning, temperature_compensation and +# temperature_acceleration blocks, so the schema defaults fill in the keys that are left out. packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml @@ -8,6 +8,10 @@ sensor: id: sen6x_partial_tuning type: SEN65 i2c_id: i2c_bus + temperature_compensation: + offset: 1.5 + temperature_acceleration: + k: 30 voc_index: name: VOC Index algorithm_tuning: From 022733c46b6b41c21e35e35b52c6e6518fbdef97 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:16:45 -0500 Subject: [PATCH 533/651] [sen6x] Add PM number concentration sensors (#18782) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 57 ++++++++++++++++++++++++++++++ esphome/components/sen6x/sen6x.h | 12 +++++++ esphome/components/sen6x/sensor.py | 29 +++++++++++++++ esphome/core/defines.h | 1 + tests/components/sen6x/common.yaml | 10 ++++++ 5 files changed, 109 insertions(+) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 42bc3aea6a6..aaad70dd940 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include #include +#include namespace esphome::sen6x { @@ -38,6 +39,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN65 = 0x0446; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; +#ifdef USE_SEN6X_NUMBER_CONCENTRATION +static constexpr uint16_t SEN6X_CMD_READ_NUMBER_CONCENTRATION = 0x0316; +#endif static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; @@ -323,6 +327,13 @@ void SEN6XComponent::dump_config() { LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); LOG_SENSOR(" ", "PM 10.0", this->pm_10_0_sensor_); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + LOG_SENSOR(" ", "PMC 0.5", this->pmc_0_5_sensor_); + LOG_SENSOR(" ", "PMC 1.0", this->pmc_1_0_sensor_); + LOG_SENSOR(" ", "PMC 2.5", this->pmc_2_5_sensor_); + LOG_SENSOR(" ", "PMC 4.0", this->pmc_4_0_sensor_); + LOG_SENSOR(" ", "PMC 10.0", this->pmc_10_0_sensor_); +#endif LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); LOG_SENSOR(" ", "VOC", this->voc_sensor_); @@ -361,6 +372,10 @@ void SEN6XComponent::update() { // -> write_command (read measurement) // -> timeout I2C_READ_DELAY // -> parse_and_publish_measurements_() + // -> with pmc_* sensors: read_number_concentration_() + // -> write_command (read number concentration) + // -> timeout I2C_READ_DELAY + // -> parse_and_publish_number_concentration_() // // All timeouts share a single ID (TIMEOUT_POLL) since only one is active // at a time. cancel_timeout in update() stops any in-flight chain. @@ -544,9 +559,51 @@ void SEN6XComponent::parse_and_publish_measurements_() { if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(co2); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + // The define says some device in the build has pmc sensors; this one may not + if (this->has_number_concentration_sensor_()) { + this->read_number_concentration_(); // the chain ends there, with the warning cleared + return; + } +#endif this->status_clear_warning(); } +#ifdef USE_SEN6X_NUMBER_CONCENTRATION +bool SEN6XComponent::has_number_concentration_sensor_() const { + return this->pmc_0_5_sensor_ != nullptr || this->pmc_1_0_sensor_ != nullptr || this->pmc_2_5_sensor_ != nullptr || + this->pmc_4_0_sensor_ != nullptr || this->pmc_10_0_sensor_ != nullptr; +} + +void SEN6XComponent::read_number_concentration_() { + if (!this->write_command(SEN6X_CMD_READ_NUMBER_CONCENTRATION)) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read number concentration failed (%d)", this->last_error_); + return; + } + + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_number_concentration_(); }); +} + +void SEN6XComponent::parse_and_publish_number_concentration_() { + sensor::Sensor *sensors[] = {this->pmc_0_5_sensor_, this->pmc_1_0_sensor_, this->pmc_2_5_sensor_, + this->pmc_4_0_sensor_, this->pmc_10_0_sensor_}; + uint16_t measurements[std::size(sensors)]; + + if (!this->read_data(measurements, std::size(measurements))) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); + return; + } + + for (size_t i = 0; i < std::size(sensors); i++) { + if (sensors[i] != nullptr) + sensors[i]->publish_state(measurements[i] == 0xFFFF ? NAN : measurements[i] / 10.0f); + } + this->status_clear_warning(); +} +#endif + void SEN6XComponent::write_ambient_pressure_(uint16_t pressure_hpa) { if (this->write_setup_register_(SEN6X_CMD_AMBIENT_PRESSURE, pressure_hpa)) this->last_ambient_pressure_ = pressure_hpa; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 8cc187ebf2b..f3054a8ffbd 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -44,6 +44,13 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S SUB_SENSOR(pm_2_5) SUB_SENSOR(pm_4_0) SUB_SENSOR(pm_10_0) +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + SUB_SENSOR(pmc_0_5) + SUB_SENSOR(pmc_1_0) + SUB_SENSOR(pmc_2_5) + SUB_SENSOR(pmc_4_0) + SUB_SENSOR(pmc_10_0) +#endif SUB_SENSOR(temperature) SUB_SENSOR(humidity) SUB_SENSOR(voc) @@ -123,6 +130,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + bool has_number_concentration_sensor_() const; + void read_number_concentration_(); + void parse_and_publish_number_concentration_(); +#endif std::string product_name_; std::string serial_number_; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index c62aa5af4cd..953b6529ef6 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -26,6 +26,11 @@ from esphome.const import ( CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_PMC_0_5, + CONF_PMC_1_0, + CONF_PMC_2_5, + CONF_PMC_4_0, + CONF_PMC_10_0, CONF_STARTUP_DELAY, CONF_STD_INITIAL, CONF_TEMPERATURE, @@ -40,12 +45,14 @@ from esphome.const import ( DEVICE_CLASS_PM25, DEVICE_CLASS_TEMPERATURE, ICON_CHEMICAL_WEAPON, + ICON_COUNTER, ICON_MOLECULE_CO2, ICON_RADIATOR, ICON_THERMOMETER, ICON_WATER_PERCENT, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + UNIT_COUNTS_PER_CUBIC_CENTIMETER, UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PARTS_PER_MILLION, UNIT_PERCENT, @@ -73,6 +80,15 @@ SEN6XComponent = sen6x_ns.class_( ) +_PMC_KEYS = (CONF_PMC_0_5, CONF_PMC_1_0, CONF_PMC_2_5, CONF_PMC_4_0, CONF_PMC_10_0) +_PMC_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS_PER_CUBIC_CENTIMETER, + icon=ICON_COUNTER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, +) + + def _gas_index_schema( *, index_offset: int, @@ -147,6 +163,11 @@ CONFIG_SCHEMA = cv.All( device_class=DEVICE_CLASS_PM10, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_PMC_0_5): _PMC_SCHEMA, + cv.Optional(CONF_PMC_1_0): _PMC_SCHEMA, + cv.Optional(CONF_PMC_2_5): _PMC_SCHEMA, + cv.Optional(CONF_PMC_4_0): _PMC_SCHEMA, + cv.Optional(CONF_PMC_10_0): _PMC_SCHEMA, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, icon=ICON_THERMOMETER, @@ -236,6 +257,11 @@ SENSOR_MAP = { CONF_PM_2_5: "set_pm_2_5_sensor", CONF_PM_4_0: "set_pm_4_0_sensor", CONF_PM_10_0: "set_pm_10_0_sensor", + CONF_PMC_0_5: "set_pmc_0_5_sensor", + CONF_PMC_1_0: "set_pmc_1_0_sensor", + CONF_PMC_2_5: "set_pmc_2_5_sensor", + CONF_PMC_4_0: "set_pmc_4_0_sensor", + CONF_PMC_10_0: "set_pmc_10_0_sensor", CONF_TEMPERATURE: "set_temperature_sensor", CONF_HUMIDITY: "set_humidity_sensor", CONF_VOC_INDEX: "set_voc_sensor", @@ -250,6 +276,9 @@ async def to_code(config: ConfigType) -> None: if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) + # The number concentration read and its sensors compile in only when one is configured + if any(key in config for key in _PMC_KEYS): + cg.add_define("USE_SEN6X_NUMBER_CONCENTRATION") cg.add(var.set_startup_delay(config[CONF_STARTUP_DELAY])) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 13dc3492f29..1fa6f7822a5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -275,6 +275,7 @@ #define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M +#define USE_SEN6X_NUMBER_CONCENTRATION #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index d276cecbc7c..6e39256d159 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -40,6 +40,16 @@ sensor: name: PM <10µm Weight concentration id: sen6x_pm_10_0 accuracy_decimals: 1 + pmc_0_5: + name: PM <0.5µm Number concentration + pmc_1_0: + name: PM <1µm Number concentration + pmc_2_5: + name: PM <2.5µm Number concentration + pmc_4_0: + name: PM <4µm Number concentration + pmc_10_0: + name: PM <10µm Number concentration nox_index: name: NOx Index algorithm_tuning: From 1a28a9220ba1f77f8d9576562adb0baacba3ed0c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 18 Oct 2026 00:35:46 +0200 Subject: [PATCH 534/651] [uart] Add a binary sensor that fires on a byte sequence (#14032) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/uart/binary_sensor/__init__.py | 101 ++++++++++++++++++ .../uart/binary_sensor/uart_binary_sensor.cpp | 50 +++++++++ .../uart/binary_sensor/uart_binary_sensor.h | 37 +++++++ esphome/core/defines.h | 1 + esphome/core/helpers.h | 8 ++ tests/components/uart/test.esp32-idf.yaml | 10 ++ tests/components/uart/test.esp8266-ard.yaml | 10 ++ 7 files changed, 217 insertions(+) create mode 100644 esphome/components/uart/binary_sensor/__init__.py create mode 100644 esphome/components/uart/binary_sensor/uart_binary_sensor.cpp create mode 100644 esphome/components/uart/binary_sensor/uart_binary_sensor.h diff --git a/esphome/components/uart/binary_sensor/__init__.py b/esphome/components/uart/binary_sensor/__init__.py new file mode 100644 index 00000000000..07ab31ab19c --- /dev/null +++ b/esphome/components/uart/binary_sensor/__init__.py @@ -0,0 +1,101 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, uart +import esphome.config_validation as cv +from esphome.const import CONF_DATA, CONF_UART_ID +from esphome.core import CORE, ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import uart_ns, validate_raw_data + +DEPENDENCIES = ["uart"] +DOMAIN = "uart_binary_sensor" + +CONF_HUB_ID = "hub_id" + +UARTBinarySensorHub = uart_ns.class_( + "UARTBinarySensorHub", uart.UARTDevice, cg.Component +) + +# Every sensor on a bus is one matcher of that bus's hub; the define is the largest hub +_request_matcher_slot = cg.slot_counter("UART_BINARY_SENSOR_COUNT") + +CONFIG_SCHEMA = ( + # Initially off, so the first sequence after boot is an OFF to ON edge for on_press + binary_sensor.binary_sensor_schema(binary_sensor.BinarySensorInitiallyOff) + .extend( + { + cv.GenerateID(CONF_HUB_ID): cv.declare_id(UARTBinarySensorHub), + # An empty pattern would match every byte; the hub's buffer index is a byte + cv.Required(CONF_DATA): cv.All( + validate_raw_data, cv.Length(min=1, max=255) + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +_validate_bus = uart.final_validate_device_schema("uart binary_sensor", require_rx=True) + + +def _one_hub_per_bus(config: ConfigType) -> ConfigType: + """The first matching sensor takes the bytes and clears them, so a sequence that contains + another on the same bus would leave one of them dead; and the sensors of a bus share the hub + the first one creates, so only that one can name it and claim the bus's receive side.""" + data = bytes(config[CONF_DATA]) + first = True + for other in fv.full_config.get().get("binary_sensor", []): + if ( + other.get("platform") != "uart" + or other[CONF_UART_ID] != config[CONF_UART_ID] + ): + continue + if other is config: + break # Every sensor before this one on the bus has been seen + first = False + other_data = bytes(other[CONF_DATA]) + if other_data in data or data in other_data: + raise cv.Invalid( + f"Another uart binary_sensor on '{config[CONF_UART_ID]}' has data this pattern " + "would match as well" + ) + if config[CONF_HUB_ID].is_manual: + raise cv.Invalid( + f"Set hub_id on the first uart binary_sensor of '{config[CONF_UART_ID]}'; " + "the others share its hub" + ) + if first: + _validate_bus(config) + return config + + +FINAL_VALIDATE_SCHEMA = _one_hub_per_bus + + +def _hubs() -> dict[ID, cg.MockObj]: + """The hub of each bus, keyed by the bus id.""" + return CORE.data.setdefault(DOMAIN, {}) + + +async def _hub_for(config: ConfigType) -> cg.MockObj: + """The one hub that reads the bus for every sensor on it, created by the first sensor.""" + hubs = _hubs() + if (hub := hubs.get(config[CONF_UART_ID])) is None: + hub = hubs[config[CONF_UART_ID]] = cg.new_Pvariable(config[CONF_HUB_ID]) + await cg.register_component(hub, config) + await uart.register_uart_device(hub, config) + return hub + + +async def to_code(config: ConfigType) -> None: + hub = await _hub_for(config) + var = await binary_sensor.new_binary_sensor(config) + data = config[CONF_DATA] + _request_matcher_slot(str(hub)) + cg.add( + hub.add_event_matcher( + var, automation.progmem_bytes("uart_binary_sensor_data", data), len(data) + ) + ) diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp new file mode 100644 index 00000000000..9bf9e726561 --- /dev/null +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp @@ -0,0 +1,50 @@ +#include "uart_binary_sensor.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::uart { + +ESPHOME_LOG_TAG(TAG, "uart.binary_sensor"); + +void UARTBinarySensorHub::dump_config() { + ESP_LOGCONFIG(TAG, "UART Binary Sensor:"); + for (const auto &matcher : this->matchers_) { + LOG_BINARY_SENSOR(" ", "Pattern", matcher.sensor); + } +} + +void UARTBinarySensorHub::loop() { + while (this->available()) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + this->buffer_.push_overwrite(byte); + for (const auto &matcher : this->matchers_) { + // The byte just read has to be the pattern's last one before the rest is compared + if (progmem_read_byte(&matcher.data[matcher.data_len - 1]) != byte || !this->matches_(matcher)) { + continue; + } + // A momentary press, as the remote receiver binary sensors publish theirs + matcher.sensor->publish_state(true); + matcher.sensor->publish_state(false); + this->buffer_.clear(); + break; + } + } +} + +// Whether the pattern, apart from its last byte, ends the buffer +bool UARTBinarySensorHub::matches_(const EventMatcher &matcher) const { + if (this->buffer_.size() < matcher.data_len) { + return false; + } + const size_t start = this->buffer_.size() - matcher.data_len; + for (size_t i = 0; i + 1 < matcher.data_len; i++) { + if (this->buffer_[start + i] != progmem_read_byte(&matcher.data[i])) { + return false; + } + } + return true; +} + +} // namespace esphome::uart diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.h b/esphome/components/uart/binary_sensor/uart_binary_sensor.h new file mode 100644 index 00000000000..71ccda9ef21 --- /dev/null +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +// One per bus: reads the bytes once and presses each sensor whose pattern ends the stream +class UARTBinarySensorHub : public UARTDevice, public Component { + public: + void setup() override { this->buffer_.init(this->max_len_); } + void loop() override; + void dump_config() override; + + // The pattern lives in flash; called once per sensor by the generated code + void add_event_matcher(binary_sensor::BinarySensor *sensor, const uint8_t *data, uint8_t data_len) { + this->matchers_.push_back({sensor, data, data_len}); + this->max_len_ = std::max(this->max_len_, data_len); + } + + protected: + struct EventMatcher { + binary_sensor::BinarySensor *sensor; + const uint8_t *data; + uint8_t data_len; + }; + + bool matches_(const EventMatcher &matcher) const; + + StaticVector matchers_; + FixedRingBuffer buffer_; + uint8_t max_len_{0}; +}; + +} // namespace esphome::uart diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1fa6f7822a5..24f66a87518 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 +#define UART_BINARY_SENSOR_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 #define USE_MICS_5524_DATASHEET diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 72bd3a0c6ba..2989655a29a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -576,6 +576,14 @@ template::max()> ConstIterator begin() const { return ConstIterator(this, 0); } ConstIterator end() const { return ConstIterator(this, this->count_); } + /// The element `pos` places after the oldest one; the sum is widened so a small index type cannot truncate it + const T &operator[](index_type pos) const { + size_t index = static_cast(this->head_) + pos; + if (index >= this->capacity_) + index -= this->capacity_; + return this->data_[index]; + } + protected: T *data_{nullptr}; index_type head_{0}; diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index 3bb87aa3cc7..02b53a89e44 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -120,3 +120,13 @@ event: event_types: - "string_event_A": "*A#" - "bytes_event_B": [0x2A, 0x42, 0x23] + +binary_sensor: + - platform: uart + id: up + uart_id: uart_id + data: [0x1B, 0x5B, 0x41] + - platform: uart + id: down + uart_id: uart_id + data: [0x1B, 0x5B, 0x42] diff --git a/tests/components/uart/test.esp8266-ard.yaml b/tests/components/uart/test.esp8266-ard.yaml index 09d23e2925e..b8f07fa9d62 100644 --- a/tests/components/uart/test.esp8266-ard.yaml +++ b/tests/components/uart/test.esp8266-ard.yaml @@ -55,3 +55,13 @@ event: event_types: - "string_event_A": "*A#" - "bytes_event_B": [0x2A, 0x42, 0x23] + +binary_sensor: + - platform: uart + id: up + uart_id: uart_id + data: [0x1B, 0x5B, 0x41] + - platform: uart + id: down + uart_id: uart_id + data: [0x1B, 0x5B, 0x42] From 818d43f742e3fc18c22bb6d2e41f46d1e0debb73 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 17 Oct 2026 18:25:36 -0500 Subject: [PATCH 535/651] [epaper_spi] Run the loop continuously between the steps of an update (#20554) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/epaper_spi.cpp | 27 ++- esphome/components/epaper_spi/epaper_spi.h | 4 + tests/components/epaper_spi/common.h | 8 + .../epaper_spi/display/test_busy_timeout.cpp | 11 +- .../epaper_spi/display/test_fast_loop.cpp | 212 ++++++++++++++++++ 5 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_fast_loop.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 71e87e038b6..fccb5325a7f 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -133,7 +133,9 @@ void EPaperBase::update() { } void EPaperBase::wait_for_idle_(bool should_wait) { - this->waiting_for_idle_start_ = millis(); + // The wait starts when the step's delay ends, or now for a wait in the middle of a step + const uint32_t now = millis(); + this->waiting_for_idle_start_ = static_cast(this->delay_until_ - now) > 0 ? this->delay_until_ : now; this->waiting_for_idle_ = should_wait; } @@ -147,19 +149,30 @@ void EPaperBase::loop() { auto now = millis(); // using modulus arithmetic to handle wrap-around int diff = now - this->delay_until_; - if (diff < 0) + if (diff < 0) { + // Running the loop continuously cannot shorten a delay longer than one loop interval + if (-diff > static_cast(App.get_loop_interval())) { + this->high_frequency_loop_.stop(); + } else { + this->high_frequency_loop_.start(); + } return; + } if (this->waiting_for_idle_) { if (this->is_idle_()) { this->waiting_for_idle_ = false; + this->high_frequency_loop_.start(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); #endif } else { - if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) { + const uint32_t busy_for = now - this->waiting_for_idle_start_; + if (busy_for > this->busy_timeout_seconds_ * 1000u) { this->abandon_update_(); return; } + if (busy_for > FAST_BUSY_POLL_MS) + this->high_frequency_loop_.stop(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_()); @@ -170,6 +183,8 @@ void EPaperBase::loop() { } } this->process_state_(); + if (this->is_failed()) + this->high_frequency_loop_.stop(); } // The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full @@ -275,16 +290,20 @@ void EPaperBase::process_state_() { void EPaperBase::set_state_(EPaperState state, uint16_t delay) { ESP_LOGV(TAG, "Exit state %s", this->epaper_state_to_string_()); this->state_ = state; - this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); // allow subclasses to nominate delays if (delay == 0) delay = this->next_delay_; this->next_delay_ = 0; this->delay_until_ = millis() + delay; + this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); ESP_LOGV(TAG, "Enter state %s, delay %u, wait_for_idle=%s", this->epaper_state_to_string_(), delay, TRUEFALSE(this->waiting_for_idle_)); + // Each step of an update otherwise waits for the next loop iteration; loop() slows down again for long waits if (state == EPaperState::IDLE) { + this->high_frequency_loop_.stop(); this->disable_loop(); + } else { + this->high_frequency_loop_.start(); } } diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index d953bf3df81..d6fe4238800 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -5,6 +5,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome::epaper_spi { using namespace display; @@ -31,6 +32,8 @@ static constexpr uint8_t SWAP_XY = 4; static constexpr uint32_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run static constexpr size_t MAX_TRANSFER_SIZE = 128; +// Longer busy waits are the waveform or a stuck pin, and are polled at the normal loop pace +static constexpr uint32_t FAST_BUSY_POLL_MS = 50; static constexpr uint8_t DELAY_FLAG = 0xFF; class EPaperBase : public Display, @@ -202,6 +205,7 @@ class EPaperBase : public Display, GPIOPin *reset_pin_{}; std::vector enable_pins_{}; bool waiting_for_idle_{}; + HighFrequencyLoopRequester high_frequency_loop_; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state uint8_t transform_{}; diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h index f21e674fde2..9631f1e0776 100644 --- a/tests/components/epaper_spi/common.h +++ b/tests/components/epaper_spi/common.h @@ -50,6 +50,14 @@ class RecordingPin : public GPIOPin { bool level{true}; }; +/// A busy pin the test can hold at "busy" and release. +class SwitchableBusyPin : public RecordingPin { + public: + bool digital_read() override { return this->busy; } + + bool busy{false}; +}; + /// SPI delegate that records what reaches the bus, filing each payload under the command it /// followed; commands are the bytes written while D/C is low. Optionally burns wall-clock time on /// each data row, so a transfer can be driven past its yield deadline. diff --git a/tests/components/epaper_spi/display/test_busy_timeout.cpp b/tests/components/epaper_spi/display/test_busy_timeout.cpp index 7080619b2ff..533c795f272 100644 --- a/tests/components/epaper_spi/display/test_busy_timeout.cpp +++ b/tests/components/epaper_spi/display/test_busy_timeout.cpp @@ -7,14 +7,8 @@ #include "esphome/components/epaper_spi/epaper_spi_t133a01.h" namespace esphome::epaper_spi::testing { - -/// A busy pin the test can hold at "busy" and release. -class SwitchableBusyPin : public RecordingPin { - public: - bool digital_read() override { return this->busy; } - - bool busy{false}; -}; +// Other test files define fixtures with the same names; keep these to this file +namespace { template void loop_until_idle(T &display) { const uint32_t deadline = millis() + 1000; @@ -142,4 +136,5 @@ TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) { << "the panel was not powered off (POF) when the update was abandoned"; } +} // namespace } // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_fast_loop.cpp b/tests/components/epaper_spi/display/test_fast_loop.cpp new file mode 100644 index 00000000000..3a2a905281e --- /dev/null +++ b/tests/components/epaper_spi/display/test_fast_loop.cpp @@ -0,0 +1,212 @@ +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { +// Other test files define fixtures with the same names; keep these to this file +namespace { + +constexpr uint8_t MALFORMED_INIT_SEQUENCE[] = {0x01}; + +class TestableSSD1683 : public EPaperSSD1683 { + public: + explicit TestableSSD1683(const uint8_t *init_sequence = nullptr, size_t init_sequence_length = 0) + : EPaperSSD1683("test", 16, 2, init_sequence, init_sequence_length) {} + // The request count is shared by the whole test binary, so never leave it raised + ~TestableSSD1683() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_busy_pin(&this->busy); + this->set_auto_clear(false); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void draw_whole_panel() { + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + void draw_nothing() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + + /// Runs the loop for the given time, or until the display is idle. + void loop_for(uint32_t ms) { + const uint32_t until = millis() + ms; + while (this->state_ != EPaperState::IDLE && millis() < until) + this->loop(); + } + + EPaperState state() const { return this->state_; } + + RecordingPin dc, reset; + SwitchableBusyPin busy; +}; + +/// With no long waits the loop runs continuously from the start of an update to its end, then goes back to its +/// normal pace. +TEST(EPaperFastLoop, LoopRunsContinuouslyForShortStepsAndStopsAfterward) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.draw_whole_panel(); + display.update(); + bool always_fast = true; + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::IDLE && millis() < deadline) { + always_fast &= HighFrequencyLoopRequester::is_high_frequency(); + display.loop(); + } + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_TRUE(always_fast); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()) << "the loop was left running continuously"; +} + +/// An update with nothing drawn ends straight away and does not leave the loop running continuously. +TEST(EPaperFastLoop, UpdateWithNothingDrawnStopsTheFastLoop) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_nothing(); + display.update(); + display.loop(); + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A display marked failed part way through an update is no longer looped, so it must stop the fast loop itself. +TEST(EPaperFastLoop, FailedDisplayStopsTheFastLoop) { + TestableSSD1683 display(MALFORMED_INIT_SEQUENCE, sizeof(MALFORMED_INIT_SEQUENCE)); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (!display.is_failed() && millis() < deadline) + display.loop(); + + ASSERT_TRUE(display.is_failed()); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A long busy wait, such as the waveform or a stuck busy pin, is polled at the normal pace. Once the pin clears, +/// the next steps run continuously again. +TEST(EPaperFastLoop, LongBusyWaitUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.busy.busy = true; + display.draw_whole_panel(); + display.update(); + display.loop_for(FAST_BUSY_POLL_MS + 20); + + ASSERT_NE(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A delay longer than a loop interval, such as a long reset pulse, is waited out at the normal pace. +TEST(EPaperFastLoop, LongDelayUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_reset_pin(&display.reset); + display.set_reset_duration(200); + + display.draw_whole_panel(); + display.update(); + display.loop_for(50); + + ASSERT_EQ(display.state(), EPaperState::RESET_END); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A step that takes several loop passes gets the fast loop back once a long busy wait before it has ended. +TEST(EPaperFastLoop, FastLoopResumesWhenALongBusyWaitEnds) { + TestableSSD1683 display; + // every row of data overruns a transfer slice, so the transfer takes a loop pass per row + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::TRANSFER_DATA && millis() < deadline) + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + display.busy.busy = true; + display.loop_for(FAST_BUSY_POLL_MS + 20); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA) << "the transfer should take more than one pass"; + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {} + ~TestableT133A01() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + this->set_busy_pin(&this->busy); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); } + + RecordingPin dc, cs, cs1; + SwitchableBusyPin busy; +}; + +/// A busy wait that starts in the middle of a step is polled continuously for its first moments, however long +/// the step has already run. +TEST(EPaperFastLoop, WaitInTheMiddleOfAStepIsPolledFast) { + TestableT133A01 display; + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + display.start_transfer(); + const uint32_t until = millis() + FAST_BUSY_POLL_MS + 20; + while (millis() < until) { + } + // the transfer waits for the busy pin after its first command, then sends a row and yields + display.loop(); + display.busy.busy = true; + display.loop(); + + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +} // namespace +} // namespace esphome::epaper_spi::testing From d1ec52e95a7506bbb4365d1f8db3aa37234e6df6 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:34:15 -0500 Subject: [PATCH 536/651] [sen6x] Add device status binary sensors (#18784) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/binary_sensor.py | 53 +++++++++++++ esphome/components/sen6x/sen6x.cpp | 90 ++++++++++++++++++++++- esphome/components/sen6x/sen6x.h | 35 +++++++++ esphome/core/defines.h | 1 + tests/components/sen6x/common.yaml | 18 +++++ 5 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sen6x/binary_sensor.py diff --git a/esphome/components/sen6x/binary_sensor.py b/esphome/components/sen6x/binary_sensor.py new file mode 100644 index 00000000000..9662e65a378 --- /dev/null +++ b/esphome/components/sen6x/binary_sensor.py @@ -0,0 +1,53 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .sensor import SEN6XComponent, sen6x_ns + +CONF_SEN6X_ID = "sen6x_id" +CONF_FAN_ERROR = "fan_error" +CONF_FAN_SPEED_WARNING = "fan_speed_warning" +CONF_RHT_ERROR = "rht_error" +CONF_GAS_ERROR = "gas_error" +CONF_CO2_ERROR = "co2_error" +CONF_HCHO_ERROR = "hcho_error" +CONF_PM_ERROR = "pm_error" + +Sen6xStatus = sen6x_ns.enum("Sen6xStatus", is_class=True) + +# Each key watches one bit of the device status register +_STATUS_KINDS = ( + (CONF_FAN_ERROR, Sen6xStatus.SEN6X_STATUS_FAN_ERROR), + (CONF_FAN_SPEED_WARNING, Sen6xStatus.SEN6X_STATUS_FAN_SPEED_WARNING), + (CONF_RHT_ERROR, Sen6xStatus.SEN6X_STATUS_RHT_ERROR), + (CONF_GAS_ERROR, Sen6xStatus.SEN6X_STATUS_GAS_ERROR), + (CONF_CO2_ERROR, Sen6xStatus.SEN6X_STATUS_CO2_ERROR), + (CONF_HCHO_ERROR, Sen6xStatus.SEN6X_STATUS_HCHO_ERROR), + (CONF_PM_ERROR, Sen6xStatus.SEN6X_STATUS_PM_ERROR), +) + +_request_status_slot = cg.slot_counter("SEN6X_STATUS_SENSOR_COUNT") + +_STATUS_SCHEMA = binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_SEN6X_ID): cv.use_id(SEN6XComponent), + **{cv.Optional(key): _STATUS_SCHEMA for key, _ in _STATUS_KINDS}, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_SEN6X_ID]) + for key, status in _STATUS_KINDS: + if (conf := config.get(key)) is None: + continue + sens = await binary_sensor.new_binary_sensor(conf) + _request_status_slot(str(hub)) + cg.add(hub.add_status_binary_sensor(sens, status)) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index aaad70dd940..9f95ed192c0 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -9,6 +9,29 @@ namespace esphome::sen6x { ESPHOME_LOG_TAG(TAG, "sen6x"); +#ifdef SEN6X_STATUS_SENSOR_COUNT +// Bit of the status register behind each kind; the CO2 error bit is 9 on SEN66 and 12 on SEN63C/SEN69C +static uint8_t status_bit(Sen6xStatus status, SEN6XComponent::Sen6xType type) { + switch (status) { + case Sen6xStatus::SEN6X_STATUS_FAN_ERROR: + return 4; + case Sen6xStatus::SEN6X_STATUS_FAN_SPEED_WARNING: + return 21; + case Sen6xStatus::SEN6X_STATUS_RHT_ERROR: + return 6; + case Sen6xStatus::SEN6X_STATUS_GAS_ERROR: + return 7; + case Sen6xStatus::SEN6X_STATUS_CO2_ERROR: + return type == SEN6XComponent::SEN66 ? 9 : 12; + case Sen6xStatus::SEN6X_STATUS_HCHO_ERROR: + return 10; + case Sen6xStatus::SEN6X_STATUS_PM_ERROR: + return 11; + } + return 0; +} +#endif + static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete static constexpr uint32_t CMD_EXEC_DELAY = 20; // execution time of set commands (datasheet section 4.8) @@ -31,6 +54,7 @@ static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; static constexpr uint16_t SEN6X_CMD_GET_SERIAL_NUMBER = 0xD033; +static constexpr uint16_t SEN6X_CMD_READ_AND_CLEAR_DEVICE_STATUS = 0xD210; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT = 0x0300; // SEN66 only! static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN62 = 0x04A3; @@ -158,6 +182,22 @@ void SEN6XComponent::setup() { ESP_LOGE(TAG, "Formaldehyde requires SEN68 or SEN69C"); this->hcho_sensor_ = nullptr; } +#ifdef SEN6X_STATUS_SENSOR_COUNT + // A status sensor for a feature this variant lacks is dropped like the measurement sensors above, so + // an empty table skips the status read; the kept ones learn their register bit here + size_t kept = 0; + for (auto entry : this->status_sensors_) { + if ((entry.status == Sen6xStatus::SEN6X_STATUS_GAS_ERROR && !has_voc_nox) || + (entry.status == Sen6xStatus::SEN6X_STATUS_CO2_ERROR && !has_co2) || + (entry.status == Sen6xStatus::SEN6X_STATUS_HCHO_ERROR && !has_hcho)) { + ESP_LOGE(TAG, "'%s' needs a variant with that feature", entry.sensor->get_name().c_str()); + continue; + } + entry.bit = status_bit(entry.status, this->sen6x_type_); + this->status_sensors_[kept++] = entry; + } + this->status_sensors_.resize(kept); +#endif // Step 3: Read firmware version and start measurements in next loop iteration this->set_timeout(0, [this]() { @@ -340,6 +380,11 @@ void SEN6XComponent::dump_config() { LOG_SENSOR(" ", "NOx", this->nox_sensor_); LOG_SENSOR(" ", "HCHO", this->hcho_sensor_); LOG_SENSOR(" ", "CO2", this->co2_sensor_); +#ifdef SEN6X_STATUS_SENSOR_COUNT + for (const auto &entry : this->status_sensors_) { + LOG_BINARY_SENSOR(" ", "Device status", entry.sensor); + } +#endif } void SEN6XComponent::update() { @@ -363,6 +408,11 @@ void SEN6XComponent::update() { // Polling uses chained timeouts to guarantee each I2C operation completes // before the next begins. The flow is: // + // read_device_status_() + // with status sensors: write_command (read and clear device status) + // -> timeout I2C_READ_DELAY -> parse_and_publish_device_status_() + // -> poll_data_ready_() + // // poll_data_ready_() // -> write_command (data ready status) // -> timeout I2C_READ_DELAY @@ -380,10 +430,13 @@ void SEN6XComponent::update() { // All timeouts share a single ID (TIMEOUT_POLL) since only one is active // at a time. cancel_timeout in update() stops any in-flight chain. this->poll_retries_remaining_ = POLL_RETRIES; - this->poll_data_ready_(); + this->read_device_status_(); } void SEN6XComponent::poll_data_ready_() { + // stop_measurement() may have run from a publish callback while the chain was in flight + if (!this->measuring_) + return; if (this->poll_retries_remaining_ == 0) { this->status_set_warning(); ESP_LOGD(TAG, "Data not ready"); @@ -569,6 +622,41 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// Reads the status register first, so the error sensors update even when the measurement read fails, then runs +// the measurement poll chain. Read And Clear (0xD210) is used because error flags are sticky on the device; +// clearing each cycle makes the sensors report whether a problem occurred since the last update. +void SEN6XComponent::read_device_status_() { +#ifdef SEN6X_STATUS_SENSOR_COUNT + if (!this->status_sensors_.empty()) { + // The pressure write in update() may still be executing; the device rejects commands until it is done + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->read_device_status_(); }); + return; + } + if (this->write_command(SEN6X_CMD_READ_AND_CLEAR_DEVICE_STATUS)) { + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_device_status_(); }); + return; + } + ESP_LOGD(TAG, "Read device status failed (%d)", this->last_error_); + } +#endif + this->poll_data_ready_(); +} + +#ifdef SEN6X_STATUS_SENSOR_COUNT +void SEN6XComponent::parse_and_publish_device_status_() { + uint16_t words[2]; + if (this->read_data(words, 2)) { + const uint32_t status = (static_cast(words[0]) << 16) | words[1]; + for (const auto &entry : this->status_sensors_) + entry.sensor->publish_state(((status >> entry.bit) & 1) != 0); + } else { + ESP_LOGD(TAG, "Read device status failed (%d)", this->last_error_); + } + this->poll_data_ready_(); +} +#endif + #ifdef USE_SEN6X_NUMBER_CONCENTRATION bool SEN6XComponent::has_number_concentration_sensor_() const { return this->pmc_0_5_sensor_ != nullptr || this->pmc_1_0_sensor_ != nullptr || this->pmc_2_5_sensor_ != nullptr || diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index f3054a8ffbd..e73f93f5a4c 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -4,9 +4,32 @@ #include "esphome/core/optional.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" +#ifdef SEN6X_STATUS_SENSOR_COUNT +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/helpers.h" +#endif namespace esphome::sen6x { +#ifdef SEN6X_STATUS_SENSOR_COUNT +// Bits of the device status register (datasheet section 4.3) a binary sensor can watch +enum class Sen6xStatus : uint8_t { + SEN6X_STATUS_FAN_ERROR, + SEN6X_STATUS_FAN_SPEED_WARNING, + SEN6X_STATUS_RHT_ERROR, + SEN6X_STATUS_GAS_ERROR, + SEN6X_STATUS_CO2_ERROR, + SEN6X_STATUS_HCHO_ERROR, + SEN6X_STATUS_PM_ERROR, +}; + +struct StatusSensor { + binary_sensor::BinarySensor *sensor; + Sen6xStatus status; + uint8_t bit; // of the status register, set at setup once the variant is known +}; +#endif + // The NOx algorithm requires std_initial to stay at 50 (Sensirion datasheet) static constexpr uint16_t NOX_STD_INITIAL = 50; @@ -91,6 +114,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void set_temperature_acceleration(uint16_t k, uint16_t p, uint16_t t1, uint16_t t2) { this->temperature_acceleration_ = TemperatureAcceleration{k, p, t1, t2}; } +#ifdef SEN6X_STATUS_SENSOR_COUNT + void add_status_binary_sensor(binary_sensor::BinarySensor *sensor, Sen6xStatus status) { + this->status_sensors_.push_back({sensor, status, 0}); + } +#endif void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } @@ -130,6 +158,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); + void read_device_status_(); +#ifdef SEN6X_STATUS_SENSOR_COUNT + void parse_and_publish_device_status_(); +#endif #ifdef USE_SEN6X_NUMBER_CONCENTRATION bool has_number_concentration_sensor_() const; void read_number_concentration_(); @@ -138,6 +170,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string product_name_; std::string serial_number_; +#ifdef SEN6X_STATUS_SENSOR_COUNT + StaticVector status_sensors_; +#endif optional voc_tuning_params_; optional nox_tuning_params_; optional temperature_acceleration_; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 24f66a87518..935952d3b31 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 +#define SEN6X_STATUS_SENSOR_COUNT 1 #define UART_BINARY_SENSOR_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 6e39256d159..f6fee2cdf58 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -77,6 +77,24 @@ sensor: name: Formaldehyde address: 0x6B +binary_sensor: + - platform: sen6x + sen6x_id: sen6x_sensor + fan_error: + name: Fan Error + fan_speed_warning: + name: Fan Speed Warning + rht_error: + name: RHT Error + gas_error: + name: Gas Error + co2_error: + name: CO2 Error + hcho_error: + name: HCHO Error + pm_error: + name: PM Error + button: - platform: template name: Fan Cleaning From 89afb111ad86c087a7d60a0171068d610acaf318 Mon Sep 17 00:00:00 2001 From: C H R I S T I A N Date: Sun, 18 Oct 2026 02:06:46 +0200 Subject: [PATCH 537/651] [mt6701] Add MT6701 (MagnTek magnetic encoder) component (#17643) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/mt6701/__init__.py | 9 + esphome/components/mt6701/mt6701.cpp | 28 +++ esphome/components/mt6701/mt6701.h | 33 +++ esphome/components/mt6701/sensor/__init__.py | 51 +++++ .../mt6701/sensor/mt6701_sensor.cpp | 23 ++ .../components/mt6701/sensor/mt6701_sensor.h | 19 ++ esphome/components/mt6701_i2c/__init__.py | 202 ++++++++++++++++++ esphome/components/mt6701_i2c/mt6701_i2c.cpp | 184 ++++++++++++++++ esphome/components/mt6701_i2c/mt6701_i2c.h | 79 +++++++ esphome/components/mt6701_spi/__init__.py | 47 ++++ .../mt6701_spi/binary_sensor/__init__.py | 46 ++++ .../mt6701_spi_binary_sensors.cpp | 29 +++ .../binary_sensor/mt6701_spi_binary_sensors.h | 25 +++ esphome/components/mt6701_spi/mt6701_spi.cpp | 67 ++++++ esphome/components/mt6701_spi/mt6701_spi.h | 41 ++++ .../mt6701_spi/text_sensor/__init__.py | 36 ++++ .../text_sensor/mt6701_spi_field_status.cpp | 28 +++ .../text_sensor/mt6701_spi_field_status.h | 19 ++ tests/component_tests/mt6701/__init__.py | 0 .../mt6701/config/mt6701_i2c_modes.yaml | 19 ++ .../config/mt6701_i2c_modes_uvw_pwm.yaml | 21 ++ tests/component_tests/mt6701/test_mt6701.py | 95 ++++++++ tests/components/mt6701_i2c/common.yaml | 33 +++ .../components/mt6701_i2c/test.esp32-ard.yaml | 3 + .../mt6701_i2c/test.esp32-c3-idf.yaml | 3 + .../components/mt6701_i2c/test.esp32-idf.yaml | 3 + .../mt6701_i2c/test.esp8266-ard.yaml | 3 + tests/components/mt6701_i2c/test.host.yaml | 3 + .../mt6701_i2c/test.rp2040-ard.yaml | 3 + tests/components/mt6701_spi/__init__.py | 6 + tests/components/mt6701_spi/common.yaml | 27 +++ .../mt6701_spi/mt6701_spi_frame_test.cpp | 96 +++++++++ .../components/mt6701_spi/test.esp32-ard.yaml | 6 + .../mt6701_spi/test.esp32-c3-idf.yaml | 6 + .../components/mt6701_spi/test.esp32-idf.yaml | 6 + .../mt6701_spi/test.esp8266-ard.yaml | 6 + .../mt6701_spi/test.rp2040-ard.yaml | 6 + 38 files changed, 1314 insertions(+) create mode 100644 esphome/components/mt6701/__init__.py create mode 100644 esphome/components/mt6701/mt6701.cpp create mode 100644 esphome/components/mt6701/mt6701.h create mode 100644 esphome/components/mt6701/sensor/__init__.py create mode 100644 esphome/components/mt6701/sensor/mt6701_sensor.cpp create mode 100644 esphome/components/mt6701/sensor/mt6701_sensor.h create mode 100644 esphome/components/mt6701_i2c/__init__.py create mode 100644 esphome/components/mt6701_i2c/mt6701_i2c.cpp create mode 100644 esphome/components/mt6701_i2c/mt6701_i2c.h create mode 100644 esphome/components/mt6701_spi/__init__.py create mode 100644 esphome/components/mt6701_spi/binary_sensor/__init__.py create mode 100644 esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp create mode 100644 esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h create mode 100644 esphome/components/mt6701_spi/mt6701_spi.cpp create mode 100644 esphome/components/mt6701_spi/mt6701_spi.h create mode 100644 esphome/components/mt6701_spi/text_sensor/__init__.py create mode 100644 esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp create mode 100644 esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h create mode 100644 tests/component_tests/mt6701/__init__.py create mode 100644 tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml create mode 100644 tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml create mode 100644 tests/component_tests/mt6701/test_mt6701.py create mode 100644 tests/components/mt6701_i2c/common.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-ard.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-c3-idf.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-idf.yaml create mode 100644 tests/components/mt6701_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/mt6701_i2c/test.host.yaml create mode 100644 tests/components/mt6701_i2c/test.rp2040-ard.yaml create mode 100644 tests/components/mt6701_spi/__init__.py create mode 100644 tests/components/mt6701_spi/common.yaml create mode 100644 tests/components/mt6701_spi/mt6701_spi_frame_test.cpp create mode 100644 tests/components/mt6701_spi/test.esp32-ard.yaml create mode 100644 tests/components/mt6701_spi/test.esp32-c3-idf.yaml create mode 100644 tests/components/mt6701_spi/test.esp32-idf.yaml create mode 100644 tests/components/mt6701_spi/test.esp8266-ard.yaml create mode 100644 tests/components/mt6701_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 539730a4a67..0def07d0237 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -428,6 +428,9 @@ esphome/components/mpl3115a2/* @kbickar esphome/components/mpu6886/* @fabaff esphome/components/ms8607/* @e28eta esphome/components/msa3xx/* @latonita +esphome/components/mt6701/* @slimcdk +esphome/components/mt6701_i2c/* @slimcdk +esphome/components/mt6701_spi/* @slimcdk esphome/components/nau7802/* @cujomalainey esphome/components/network/* @esphome/core esphome/components/nextion/* @edwardtfn @senexcrenshaw diff --git a/esphome/components/mt6701/__init__.py b/esphome/components/mt6701/__init__.py new file mode 100644 index 00000000000..9325b8299f1 --- /dev/null +++ b/esphome/components/mt6701/__init__.py @@ -0,0 +1,9 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@slimcdk"] +DOMAIN = "mt6701" + +mt6701_ns = cg.esphome_ns.namespace("mt6701") +MT6701Component = mt6701_ns.class_("MT6701Component", cg.Component) + +CONF_MT6701_ID = "mt6701_id" diff --git a/esphome/components/mt6701/mt6701.cpp b/esphome/components/mt6701/mt6701.cpp new file mode 100644 index 00000000000..46316b8e551 --- /dev/null +++ b/esphome/components/mt6701/mt6701.cpp @@ -0,0 +1,28 @@ +#include "mt6701.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701 { + +bool MT6701Component::read_encoder() { + if (this->is_failed() || this->suspend_sampling_) + return false; + + uint16_t count; + if (!this->read_count(count)) { + if (this->consecutive_errors_ < MAX_CONSECUTIVE_ERRORS) { + this->consecutive_errors_++; + if (this->consecutive_errors_ == MAX_CONSECUTIVE_ERRORS) + this->status_set_warning(LOG_STR("repeated read/CRC errors")); + } + return false; + } + if (this->consecutive_errors_ != 0) { + this->consecutive_errors_ = 0; + this->status_clear_warning(); + } + + this->count_ = count; + return true; +} + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/mt6701.h b/esphome/components/mt6701/mt6701.h new file mode 100644 index 00000000000..4795441f167 --- /dev/null +++ b/esphome/components/mt6701/mt6701.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::mt6701 { + +// A 14-bit absolute angle: one revolution is 16384 counts +static constexpr float COUNTS_TO_DEGREES = 360.0f / 16384; +// Consecutive read or CRC failures tolerated before the component reports a warning +static constexpr uint8_t MAX_CONSECUTIVE_ERRORS = 5; + +/// Bus-independent core; the I2C and SSI hubs provide read_count(), the entities poll it +class MT6701Component : public Component { + public: + /// Read a fresh angle; false on a bus or CRC error, while suspended, or when setup failed + bool read_encoder(); + + /// Most recent absolute angle as a raw count in the range [0, 16383]. + uint16_t get_count() const { return this->count_; } + /// Most recent absolute shaft angle in degrees, in the range [0, 360). + float get_angle_degrees() const { return this->count_ * COUNTS_TO_DEGREES; } + + protected: + /// The current 14-bit count from the bus; false on a bus or CRC error + virtual bool read_count(uint16_t &count) = 0; + + uint16_t count_{0}; + uint8_t consecutive_errors_{0}; + // Set by the I2C hub while the EEPROM is programmed; no bus access until it clears + bool suspend_sampling_{false}; +}; + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/sensor/__init__.py b/esphome/components/mt6701/sensor/__init__.py new file mode 100644 index 00000000000..617524b1917 --- /dev/null +++ b/esphome/components/mt6701/sensor/__init__.py @@ -0,0 +1,51 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ICON_ROTATE_RIGHT, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREES, + UNIT_EMPTY, +) +from esphome.types import ConfigType + +from .. import CONF_MT6701_ID, MT6701Component, mt6701_ns + +AUTO_LOAD = ["mt6701"] + +MT6701Sensor = mt6701_ns.class_("MT6701Sensor", sensor.Sensor, cg.PollingComponent) + +CONF_RAW_COUNT = "raw_count" + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + MT6701Sensor, + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=2, + icon=ICON_ROTATE_RIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.GenerateID(CONF_MT6701_ID): cv.use_id(MT6701Component), + cv.Optional(CONF_RAW_COUNT): sensor.sensor_schema( + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + icon=ICON_ROTATE_RIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_MT6701_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_RAW_COUNT, var.set_raw_count_sensor) diff --git a/esphome/components/mt6701/sensor/mt6701_sensor.cpp b/esphome/components/mt6701/sensor/mt6701_sensor.cpp new file mode 100644 index 00000000000..c849ef91397 --- /dev/null +++ b/esphome/components/mt6701/sensor/mt6701_sensor.cpp @@ -0,0 +1,23 @@ +#include "mt6701_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701 { + +ESPHOME_LOG_TAG(TAG, "mt6701.sensor"); + +void MT6701Sensor::update() { + if (!this->parent_->read_encoder()) + return; + + this->publish_state(this->parent_->get_angle_degrees()); + if (this->raw_count_sensor_ != nullptr) + this->raw_count_sensor_->publish_state(this->parent_->get_count()); +} + +void MT6701Sensor::dump_config() { + LOG_SENSOR("", "MT6701 Sensor", this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Raw Count", this->raw_count_sensor_); +} + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/sensor/mt6701_sensor.h b/esphome/components/mt6701/sensor/mt6701_sensor.h new file mode 100644 index 00000000000..2bf03dd7225 --- /dev/null +++ b/esphome/components/mt6701/sensor/mt6701_sensor.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/mt6701/mt6701.h" + +namespace esphome::mt6701 { + +/// The shaft angle in degrees, with the raw count as an optional sub-sensor +class MT6701Sensor final : public PollingComponent, public Parented, public sensor::Sensor { + SUB_SENSOR(raw_count) + + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701_i2c/__init__.py b/esphome/components/mt6701_i2c/__init__.py new file mode 100644 index 00000000000..17734e00459 --- /dev/null +++ b/esphome/components/mt6701_i2c/__init__.py @@ -0,0 +1,202 @@ +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_DIRECTION, CONF_HYSTERESIS, CONF_ID, CONF_MODE +from esphome.types import ConfigType + +from ..mt6701 import MT6701Component + +CODEOWNERS = ["@slimcdk"] +AUTO_LOAD = ["mt6701"] +DEPENDENCIES = ["i2c"] +DOMAIN = "mt6701_i2c" +MULTI_CONF = True + +mt6701_i2c_ns = cg.esphome_ns.namespace("mt6701_i2c") +MT6701I2CComponent = mt6701_i2c_ns.class_( + "MT6701I2CComponent", MT6701Component, i2c.I2CDevice +) + +CONF_ZERO_OFFSET = "zero_offset" +CONF_OUTPUT_MODE = "output_mode" +CONF_ABZ = "abz" +CONF_PULSES_PER_REVOLUTION = "pulses_per_revolution" +CONF_Z_PULSE_WIDTH = "z_pulse_width" +CONF_UVW = "uvw" +CONF_POLE_PAIRS = "pole_pairs" +CONF_OUT_PIN = "out_pin" +CONF_PWM_FREQUENCY = "pwm_frequency" +CONF_PWM_POLARITY = "pwm_polarity" +CONF_ANALOG_START = "analog_start" +CONF_ANALOG_STOP = "analog_stop" + +# DIR bit: 1 counts clockwise +DIRECTION = { + "CLOCKWISE": 1, + "COUNTERCLOCKWISE": 0, +} + +# Output LSBs to the HYST register code +HYSTERESIS = { + 0: 4, + 0.25: 5, + 0.5: 6, + 1: 0, + 2: 1, + 4: 2, + 8: 3, +} + +Z_PULSE_WIDTH = { + "1LSB": 0, + "2LSB": 1, + "4LSB": 2, + "8LSB": 3, + "12LSB": 4, + "16LSB": 5, + "180DEG": 6, +} + +OUTPUT_MODE = { + "ABZ": 0, + "UVW": 1, +} + +OUT_PIN_MODE = { + "ANALOG": 0, + "PWM": 1, +} + +PWM_FREQUENCY = { + "994.4HZ": 0, + "497.2HZ": 1, +} + +PWM_POLARITY = { + "HIGH": 0, + "LOW": 1, +} + +# Zero offset and analog start/stop are 12-bit positions over one revolution +RESOLUTION_12BIT = 4096 +MAX_POSITION_12BIT = RESOLUTION_12BIT - 1 +ANGLE_TO_POSITION_12 = RESOLUTION_12BIT / 360 + + +def _angle_to_position_12(value: Any) -> int: + value = cv.float_range(min=0, max=360)( + cv.float_with_unit("angle", "(°|deg)")(value) + ) + # 360° is not representable; clamp instead of wrapping to 0 + return min(round(value * ANGLE_TO_POSITION_12), MAX_POSITION_12BIT) + + +def _percent_to_position_12(value: Any) -> int: + value = cv.percentage(value) + return min(round(value * RESOLUTION_12BIT), MAX_POSITION_12BIT) + + +def position12(value: Any) -> int: + """A 12-bit position: a raw count, an angle (``45deg``, ``90°``) or a share of a turn (``25%``).""" + if isinstance(value, str) and value.endswith("%"): + return _percent_to_position_12(value) + if isinstance(value, str) and value.endswith(("°", "deg")): + return _angle_to_position_12(value) + return cv.int_range(min=0, max=MAX_POSITION_12BIT)(value) + + +def validate_hysteresis(value: Any) -> int: + value = cv.float_(value) + if value not in HYSTERESIS: + raise cv.Invalid( + f"Hysteresis must be one of {sorted(HYSTERESIS)} (LSBs), got {value}" + ) + return HYSTERESIS[value] + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MT6701I2CComponent), + cv.Optional(CONF_DIRECTION): cv.enum(DIRECTION, upper=True), + cv.Optional(CONF_ZERO_OFFSET): position12, + cv.Optional(CONF_HYSTERESIS): validate_hysteresis, + cv.Optional(CONF_OUTPUT_MODE): cv.enum(OUTPUT_MODE, upper=True), + cv.Optional(CONF_ABZ): cv.Schema( + { + cv.Optional(CONF_PULSES_PER_REVOLUTION): cv.int_range( + min=1, max=1024 + ), + cv.Optional(CONF_Z_PULSE_WIDTH): cv.enum(Z_PULSE_WIDTH, upper=True), + } + ), + cv.Optional(CONF_UVW): cv.Schema( + { + cv.Required(CONF_POLE_PAIRS): cv.int_range(min=1, max=16), + } + ), + cv.Optional(CONF_OUT_PIN): cv.Schema( + { + cv.Required(CONF_MODE): cv.enum(OUT_PIN_MODE, upper=True), + cv.Optional(CONF_PWM_FREQUENCY): cv.enum(PWM_FREQUENCY, upper=True), + cv.Optional(CONF_PWM_POLARITY): cv.enum(PWM_POLARITY, upper=True), + cv.Optional(CONF_ANALOG_START): position12, + cv.Optional(CONF_ANALOG_STOP): position12, + } + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x06)) +) + + +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 (direction := config.get(CONF_DIRECTION)) is not None: + cg.add(var.set_direction(direction)) + if (zero_offset := config.get(CONF_ZERO_OFFSET)) is not None: + cg.add(var.set_zero_offset(zero_offset)) + if (hysteresis := config.get(CONF_HYSTERESIS)) is not None: + cg.add(var.set_hysteresis(hysteresis)) + if (output_mode := config.get(CONF_OUTPUT_MODE)) is not None: + cg.add(var.set_output_mode(output_mode)) + + if abz := config.get(CONF_ABZ): + if (ppr := abz.get(CONF_PULSES_PER_REVOLUTION)) is not None: + cg.add(var.set_abz_pulses_per_revolution(ppr)) + if (width := abz.get(CONF_Z_PULSE_WIDTH)) is not None: + cg.add(var.set_z_pulse_width(width)) + + if uvw := config.get(CONF_UVW): + cg.add(var.set_uvw_pole_pairs(uvw[CONF_POLE_PAIRS])) + + if out_pin := config.get(CONF_OUT_PIN): + cg.add(var.set_out_pin_mode(out_pin[CONF_MODE])) + if (freq := out_pin.get(CONF_PWM_FREQUENCY)) is not None: + cg.add(var.set_pwm_frequency(freq)) + if (pol := out_pin.get(CONF_PWM_POLARITY)) is not None: + cg.add(var.set_pwm_polarity(pol)) + if (start := out_pin.get(CONF_ANALOG_START)) is not None: + cg.add(var.set_analog_start(start)) + if (stop := out_pin.get(CONF_ANALOG_STOP)) is not None: + cg.add(var.set_analog_stop(stop)) + + +MT6701_SAVE_EEPROM_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(MT6701I2CComponent), + } +) + +automation.register_apply_action( + "mt6701_i2c.save_eeprom", + MT6701_SAVE_EEPROM_ACTION_SCHEMA, + automation.ApplyCall("save_eeprom()"), +) diff --git a/esphome/components/mt6701_i2c/mt6701_i2c.cpp b/esphome/components/mt6701_i2c/mt6701_i2c.cpp new file mode 100644 index 00000000000..cf1094ee225 --- /dev/null +++ b/esphome/components/mt6701_i2c/mt6701_i2c.cpp @@ -0,0 +1,184 @@ +#include "mt6701_i2c.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_i2c { + +ESPHOME_LOG_TAG(TAG, "mt6701_i2c"); + +// Registers apply_config_() may write, each read-modify-written once +static const uint8_t CONFIG_REGS[] PROGMEM = { + REG_ABZ_MUX_DIR, REG_RES_H, REG_ABZ_RES_L, REG_CONFIG_H, REG_ZERO_L, + REG_HYST_L, REG_OUT, REG_A_HIGH, REG_A_START_L, REG_A_STOP_L, +}; +static constexpr uint8_t CONFIG_REG_COUNT = sizeof(CONFIG_REGS); + +void MT6701I2CComponent::setup() { + uint16_t count; + if (!this->read_count(count)) { + this->mark_failed(LOG_STR("not responding on I2C")); + return; + } + this->apply_config_(); +} + +void MT6701I2CComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 (I2C):"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + if (this->direction_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Direction bit: %u", this->direction_); + } + if (this->zero_offset_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Zero offset: %u", this->zero_offset_); + } + if (this->hysteresis_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Hysteresis code: %u", this->hysteresis_); + } + if (this->output_mode_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Output mode: %s", this->output_mode_ != 0 ? LOG_STR_LITERAL("UVW") : LOG_STR_LITERAL("ABZ")); + } + if (this->abz_pulses_per_revolution_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " ABZ pulses/rev: %u", this->abz_pulses_per_revolution_); + } + if (this->z_pulse_width_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Z pulse width code: %u", this->z_pulse_width_); + } + if (this->uvw_pole_pairs_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " UVW pole pairs: %u", this->uvw_pole_pairs_); + } + if (this->out_pin_mode_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Output pin mode: %s", + this->out_pin_mode_ != 0 ? LOG_STR_LITERAL("PWM") : LOG_STR_LITERAL("analog")); + } + if (this->pwm_frequency_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " PWM frequency code: %u", this->pwm_frequency_); + } + if (this->pwm_polarity_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " PWM polarity code: %u", this->pwm_polarity_); + } + if (this->analog_start_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Analog start: %u", this->analog_start_); + } + if (this->analog_stop_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Analog stop: %u", this->analog_stop_); + } +} + +bool MT6701I2CComponent::read_count(uint16_t &count) { + // Single-byte reads, 0x03 first; datasheet 1.9 documents no burst read + uint8_t high; + uint8_t low; + if (this->read_register(REG_ANGLE_H, &high, 1) != i2c::ERROR_OK || + this->read_register(REG_ANGLE_L, &low, 1) != i2c::ERROR_OK) + return false; + count = encode_uint16(high, low) >> 2; + return true; +} + +bool MT6701I2CComponent::update_register_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t current; + if (this->read_register(reg, ¤t, 1) != i2c::ERROR_OK) + return false; + uint8_t updated = (current & ~mask) | (value & mask); + if (updated == current) + return true; + return this->write_register(reg, &updated, 1) == i2c::ERROR_OK; +} + +void MT6701I2CComponent::apply_config_() { + // Collect the bits per register first; several options share one register + uint8_t masks[CONFIG_REG_COUNT] = {}; + uint8_t values[CONFIG_REG_COUNT] = {}; + auto add = [&masks, &values](uint8_t reg, uint8_t mask, uint8_t value) { + for (uint8_t i = 0; i < CONFIG_REG_COUNT; i++) { + if (progmem_read_byte(&CONFIG_REGS[i]) == reg) { + masks[i] |= mask; + values[i] |= value & mask; + return; + } + } + }; + + if (this->direction_ != UNSET_U8) + add(REG_ABZ_MUX_DIR, 0x02, this->direction_ << 1); + if (this->output_mode_ != UNSET_U8) + add(REG_ABZ_MUX_DIR, 0x40, (this->output_mode_ & 0x01) << 6); + if (this->zero_offset_ != UNSET_U16) { + add(REG_CONFIG_H, 0x0F, this->zero_offset_ >> 8); + add(REG_ZERO_L, 0xFF, this->zero_offset_ & 0xFF); + } + if (this->hysteresis_ != UNSET_U8) { + add(REG_CONFIG_H, 0x80, (this->hysteresis_ & 0x04) << 5); // HYST[2] -> bit7 + add(REG_HYST_L, 0xC0, (this->hysteresis_ & 0x03) << 6); // HYST[1:0] -> bits7:6 + } + if (this->abz_pulses_per_revolution_ != UNSET_U16) { + // ABZ_RES holds pulses per revolution minus 1 + uint16_t res = this->abz_pulses_per_revolution_ - 1; + add(REG_RES_H, 0x03, res >> 8); + add(REG_ABZ_RES_L, 0xFF, res & 0xFF); + } + if (this->z_pulse_width_ != UNSET_U8) + add(REG_CONFIG_H, 0x70, (this->z_pulse_width_ & 0x07) << 4); + if (this->uvw_pole_pairs_ != UNSET_U8) { + // UVW_RES holds pole pairs minus 1 + add(REG_RES_H, 0xF0, (this->uvw_pole_pairs_ - 1) << 4); + } + if (this->out_pin_mode_ != UNSET_U8) + add(REG_OUT, 0x20, (this->out_pin_mode_ & 0x01) << 5); + if (this->pwm_frequency_ != UNSET_U8) + add(REG_OUT, 0x80, (this->pwm_frequency_ & 0x01) << 7); + if (this->pwm_polarity_ != UNSET_U8) + add(REG_OUT, 0x40, (this->pwm_polarity_ & 0x01) << 6); + if (this->analog_start_ != UNSET_U16) { + add(REG_A_HIGH, 0x0F, this->analog_start_ >> 8); + add(REG_A_START_L, 0xFF, this->analog_start_ & 0xFF); + } + if (this->analog_stop_ != UNSET_U16) { + add(REG_A_HIGH, 0xF0, (this->analog_stop_ >> 8) << 4); + add(REG_A_STOP_L, 0xFF, this->analog_stop_ & 0xFF); + } + + bool ok = true; + for (uint8_t i = 0; i < CONFIG_REG_COUNT; i++) { + if (masks[i] != 0) + ok &= this->update_register_(progmem_read_byte(&CONFIG_REGS[i]), masks[i], values[i]); + } + if (!ok) { + // A half-configured chip must not be sampled or have its EEPROM saved + this->mark_failed(LOG_STR("configuration register write failed")); + } +} + +void MT6701I2CComponent::save_eeprom() { + if (this->is_failed()) { + // A failed component gets no timeouts, so the bus would stay suspended + ESP_LOGW(TAG, "Not programming EEPROM: component is marked failed"); + return; + } + if (this->suspend_sampling_) { + ESP_LOGW(TAG, "EEPROM programming already in progress"); + return; + } + ESP_LOGW(TAG, "Programming MT6701 EEPROM. The EEPROM has a limited number of write " + "cycles, so avoid doing this repeatedly. Supply must be 4.5-5.5 V."); + this->suspend_sampling_ = true; + + bool ok = this->write_byte(REG_EEPROM_KEY, EEPROM_KEY_VALUE) && this->write_byte(REG_EEPROM_CMD, EEPROM_CMD_VALUE); + if (!ok) { + ESP_LOGE(TAG, "Failed to start EEPROM programming"); + this->suspend_sampling_ = false; + return; + } + + // suspend_sampling_ keeps a second save out, so the timer needs no name + this->set_timeout(EEPROM_PROGRAM_DELAY_MS, [this]() { + this->suspend_sampling_ = false; + ESP_LOGI(TAG, "EEPROM programming finished. Power-cycle the MT6701 to verify the stored configuration"); + }); +} + +} // namespace esphome::mt6701_i2c diff --git a/esphome/components/mt6701_i2c/mt6701_i2c.h b/esphome/components/mt6701_i2c/mt6701_i2c.h new file mode 100644 index 00000000000..7d2d91d6986 --- /dev/null +++ b/esphome/components/mt6701_i2c/mt6701_i2c.h @@ -0,0 +1,79 @@ +#pragma once + +#include "esphome/components/mt6701/mt6701.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::mt6701_i2c { + +// Angle: D[13:6] at 0x03, D[5:0] in bits 7:2 of 0x04 +static constexpr uint8_t REG_ANGLE_H = 0x03; +static constexpr uint8_t REG_ANGLE_L = 0x04; + +// EEPROM programming (datasheet 8.2) needs more than 600 ms left alone afterwards +static constexpr uint8_t REG_EEPROM_KEY = 0x09; +static constexpr uint8_t REG_EEPROM_CMD = 0x0A; +static constexpr uint8_t EEPROM_KEY_VALUE = 0xB3; +static constexpr uint8_t EEPROM_CMD_VALUE = 0x05; +static constexpr uint32_t EEPROM_PROGRAM_DELAY_MS = 700; + +// Configuration registers; reserved bits make every write a read-modify-write +static constexpr uint8_t REG_ABZ_MUX_DIR = 0x29; // bit6 ABZ_MUX, bit1 DIR +static constexpr uint8_t REG_RES_H = 0x30; // bits7:4 UVW_RES, bits1:0 ABZ_RES[9:8] +static constexpr uint8_t REG_ABZ_RES_L = 0x31; // ABZ_RES[7:0] +static constexpr uint8_t REG_CONFIG_H = 0x32; // bit7 HYST[2], bits6:4 Z_PULSE_WIDTH, bits3:0 ZERO[11:8] +static constexpr uint8_t REG_ZERO_L = 0x33; // ZERO[7:0] +static constexpr uint8_t REG_HYST_L = 0x34; // bits7:6 HYST[1:0] +static constexpr uint8_t REG_OUT = 0x38; // bit7 PWM_FREQ, bit6 PWM_POL, bit5 OUT_MODE +static constexpr uint8_t REG_A_HIGH = 0x3E; // bits7:4 A_STOP[11:8], bits3:0 A_START[11:8] +static constexpr uint8_t REG_A_START_L = 0x3F; // A_START[7:0] +static constexpr uint8_t REG_A_STOP_L = 0x40; // A_STOP[7:0] + +/// MT6701 over I2C, with the configuration registers +class MT6701I2CComponent final : public mt6701::MT6701Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + // Options left unset are never written, so the chip keeps its EEPROM defaults + void set_direction(uint8_t direction) { this->direction_ = direction; } + void set_zero_offset(uint16_t position) { this->zero_offset_ = position; } + void set_hysteresis(uint8_t hysteresis) { this->hysteresis_ = hysteresis; } + void set_output_mode(uint8_t output_mode) { this->output_mode_ = output_mode; } + void set_abz_pulses_per_revolution(uint16_t pulses) { this->abz_pulses_per_revolution_ = pulses; } + void set_z_pulse_width(uint8_t width) { this->z_pulse_width_ = width; } + void set_uvw_pole_pairs(uint8_t pole_pairs) { this->uvw_pole_pairs_ = pole_pairs; } + void set_out_pin_mode(uint8_t out_pin_mode) { this->out_pin_mode_ = out_pin_mode; } + void set_pwm_frequency(uint8_t pwm_frequency) { this->pwm_frequency_ = pwm_frequency; } + void set_pwm_polarity(uint8_t pwm_polarity) { this->pwm_polarity_ = pwm_polarity; } + void set_analog_start(uint16_t position) { this->analog_start_ = position; } + void set_analog_stop(uint16_t position) { this->analog_stop_ = position; } + + /// Write the registers to the EEPROM: pauses sampling for the programming time. + /// The EEPROM wears, so only an explicit action calls this. + void save_eeprom(); + + protected: + bool read_count(uint16_t &count) override; + + void apply_config_(); + bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); + + // Unset options sit above their field's range + static constexpr uint16_t UNSET_U16 = 0xFFFF; + static constexpr uint8_t UNSET_U8 = 0xFF; + + uint16_t zero_offset_{UNSET_U16}; + uint16_t abz_pulses_per_revolution_{UNSET_U16}; + uint16_t analog_start_{UNSET_U16}; + uint16_t analog_stop_{UNSET_U16}; + uint8_t direction_{UNSET_U8}; + uint8_t hysteresis_{UNSET_U8}; + uint8_t output_mode_{UNSET_U8}; + uint8_t z_pulse_width_{UNSET_U8}; + uint8_t uvw_pole_pairs_{UNSET_U8}; + uint8_t out_pin_mode_{UNSET_U8}; + uint8_t pwm_frequency_{UNSET_U8}; + uint8_t pwm_polarity_{UNSET_U8}; +}; + +} // namespace esphome::mt6701_i2c diff --git a/esphome/components/mt6701_spi/__init__.py b/esphome/components/mt6701_spi/__init__.py new file mode 100644 index 00000000000..6f45e347b7d --- /dev/null +++ b/esphome/components/mt6701_spi/__init__.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import spi +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from ..mt6701 import MT6701Component + +CODEOWNERS = ["@slimcdk"] +AUTO_LOAD = ["mt6701"] +DEPENDENCIES = ["spi"] +DOMAIN = "mt6701_spi" +MULTI_CONF = True + +mt6701_spi_ns = cg.esphome_ns.namespace("mt6701_spi") +MT6701SPIComponent = mt6701_spi_ns.class_( + "MT6701SPIComponent", MT6701Component, spi.SPIDevice +) + +CONF_MT6701_SPI_ID = "mt6701_spi_id" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MT6701SPIComponent), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend( + spi.spi_device_schema( + cs_pin_required=True, + default_data_rate=1_000_000, + default_mode="MODE1", + ) + ) +) + +# SSI only reads, so the bus needs its input line +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "mt6701_spi", require_mosi=False, require_miso=True +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/mt6701_spi/binary_sensor/__init__.py b/esphome/components/mt6701_spi/binary_sensor/__init__.py new file mode 100644 index 00000000000..f06dc31aa5f --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/__init__.py @@ -0,0 +1,46 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_PROBLEM, + ENTITY_CATEGORY_DIAGNOSTIC, +) +from esphome.types import ConfigType + +from .. import CONF_MT6701_SPI_ID, MT6701SPIComponent, mt6701_spi_ns + +DEPENDENCIES = ["mt6701_spi"] + +CONF_PUSH_BUTTON = "push_button" +CONF_TRACK_LOSS = "track_loss" + +MT6701SPIBinarySensors = mt6701_spi_ns.class_( + "MT6701SPIBinarySensors", cg.Component, cg.Parented.template(MT6701SPIComponent) +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(MT6701SPIBinarySensors), + cv.GenerateID(CONF_MT6701_SPI_ID): cv.use_id(MT6701SPIComponent), + # A press has to be seen between two reads, so the default is short + cv.Optional( + CONF_UPDATE_INTERVAL, default="50ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_PUSH_BUTTON): binary_sensor.binary_sensor_schema(), + cv.Optional(CONF_TRACK_LOSS): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_MT6701_SPI_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PUSH_BUTTON, var.set_push_button_binary_sensor) + await binary_sensors(CONF_TRACK_LOSS, var.set_track_loss_binary_sensor) diff --git a/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp new file mode 100644 index 00000000000..9af88d056e2 --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp @@ -0,0 +1,29 @@ +#include "mt6701_spi_binary_sensors.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi.binary_sensor"); + +void MT6701SPIBinarySensors::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_read_ < this->update_interval_) + return; + this->last_read_ = now; + if (!this->parent_->read_encoder()) + return; + const uint8_t status = this->parent_->get_status(); + if (this->push_button_binary_sensor_ != nullptr) + this->push_button_binary_sensor_->publish_state((status & 0x04) != 0); + if (this->track_loss_binary_sensor_ != nullptr) + this->track_loss_binary_sensor_->publish_state((status & 0x08) != 0); +} + +void MT6701SPIBinarySensors::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 status:\n Update Interval: %" PRIu32 " ms", this->update_interval_); + LOG_BINARY_SENSOR(" ", "Push button", this->push_button_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Track loss", this->track_loss_binary_sensor_); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h new file mode 100644 index 00000000000..646f6341c40 --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/mt6701_spi/mt6701_spi.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::mt6701_spi { + +// Reads the encoder at its own interval and publishes the status flags of each frame +class MT6701SPIBinarySensors : public Component, public Parented { + SUB_BINARY_SENSOR(push_button) + SUB_BINARY_SENSOR(track_loss) + + public: + void set_update_interval(uint32_t update_interval) { this->update_interval_ = update_interval; } + void loop() override; + void dump_config() override; + + protected: + uint32_t update_interval_{50}; + uint32_t last_read_{0}; +}; + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/mt6701_spi.cpp b/esphome/components/mt6701_spi/mt6701_spi.cpp new file mode 100644 index 00000000000..493b7ded3f7 --- /dev/null +++ b/esphome/components/mt6701_spi/mt6701_spi.cpp @@ -0,0 +1,67 @@ +#include "mt6701_spi.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi"); + +// The 6-bit CRC (x^6 + x + 1) over 18 bits is crc8 with the polynomial shifted up by two +static constexpr uint8_t CRC6_AS_CRC8_POLY = 0x0C; + +bool decode_frame(const uint8_t frame[3], uint16_t &count, uint8_t &status) { + if (crc8(frame, 3, 0x00, CRC6_AS_CRC8_POLY, true) != 0) + return false; + const uint32_t bits = encode_uint24(frame[0], frame[1], frame[2]); + count = bits >> 10; + status = (bits >> 6) & 0x0F; + return true; +} + +ProbeResult probe_result(uint8_t valid_frames, bool any_nonzero) { + if (valid_frames < PROBE_MIN_VALID) + return ProbeResult::PROBE_RESULT_NO_ENCODER; + return any_nonzero ? ProbeResult::PROBE_RESULT_OK : ProbeResult::PROBE_RESULT_ALL_ZERO; +} + +void MT6701SPIComponent::setup() { + this->spi_setup(); + // All-zero frames only warn: an encoder at rest looks like a stuck-low data line + uint8_t valid = 0; + bool nonzero = false; + for (uint8_t i = 0; i < PROBE_FRAMES && (valid < PROBE_MIN_VALID || !nonzero); i++) { + uint16_t count; + if (this->read_count(count)) { + valid++; + nonzero |= count != 0 || this->status_ != 0; + } + } + switch (probe_result(valid, nonzero)) { + case ProbeResult::PROBE_RESULT_NO_ENCODER: + this->mark_failed(LOG_STR("no valid frame received, check the wiring and CS pin")); + break; + case ProbeResult::PROBE_RESULT_ALL_ZERO: + ESP_LOGW(TAG, "Every frame read zero: the data line may be stuck low"); + break; + case ProbeResult::PROBE_RESULT_OK: + break; + } +} + +void MT6701SPIComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 (SSI/SPI):"); + LOG_PIN(" CS Pin: ", this->cs_); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool MT6701SPIComponent::read_count(uint16_t &count) { + uint8_t buffer[3] = {0, 0, 0}; + this->enable(); + this->read_array(buffer, 3); + this->disable(); + return decode_frame(buffer, count, this->status_); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/mt6701_spi.h b/esphome/components/mt6701_spi/mt6701_spi.h new file mode 100644 index 00000000000..fed7217a1e0 --- /dev/null +++ b/esphome/components/mt6701_spi/mt6701_spi.h @@ -0,0 +1,41 @@ +#pragma once + +#include "esphome/components/mt6701/mt6701.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::mt6701_spi { + +/// Decode a 24-bit SSI frame (14-bit angle, 4 status bits, 6-bit CRC); false when the CRC fails +bool decode_frame(const uint8_t frame[3], uint16_t &count, uint8_t &status); + +// Noise passes the 6-bit CRC 1 time in 64, so setup wants several good frames +static constexpr uint8_t PROBE_FRAMES = 10; +static constexpr uint8_t PROBE_MIN_VALID = 3; + +enum class ProbeResult : uint8_t { + PROBE_RESULT_NO_ENCODER, + PROBE_RESULT_ALL_ZERO, // a stuck-low data line, or an encoder at rest + PROBE_RESULT_OK, +}; +ProbeResult probe_result(uint8_t valid_frames, bool any_nonzero); + +/// MT6701 over SSI: read-only, but each frame carries the field, push button and track-loss flags. +/// Data shifts out on the rising clock edge, so it is sampled on the falling edge with the clock +/// idle low; a clock idling high reads an extra leading bit. +class MT6701SPIComponent final : public mt6701::MT6701Component, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + /// The 4-bit status of the last good frame: bit3 track loss, bit2 push button, bits1:0 field strength + uint8_t get_status() const { return this->status_; } + + protected: + bool read_count(uint16_t &count) override; + + uint8_t status_{0}; +}; + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/text_sensor/__init__.py b/esphome/components/mt6701_spi/text_sensor/__init__.py new file mode 100644 index 00000000000..bbec4a40e4f --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/__init__.py @@ -0,0 +1,36 @@ +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_MAGNET +from esphome.types import ConfigType + +from .. import CONF_MT6701_SPI_ID, MT6701SPIComponent, mt6701_spi_ns + +DEPENDENCIES = ["mt6701_spi"] + +MT6701SPIFieldStatus = mt6701_spi_ns.class_( + "MT6701SPIFieldStatus", + text_sensor.TextSensor, + cg.PollingComponent, + cg.Parented.template(MT6701SPIComponent), +) + +CONFIG_SCHEMA = ( + text_sensor.text_sensor_schema( + MT6701SPIFieldStatus, + icon=ICON_MAGNET, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ) + .extend( + { + cv.GenerateID(CONF_MT6701_SPI_ID): cv.use_id(MT6701SPIComponent), + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await text_sensor.new_text_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_MT6701_SPI_ID]) diff --git a/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp new file mode 100644 index 00000000000..5db30c4b077 --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp @@ -0,0 +1,28 @@ +#include "mt6701_spi_field_status.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi.text_sensor"); + +// Indexed by Mg[1:0]; 0b11 is reserved in the datasheet +PROGMEM_STRING_TABLE(FieldStatusStrings, "OK", "TOO_STRONG", "TOO_WEAK", "UNKNOWN"); + +void MT6701SPIFieldStatus::update() { + if (!this->parent_->read_encoder()) + return; + char text[FieldStatusStrings::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(text, + reinterpret_cast(FieldStatusStrings::get_progmem_str( + this->parent_->get_status() & 0x03, FieldStatusStrings::LAST_INDEX)), + sizeof(text)); + this->publish_state(text); +} + +void MT6701SPIFieldStatus::dump_config() { + LOG_TEXT_SENSOR("", "MT6701 field status", this); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h new file mode 100644 index 00000000000..200cc78a7fb --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/mt6701_spi/mt6701_spi.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::mt6701_spi { + +// Reports the magnetic field strength the status bits Mg[1:0] carry +class MT6701SPIFieldStatus : public text_sensor::TextSensor, + public PollingComponent, + public Parented { + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::mt6701_spi diff --git a/tests/component_tests/mt6701/__init__.py b/tests/component_tests/mt6701/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml b/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml new file mode 100644 index 00000000000..135ee0087ac --- /dev/null +++ b/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml @@ -0,0 +1,19 @@ +esphome: + name: mt6701-modes-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + - id: i2c_bus + sda: GPIO21 + scl: GPIO22 + +mt6701_i2c: + id: encoder_hub + i2c_id: i2c_bus + output_mode: ABZ + out_pin: + mode: ANALOG diff --git a/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml b/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml new file mode 100644 index 00000000000..a509bc481df --- /dev/null +++ b/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml @@ -0,0 +1,21 @@ +esphome: + name: mt6701-modes-uvw-pwm-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + - id: i2c_bus + sda: GPIO21 + scl: GPIO22 + +mt6701_i2c: + id: encoder_hub + i2c_id: i2c_bus + output_mode: UVW + uvw: + pole_pairs: 7 + out_pin: + mode: PWM diff --git a/tests/component_tests/mt6701/test_mt6701.py b/tests/component_tests/mt6701/test_mt6701.py new file mode 100644 index 00000000000..05a9ff93286 --- /dev/null +++ b/tests/component_tests/mt6701/test_mt6701.py @@ -0,0 +1,95 @@ +"""Config-validation tests for the mt6701 encoder component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.mt6701_i2c import DIRECTION, position12, validate_hysteresis +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # raw 12-bit counts pass through unchanged + (0, 0), + (2048, 2048), + (4095, 4095), + # angles are converted to counts + ("0deg", 0), + ("45deg", 512), + ("90°", 1024), + # a full turn is not representable and must clamp to the max, not wrap to 0 + ("360deg", 4095), + # percentages of a full revolution + ("25%", 1024), + ("50%", 2048), + ("100%", 4095), + ], +) +def test_position12_valid(value: int | str, expected: int) -> None: + assert position12(value) == expected + + +@pytest.mark.parametrize("value", [-1, 4096, 5000, "400deg", "-10deg"]) +def test_position12_rejects_out_of_range(value: int | str) -> None: + with pytest.raises(cv.Invalid): + position12(value) + + +@pytest.mark.parametrize( + ("lsb", "register_code"), + [(0, 4), (0.25, 5), (0.5, 6), (1, 0), (2, 1), (4, 2), (8, 3)], +) +def test_hysteresis_maps_lsb_to_register_code(lsb: float, register_code: int) -> None: + assert validate_hysteresis(lsb) == register_code + + +@pytest.mark.parametrize("value", [3, 5, 16]) +def test_hysteresis_rejects_unsupported_step(value: int) -> None: + with pytest.raises(cv.Invalid): + validate_hysteresis(value) + + +def test_direction_follows_datasheet_semantics() -> None: + # Datasheet section 8.1: DIR=1 makes the angle increase clockwise. + assert DIRECTION["CLOCKWISE"] == 1 + assert DIRECTION["COUNTERCLOCKWISE"] == 0 + + +@pytest.mark.parametrize( + ("config_file", "output_mode_call", "out_pin_call"), + [ + # ABZ and ANALOG are register code 0 + ( + "mt6701_i2c_modes.yaml", + "set_output_mode(0)", + "set_out_pin_mode(0)", + ), + # UVW and PWM are register code 1 + ( + "mt6701_i2c_modes_uvw_pwm.yaml", + "set_output_mode(1)", + "set_out_pin_mode(1)", + ), + ], +) +def test_enum_options_generate_register_codes( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + output_mode_call: str, + out_pin_call: str, +) -> None: + """cv.enum returns the key string; codegen must emit its register code. + + Both codes are pinned, so that hardcoding a constant or reading a different + field is caught. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert output_mode_call in main_cpp + assert out_pin_call in main_cpp diff --git a/tests/components/mt6701_i2c/common.yaml b/tests/components/mt6701_i2c/common.yaml new file mode 100644 index 00000000000..fbcf4512f08 --- /dev/null +++ b/tests/components/mt6701_i2c/common.yaml @@ -0,0 +1,33 @@ +mt6701_i2c: + id: mt6701_i2c_encoder + i2c_id: i2c_bus + address: 0x06 + direction: CLOCKWISE + zero_offset: 45deg + hysteresis: 1 + output_mode: ABZ + abz: + pulses_per_revolution: 1024 + z_pulse_width: 1LSB + uvw: + pole_pairs: 8 + out_pin: + mode: PWM + pwm_frequency: 994.4Hz + pwm_polarity: HIGH + analog_start: 0deg + analog_stop: 360deg + +sensor: + - platform: mt6701 + mt6701_id: mt6701_i2c_encoder + name: MT6701 I2C Angle + update_interval: 100ms + raw_count: + name: MT6701 I2C Raw Count + +button: + - platform: template + name: MT6701 I2C Save EEPROM + on_press: + - mt6701_i2c.save_eeprom: mt6701_i2c_encoder diff --git a/tests/components/mt6701_i2c/test.esp32-ard.yaml b/tests/components/mt6701_i2c/test.esp32-ard.yaml new file mode 100644 index 00000000000..dced81081e2 --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml b/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..3837e73028f --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-c3-idf.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp32-idf.yaml b/tests/components/mt6701_i2c/test.esp32-idf.yaml new file mode 100644 index 00000000000..a4ce3b5d391 --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp8266-ard.yaml b/tests/components/mt6701_i2c/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7639027368f --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.host.yaml b/tests/components/mt6701_i2c/test.host.yaml new file mode 100644 index 00000000000..dd5d5faf9d7 --- /dev/null +++ b/tests/components/mt6701_i2c/test.host.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/host.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.rp2040-ard.yaml b/tests/components/mt6701_i2c/test.rp2040-ard.yaml new file mode 100644 index 00000000000..06082480a6a --- /dev/null +++ b/tests/components/mt6701_i2c/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_spi/__init__.py b/tests/components/mt6701_spi/__init__.py new file mode 100644 index 00000000000..7937e0202db --- /dev/null +++ b/tests/components/mt6701_spi/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The frame tests call the decoder directly; no bus is driven + manifest.dependencies = manifest.dependencies + ["spi"] diff --git a/tests/components/mt6701_spi/common.yaml b/tests/components/mt6701_spi/common.yaml new file mode 100644 index 00000000000..093ba44dfed --- /dev/null +++ b/tests/components/mt6701_spi/common.yaml @@ -0,0 +1,27 @@ +mt6701_spi: + id: mt6701_spi_encoder + spi_id: spi_bus + cs_pin: ${cs_pin} + +sensor: + - platform: mt6701 + mt6701_id: mt6701_spi_encoder + name: MT6701 SPI Angle + update_interval: 100ms + raw_count: + name: MT6701 SPI Raw Count + +binary_sensor: + - platform: mt6701_spi + mt6701_spi_id: mt6701_spi_encoder + update_interval: 20ms + push_button: + name: MT6701 SPI Button + track_loss: + name: MT6701 SPI Track Loss + +text_sensor: + - platform: mt6701_spi + mt6701_spi_id: mt6701_spi_encoder + name: MT6701 SPI Field Status + update_interval: 10s diff --git a/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp b/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp new file mode 100644 index 00000000000..43392601aca --- /dev/null +++ b/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/mt6701_spi/mt6701_spi.h" + +namespace esphome::mt6701_spi::testing { + +namespace { + +// The datasheet's CRC: x^6 + x + 1 over the 18 data bits, most significant bit first, zero seed +uint8_t crc6(uint32_t data18) { + uint8_t crc = 0; + for (int bit = 17; bit >= 0; bit--) { + const uint8_t in = ((data18 >> bit) & 1) ^ ((crc >> 5) & 1); + crc = ((crc << 1) & 0x3F) ^ (in ? 0x03 : 0); + } + return crc; +} + +// A frame as the chip sends it: 14 angle bits, 4 status bits, 6 CRC bits +std::array make_frame(uint16_t count, uint8_t status) { + const uint32_t data18 = (static_cast(count & 0x3FFF) << 4) | (status & 0x0F); + const uint32_t frame = (data18 << 6) | crc6(data18); + return {static_cast(frame >> 16), static_cast(frame >> 8), static_cast(frame)}; +} + +} // namespace + +TEST(MT6701SpiFrame, DecodesANonZeroReading) { + const auto frame = make_frame(0x1234, 0b0101); + uint16_t count = 0; + uint8_t status = 0; + ASSERT_TRUE(decode_frame(frame.data(), count, status)); + EXPECT_EQ(count, 0x1234); + EXPECT_EQ(status, 0b0101); +} + +TEST(MT6701SpiFrame, AZeroReadingIsValid) { + const auto frame = make_frame(0, 0); + EXPECT_EQ(frame, (std::array{0, 0, 0})); + uint16_t count = 1; + uint8_t status = 1; + ASSERT_TRUE(decode_frame(frame.data(), count, status)); + EXPECT_EQ(count, 0); + EXPECT_EQ(status, 0); +} + +TEST(MT6701SpiFrame, EveryAngleAndStatusRoundTrips) { + for (uint32_t count = 0; count < 16384; count += 37) { + for (uint8_t status = 0; status < 16; status++) { + const auto frame = make_frame(count, status); + uint16_t got_count = 0; + uint8_t got_status = 0; + ASSERT_TRUE(decode_frame(frame.data(), got_count, got_status)) << count << " " << status; + EXPECT_EQ(got_count, count); + EXPECT_EQ(got_status, status); + } + } +} + +TEST(MT6701SpiFrame, ACorruptedBitFailsTheCrc) { + const auto good = make_frame(0x2ACE, 0b1010); + for (int bit = 0; bit < 24; bit++) { + auto frame = good; + frame[2 - bit / 8] ^= 1 << (bit % 8); + uint16_t count = 0; + uint8_t status = 0; + EXPECT_FALSE(decode_frame(frame.data(), count, status)) << "bit " << bit; + } +} + +TEST(MT6701SpiFrame, StatusBitsAreTheFieldPushAndTrackFlags) { + uint16_t count = 0; + uint8_t status = 0; + // Mg[1:0] = 2 (field too weak), Mg[2] push button, Mg[3] loss of track + ASSERT_TRUE(decode_frame(make_frame(100, 0b1110).data(), count, status)); + EXPECT_EQ(status & 0x03, 2); + EXPECT_TRUE(status & 0x04); + EXPECT_TRUE(status & 0x08); +} + +TEST(MT6701SpiProbe, ThreeGoodFramesProveTheEncoder) { + EXPECT_EQ(probe_result(0, false), ProbeResult::PROBE_RESULT_NO_ENCODER); + EXPECT_EQ(probe_result(PROBE_MIN_VALID - 1, true), ProbeResult::PROBE_RESULT_NO_ENCODER); + EXPECT_EQ(probe_result(PROBE_MIN_VALID, true), ProbeResult::PROBE_RESULT_OK); + EXPECT_EQ(probe_result(PROBE_FRAMES, true), ProbeResult::PROBE_RESULT_OK); +} + +TEST(MT6701SpiProbe, AllZeroFramesOnlyWarn) { + EXPECT_EQ(probe_result(PROBE_MIN_VALID, false), ProbeResult::PROBE_RESULT_ALL_ZERO); + EXPECT_EQ(probe_result(PROBE_FRAMES, false), ProbeResult::PROBE_RESULT_ALL_ZERO); +} + +} // namespace esphome::mt6701_spi::testing diff --git a/tests/components/mt6701_spi/test.esp32-ard.yaml b/tests/components/mt6701_spi/test.esp32-ard.yaml new file mode 100644 index 00000000000..6d06dd7bc59 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-ard.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp32-c3-idf.yaml b/tests/components/mt6701_spi/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..c9de669b984 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-c3-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO7 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-c3-idf.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp32-idf.yaml b/tests/components/mt6701_spi/test.esp32-idf.yaml new file mode 100644 index 00000000000..642c4d90300 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp8266-ard.yaml b/tests/components/mt6701_spi/test.esp8266-ard.yaml new file mode 100644 index 00000000000..e7b3a4401da --- /dev/null +++ b/tests/components/mt6701_spi/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO15 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.rp2040-ard.yaml b/tests/components/mt6701_spi/test.rp2040-ard.yaml new file mode 100644 index 00000000000..23435b8fb1f --- /dev/null +++ b/tests/components/mt6701_spi/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + mt6701_spi: !include common.yaml From 928cf7d69671b7f6971ece696cfa8bcf77915612 Mon Sep 17 00:00:00 2001 From: Michele Porelli Date: Sat, 17 Oct 2026 17:59:40 -0700 Subject: [PATCH 538/651] [libretiny] Emulate rtc preference storage on the Beken BK7231T, BK7231N and BK7238 with flash and the reset reason (#21007) --- .../components/libretiny/preference_backend.h | 10 ++ esphome/components/libretiny/preferences.cpp | 95 +++++++++++++++++-- esphome/components/libretiny/preferences.h | 11 ++- esphome/core/defines.h | 1 + esphome/preferences.py | 42 ++++++-- tests/unit_tests/test_preferences.py | 53 +++++++++++ 6 files changed, 192 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/preference_backend.h b/esphome/components/libretiny/preference_backend.h index f7f8279ac08..00d76d7d862 100644 --- a/esphome/components/libretiny/preference_backend.h +++ b/esphome/components/libretiny/preference_backend.h @@ -1,6 +1,8 @@ #pragma once #ifdef USE_LIBRETINY +#include "esphome/core/defines.h" + #include #include @@ -20,6 +22,14 @@ class LibreTinyPreferenceBackend final { uint32_t key; struct fdb_kvdb *db; struct fdb_blob *blob; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + // storage: rtc lives under its own FlashDB name prefix, so the database can drop every rtc entry + // at once after a boot that RTC memory would not have survived + bool rtc{false}; + bool is_rtc() const { return this->rtc; } +#else + static constexpr bool is_rtc() { return false; } +#endif }; class LibreTinyPreferences; diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index a868e5042e2..9d21e88b14b 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -4,6 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include namespace esphome::libretiny { @@ -13,20 +14,58 @@ ESPHOME_LOG_TAG(TAG, "preferences"); struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) +#ifdef USE_LIBRETINY_RTC_PREFERENCES + bool rtc; + bool is_rtc() const { return this->rtc; } +#else + static constexpr bool is_rtc() { return false; } +#endif }; static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// The FlashDB name of a key: its decimal digits, with a prefix for storage: rtc so one pass over the +// database finds every rtc entry. Without rtc storage is_rtc() is a constant false and the branch folds away. +static constexpr size_t KEY_NAME_SIZE = UINT32_MAX_STR_SIZE + 1; +static constexpr char RTC_KEY_PREFIX = 'r'; +static void key_name(char (&buf)[KEY_NAME_SIZE], uint32_t key, bool rtc) { + char *digits = buf; + if (rtc) + *digits++ = RTC_KEY_PREFIX; + uint32_to_str(std::span(digits, UINT32_MAX_STR_SIZE), key); +} + +#ifdef USE_LIBRETINY_RTC_PREFERENCES +// Whether this boot followed a reset that RTC memory survives on ESP32 and ESP8266. LibreTiny chips +// have no such memory, so storage: rtc keeps preferences in flash and uses this to forget them. +static bool boot_kept_rtc_preferences() { + switch (lt_get_reboot_reason()) { + case REBOOT_REASON_SOFTWARE: + case REBOOT_REASON_WATCHDOG: + case REBOOT_REASON_CRASH: + case REBOOT_REASON_SLEEP_GPIO: + case REBOOT_REASON_SLEEP_RTC: + case REBOOT_REASON_SLEEP_USB: + return true; + default: // power-on, brown-out, reset pin, debugger, unknown + return false; + } +} +#endif // USE_LIBRETINY_RTC_PREFERENCES + bool LibreTinyPreferenceBackend::save(const uint8_t *data, size_t len) { - // try find in pending saves and update that + // try find in pending saves and update that; a flash and an rtc entry may share a key for (auto &obj : s_pending_save) { - if (obj.key == this->key) { + if (obj.key == this->key && obj.is_rtc() == this->is_rtc()) { obj.data.set(data, len); return true; } } NVSData save{}; save.key = this->key; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + save.rtc = this->rtc; +#endif save.data.set(data, len); s_pending_save.push_back(std::move(save)); ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len); @@ -36,7 +75,7 @@ bool LibreTinyPreferenceBackend::save(const uint8_t *data, size_t len) { bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { // try find in pending saves and load from that for (auto &obj : s_pending_save) { - if (obj.key == this->key) { + if (obj.key == this->key && obj.is_rtc() == this->is_rtc()) { if (obj.data.size() != len) { // size mismatch return false; @@ -46,8 +85,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[UINT32_MAX_STR_SIZE]; - uint32_to_str(key_str, this->key); + char key_str[KEY_NAME_SIZE]; + key_name(key_str, this->key, this->is_rtc()); fdb_blob_make(this->blob, data, len); size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob); if (actual_len != len) { @@ -64,21 +103,57 @@ void LibreTinyPreferences::open() { fdb_err_t err = fdb_kvdb_init(&this->db, "esphome", "kvs", NULL, NULL); if (err != FDB_NO_ERR) { LT_E("fdb_kvdb_init(...) failed: %d", err); - } else { - LT_I("Preferences initialized"); + return; + } + LT_I("Preferences initialized"); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + if (!boot_kept_rtc_preferences()) + this->discard_rtc_entries_(); +#endif +} + +#ifdef USE_LIBRETINY_RTC_PREFERENCES +ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type, bool in_flash) { + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type, !in_flash))); +} + +// One pass over the database deletes every rtc entry. FlashDB only marks a deleted entry's status in place +// and its iterator steps by address, so deleting the current entry is safe. This runs before the logger +// exists, so it logs through LibreTiny; a failed delete is a flash fault the next write will report as well. +void LibreTinyPreferences::discard_rtc_entries_() { + uint16_t discarded = 0; + fdb_kv_iterator iterator; + fdb_kv_iterator_init(&iterator); + while (fdb_kv_iterate(&this->db, &iterator)) { + const char *name = iterator.curr_kv.name; + if (name[0] != RTC_KEY_PREFIX) + continue; + if (const fdb_err_t err = fdb_kv_del(&this->db, name); err != FDB_NO_ERR) { + LT_E("Failed to discard rtc preference %s: %d", name, err); + continue; + } + discarded++; + } + if (discarded != 0) { + LT_D("Discarded %u rtc preferences after a hard reset", discarded); } } +#endif ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type) { // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type))); } -LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type) { +LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type, bool rtc) { LibreTinyPreferenceBackend backend; backend.key = type; backend.db = &this->db; backend.blob = &this->blob; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + backend.rtc = rtc; +#endif return backend; } @@ -97,8 +172,8 @@ bool LibreTinyPreferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[UINT32_MAX_STR_SIZE]; - uint32_to_str(key_str, save.key); + char key_str[KEY_NAME_SIZE]; + key_name(key_str, save.key, save.is_rtc()); ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str); if (this->is_changed_(&this->db, save, key_str)) { ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size()); diff --git a/esphome/components/libretiny/preferences.h b/esphome/components/libretiny/preferences.h index fd86c48b20d..4b3737f07fd 100644 --- a/esphome/components/libretiny/preferences.h +++ b/esphome/components/libretiny/preferences.h @@ -1,6 +1,7 @@ #pragma once #ifdef USE_LIBRETINY +#include "esphome/core/defines.h" #include "esphome/core/preference_backend.h" #include @@ -12,9 +13,14 @@ class LibreTinyPreferences final : public PreferencesMixin public: using PreferencesMixin::make_preference; void open(); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); +#else + // Without rtc storage every preference lives in flash; inline so builds that don't use rtc pay nothing ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { return this->make_preference(length, type); } +#endif ESPPreferenceObject make_preference(size_t length, uint32_t type); /// One-shot read of a stored preference by key, without allocating a backend bool load_from_key(uint32_t type, uint8_t *data, size_t len); @@ -25,8 +31,11 @@ class LibreTinyPreferences final : public PreferencesMixin struct fdb_blob blob; protected: - LibreTinyPreferenceBackend make_backend_(uint32_t type); + LibreTinyPreferenceBackend make_backend_(uint32_t type, bool rtc = false); bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + void discard_rtc_entries_(); +#endif }; void setup_preferences(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 935952d3b31..852ea3439a9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -717,6 +717,7 @@ #else #define USE_BK72XX_BLE_TRACKER #endif +#define USE_LIBRETINY_RTC_PREFERENCES #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_SUSPEND_LOOP #define USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/preferences.py b/esphome/preferences.py index c9a465d6252..b31dcecf874 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -3,8 +3,11 @@ Preferences can be stored either in flash (durable across power loss) or in RTC memory (fast, survives deep sleep and soft resets but not power loss). The flash-vs-RTC choice is only meaningful on platforms whose preferences backend -honors the ``in_flash`` flag — currently ESP32 and ESP8266. On other platforms -the value is accepted only as ``flash`` (the sole supported backend). +honors the ``in_flash`` flag — currently ESP32, ESP8266 and the Beken BK7231T, +BK7231N and BK7238. LibreTiny chips have no RTC memory: there ``rtc`` keeps the +preference in flash and discards it after a reset that RTC memory would not +survive. On other platforms the value is accepted only as ``flash`` (the sole +supported backend). Components include :func:`storage_schema` in their config and convert the chosen value with :func:`is_in_flash` when calling ``make_preference``. @@ -20,7 +23,7 @@ STORAGE_RTC = "rtc" def rtc_supported() -> bool: - """Whether the target has RTC-backed preferences (not ESP32-C2/C61); call at validation time.""" + """Whether the target has RTC-backed preferences (ESP8266, ESP32 except C2/C61, some Beken chips); call at validation time.""" if CORE.is_esp8266: return True if CORE.is_esp32: @@ -29,7 +32,26 @@ def rtc_supported() -> bool: # Mirrors the C++ SOC_RTC_MEM_SUPPORTED guard in the ESP32 backend return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) - return False + return _libretiny_rtc_supported() + + +def _libretiny_rtc_supported() -> bool: + """Whether this LibreTiny family accepts ``rtc``, emulated with flash plus the reset reason. + + The emulation is only as good as the family's ``lt_get_reboot_reason()``, so families are + listed once that reporting has been checked on hardware: a chip-enable reset and a power cut + both report a power-on, a software, watchdog or crash reset does not. + """ + if not CORE.is_libretiny: + return False + from esphome.components.libretiny import get_libretiny_family + from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231T, + FAMILY_BK7238, + ) + + return get_libretiny_family() in (FAMILY_BK7231T, FAMILY_BK7231N, FAMILY_BK7238) def _default_storage() -> str: @@ -58,15 +80,17 @@ def storage_schema(): def request_rtc_storage() -> None: - """Compile the RTC-backed storage into the ESP32 preferences backend. + """Compile the RTC-backed storage into the ESP32 or LibreTiny preferences backend. - The RTC storage region is left out of ESP32 builds unless something asks for - it, so unused builds don't reserve RTC memory. Call this from ``to_code`` - when a config option selects RTC storage. No-op on other platforms (ESP8266 - always has its RTC backend). + It is left out of ESP32 and LibreTiny builds unless something asks for it, so + unused builds don't reserve RTC memory or check the reset reason. Call this + from ``to_code`` when a config option selects RTC storage. No-op on other + platforms (ESP8266 always has its RTC backend). """ if CORE.is_esp32: cg.add_define("USE_ESP32_RTC_PREFERENCES") + elif _libretiny_rtc_supported(): + cg.add_define("USE_LIBRETINY_RTC_PREFERENCES") def validate_rtc_storage(value): diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py index a1ab78bfbff..da56aa8d6fa 100644 --- a/tests/unit_tests/test_preferences.py +++ b/tests/unit_tests/test_preferences.py @@ -10,15 +10,26 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C3, VARIANT_ESP32C61, ) +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, + FAMILY_RTL8710B, + KEY_FAMILY, + KEY_LIBRETINY, +) import esphome.config_validation as cv from esphome.const import ( CONF_STORAGE, KEY_CORE, KEY_TARGET_PLATFORM, KEY_VARIANT, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, + PLATFORM_RTL87XX, ) from esphome.core import CORE @@ -191,3 +202,45 @@ def test_validate_storage_rejects_unknown_value() -> None: _set_esp32(VARIANT_ESP32) with pytest.raises(cv.Invalid): preferences.validate_storage("nvs") + + +def _set_libretiny(platform: str, family: str) -> None: + _set_platform(platform) + CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family} + + +@pytest.mark.parametrize("family", [FAMILY_BK7231T, FAMILY_BK7231N, FAMILY_BK7238]) +def test_rtc_allowed_on_tested_beken_families(family: str) -> None: + # Emulated with flash plus the reset reason, which was checked on hardware. + _set_libretiny(PLATFORM_BK72XX, family) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize( + ("platform", "family"), + [ + (PLATFORM_BK72XX, FAMILY_BK7251), + (PLATFORM_RTL87XX, FAMILY_RTL8710B), + ], +) +def test_rtc_rejected_on_other_libretiny_families(platform: str, family: str) -> None: + _set_libretiny(platform, family) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_default_storage_bk7231t_is_flash() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7231T) + assert _validate({})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +def test_request_rtc_storage_bk7231t_emits_define() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7231T) + preferences.request_rtc_storage() + assert "USE_LIBRETINY_RTC_PREFERENCES" in _define_names() + + +def test_request_rtc_storage_other_libretiny_family_is_a_no_op() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7251) + preferences.request_rtc_storage() + assert "USE_LIBRETINY_RTC_PREFERENCES" not in _define_names() From bb9067acd719ffc59915668004e6492ea2b3e291 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:07:16 +0200 Subject: [PATCH 539/651] [tfluna] Recover from transient runtime reconfiguration failures (#20992) Co-authored-by: J. Nick Koston --- esphome/components/tfluna/tfluna.cpp | 64 ++++-- esphome/components/tfluna/tfluna.h | 6 + tests/components/tfluna/test_tfluna.cpp | 282 ++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 tests/components/tfluna/test_tfluna.cpp diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index e3d7045d8e6..706213024d6 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -42,31 +42,25 @@ void TFLuna::dump_config() { #endif } -void TFLuna::setup() { +bool TFLuna::configure_() { uint8_t mode; if (!this->read_byte(MODE_REGISTER, &mode)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } if (mode != MODE_TRIGGER) { if (!this->write_byte(MODE_REGISTER, MODE_TRIGGER)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } if (!this->write_byte(SAVE_REGISTER, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } } uint8_t raw_version[3]; if (!this->read_bytes(VERSION_REVISION_REGISTER, raw_version, sizeof(raw_version))) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); - return; + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return true; } else { snprintf(this->version_, sizeof(this->version_), "%d.%d.%d", raw_version[2], raw_version[1], raw_version[0]); #ifdef USE_TEXT_SENSOR @@ -75,12 +69,26 @@ void TFLuna::setup() { } #endif } + return true; +} + +void TFLuna::setup() { + if (!this->configure_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void TFLuna::reconfigure_() { + this->reconfigure_pending_ = !this->configure_(); + if (this->reconfigure_pending_) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } } bool TFLuna::read_data_() { uint8_t buf[8]; if (!this->read_bytes(DISTANCE_LOW_REGISTER, buf, sizeof(buf))) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } // Layout: @@ -125,34 +133,50 @@ void TFLuna::read_data_timeout_() { this->attempt_++; this->set_timeout(READ_RETRY_TIMEOUT_ID, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); } else { - this->status_set_warning("Hung device, restarting..."); + this->status_set_warning(LOG_STR("Hung device, restarting...")); this->restart(); } } } void TFLuna::update() { + if (this->is_failed()) { + return; + } this->cancel_timeout(READ_RETRY_TIMEOUT_ID); this->attempt_ = 0; + if (this->reconfigure_pending_) { + this->reconfigure_(); + if (this->reconfigure_pending_) { + return; + } + } if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->read_data_timeout_(); } void TFLuna::factory_reset() { - if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) { return; } - this->status_set_warning("Factory reset issued; waiting for device to become ready"); - this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->setup(); }); + if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->reconfigure_pending_ = false; + this->status_set_warning(LOG_STR("Factory reset issued; waiting for device to become ready")); + this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->reconfigure_(); }); } void TFLuna::restart() { + if (this->is_failed()) { + return; + } if (!this->write_byte(SHUTDOWN_REBOOT_REGISTER, 0x02)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/tfluna/tfluna.h b/esphome/components/tfluna/tfluna.h index 0cc866ef1df..84de5f05d58 100644 --- a/esphome/components/tfluna/tfluna.h +++ b/esphome/components/tfluna/tfluna.h @@ -51,7 +51,13 @@ class TFLuna : public i2c::I2CDevice, public PollingComponent { void read_data_timeout_(); + [[nodiscard]] bool configure_(); + + void reconfigure_(); + uint8_t attempt_ = 0; + + bool reconfigure_pending_ = false; }; } // namespace esphome::tfluna diff --git a/tests/components/tfluna/test_tfluna.cpp b/tests/components/tfluna/test_tfluna.cpp new file mode 100644 index 00000000000..30236263c74 --- /dev/null +++ b/tests/components/tfluna/test_tfluna.cpp @@ -0,0 +1,282 @@ +#include + +#include +#include +#include "esphome/components/tfluna/tfluna.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::tfluna::testing { + +static constexpr uint8_t TIMESTAMP_LOW = 0x06; +static constexpr uint8_t VERSION = 0x0A; +static constexpr uint8_t SAVE = 0x20; +static constexpr uint8_t RESTART = 0x21; +static constexpr uint8_t MODE = 0x23; +static constexpr uint8_t MODE_TRIGGER = 0x01; +static constexpr uint8_t TRIGGER = 0x24; +static constexpr uint8_t RESET = 0x29; +static constexpr uint8_t NO_FAULT = 0xFF; // outside the register file + +struct Fault { + bool read; + uint8_t reg; +}; + +// Register file behind read_register (register byte, then read) and write_register +// (register byte + data, no read). A fault is one NACK, then the bus heals. +class RegisterBus final : public i2c::I2CBus { + public: + std::array regs{}; + uint8_t fail_read{NO_FAULT}; + uint8_t fail_write{NO_FAULT}; + unsigned mode_writes{0}; + unsigned save_writes{0}; + + i2c::ErrorCode write_readv(uint8_t /*address*/, const uint8_t *out, size_t out_len, uint8_t *in, + size_t in_len) override { + if (out_len == 0 || out[0] + (out_len - 1) + in_len > this->regs.size()) + return i2c::ERROR_INVALID_ARGUMENT; + const uint8_t reg = out[0]; + if (out_len > 1) { + if (reg == this->fail_write) { + this->fail_write = NO_FAULT; + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + std::copy(out + 1, out + out_len, this->regs.begin() + reg); + this->mode_writes += reg == MODE; + this->save_writes += reg == SAVE; + if (reg == RESET) + this->regs[MODE] = 0; // fixture choice: defaults are not trigger mode + if (reg == TRIGGER) + this->regs[TIMESTAMP_LOW]++; // fixture choice: every trigger yields a new frame + } + if (in_len != 0) { + if (reg == this->fail_read) { + this->fail_read = NO_FAULT; + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + std::copy_n(this->regs.begin() + reg, in_len, in); + } + return i2c::ERROR_OK; + } +}; + +class TFLunaTest : public ::testing::Test { + protected: + void SetUp() override { + // App lives for the whole binary; a timer another test left would fire on a dead object in run_timers() + ASSERT_TRUE(scheduler_idle()); + this->owns_scheduler_ = true; + this->bus_.regs[VERSION] = 3; + this->bus_.regs[VERSION + 1] = 2; + this->bus_.regs[VERSION + 2] = 1; + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x10); + } + + // Timeouts capture &device_ and running one publishes it to App; drop both before the fixture dies + void TearDown() override { + if (!this->owns_scheduler_) + return; // SetUp already failed; leave the other test's timers unrun + App.scheduler.cancel_timeout(&this->device_, uint32_t{0}); + App.scheduler.cancel_timeout(&this->device_, uint32_t{1}); + App.scheduler.call(millis()); + EXPECT_FALSE(App.scheduler.next_schedule_in(millis()).has_value()); + // Recycled items hold no callback; free them so a later App reset has none of ours to lose + App.scheduler.trim_freelist(); + LoopBlockingGuard clear{nullptr, nullptr, millis()}; + } + + // Real clock past the existing 100 ms reset timeout; no new device timing + // Timers are serviced like the main loop during the real wait instead of one long sleep + static void run_timers() { + const uint32_t start = millis(); + while (millis() - start < 110) { + App.scheduler.call(millis()); + delay(1); + } + App.scheduler.call(millis()); + } + + // Threaded hosts' next_schedule_in() skips timers still staged for the heap; merging them first runs no callback + static bool scheduler_idle() { + App.scheduler.process_to_add(); + return !App.scheduler.next_schedule_in(millis()).has_value(); + } + + void fail_at_(Fault fault) { (fault.read ? this->bus_.fail_read : this->bus_.fail_write) = fault.reg; } + + // One NACK at the read-mode, write-mode or SAVE step; fatal at boot, retried by the next update at runtime + void expect_boot_nack_marks_failed_(Fault fault) { + this->fail_at_(fault); + this->device_.setup(); + EXPECT_TRUE(this->device_.is_failed()); + } + + void expect_runtime_nack_recovers_(Fault fault) { + this->device_.setup(); + this->device_.factory_reset(); + this->fail_at_(fault); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); + } + + RegisterBus bus_; + TFLuna device_; + bool owns_scheduler_{false}; +}; + +// A timeout another suite left staged must block ownership before any callback can run +TEST_F(TFLunaTest, StagedForeignTimeoutBlocksOwnership) { + TFLuna foreign; + bool fired = false; + App.scheduler.set_timeout(&foreign, uint32_t{0}, 1000, [&fired]() { fired = true; }); + EXPECT_FALSE(scheduler_idle()); + EXPECT_FALSE(fired); + // Drop the timer before foreign and fired leave scope; no ASSERT may return early above this + EXPECT_TRUE(App.scheduler.cancel_timeout(&foreign, uint32_t{0})); + App.scheduler.call(millis()); + EXPECT_FALSE(fired); + EXPECT_TRUE(scheduler_idle()); +} + +TEST_F(TFLunaTest, BootSelectsTriggerModeAndSaves) { + this->device_.setup(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 1u); + EXPECT_EQ(this->bus_.save_writes, 1u); +} + +TEST_F(TFLunaTest, NormalFactoryResetRestoresTriggerMode) { + this->device_.setup(); + this->device_.factory_reset(); + EXPECT_TRUE(this->device_.status_has_warning()); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_EQ(this->bus_.save_writes, 2u); + this->device_.update(); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, ResetWriteNackSchedulesNothing) { + this->device_.setup(); + this->bus_.fail_write = RESET; + this->device_.factory_reset(); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + EXPECT_EQ(this->bus_.mode_writes, 1u); + this->device_.update(); // no reconfigure was left pending; the next good read clears the warning + EXPECT_EQ(this->bus_.mode_writes, 1u); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, DoublePressInWindowReconfiguresOnce) { + this->device_.setup(); + this->device_.factory_reset(); + this->device_.factory_reset(); + run_timers(); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); +} + +// Original: the first NACK marks the component failed, so Scheduler::call() drops the second reset's timeout +TEST_F(TFLunaTest, ResetWhileReconfigurePendingWaitsForWindowThenRecovers) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->device_.factory_reset(); + this->device_.update(); // inside the 100 ms window: no early reconfigure + EXPECT_EQ(this->bus_.mode_writes, 1u); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 2u); + this->device_.update(); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, ResetWriteNackKeepsPendingReconfigure) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->bus_.fail_write = RESET; + this->device_.factory_reset(); + run_timers(); + this->device_.update(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, UpdateSkipsPollingWhileReconfigureFails) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->bus_.fail_read = MODE; + this->device_.update(); + EXPECT_EQ(this->bus_.regs[TIMESTAMP_LOW], 0); // no trigger issued + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, RestartWriteNackWarnsThenNextReadClears) { + this->device_.setup(); + this->bus_.fail_write = RESTART; + this->device_.restart(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +// A boot failure is permanent; runtime entry points must not touch the bus +TEST_F(TFLunaTest, FailedComponentSkipsRuntimeWrites) { + this->bus_.fail_read = MODE; + this->device_.setup(); + ASSERT_TRUE(this->device_.is_failed()); + this->device_.factory_reset(); + this->device_.restart(); + this->device_.update(); + EXPECT_TRUE(this->device_.is_failed()); + EXPECT_FALSE(this->device_.status_has_warning()); + EXPECT_EQ(this->bus_.regs[RESET], 0); + EXPECT_EQ(this->bus_.regs[RESTART], 0); + EXPECT_EQ(this->bus_.regs[TIMESTAMP_LOW], 0); + EXPECT_EQ(this->bus_.mode_writes, 0u); +} + +TEST_F(TFLunaTest, BootReadModeNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{true, MODE}); } + +TEST_F(TFLunaTest, BootWriteModeNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{false, MODE}); } + +TEST_F(TFLunaTest, BootSaveNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{false, SAVE}); } + +TEST_F(TFLunaTest, RuntimeReadModeNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{true, MODE}); +} + +TEST_F(TFLunaTest, RuntimeWriteModeNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{false, MODE}); +} + +TEST_F(TFLunaTest, RuntimeSaveNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{false, SAVE}); +} + +} // namespace esphome::tfluna::testing From e6f1ed8d510a9d8b5b3bae431df4d9f117c632aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:07:42 -1000 Subject: [PATCH 540/651] [tcp_uart] Expose the connection entities as platforms (#21009) --- esphome/components/tcp_uart/__init__.py | 29 +++-------------- esphome/components/tcp_uart/binary_sensor.py | 25 +++++++++++++++ esphome/components/tcp_uart/sensor.py | 31 +++++++++++++++++++ esphome/components/tcp_uart/tcp_uart.cpp | 6 ++++ esphome/components/tcp_uart/tcp_uart.h | 7 +++++ tests/components/tcp_uart/benchmark.yaml | 5 +-- tests/components/tcp_uart/common.yaml | 8 +++++ .../tcp_uart/test-client.esp32-idf.yaml | 8 +++++ .../integration/fixtures/tcp_uart_bridge.yaml | 4 +++ 9 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 esphome/components/tcp_uart/binary_sensor.py create mode 100644 esphome/components/tcp_uart/sensor.py diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index df9c1720ee6..1e120275602 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_DATA_BITS, CONF_HOST, CONF_PARITY, @@ -11,23 +10,16 @@ from esphome.components.const import ( CONF_STOP_BITS, ) import esphome.config_validation as cv -from esphome.const import ( - CONF_BAUD_RATE, - CONF_ID, - CONF_PORT, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, -) +from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_PORT from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DEPENDENCIES = ["network"] -AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["uart", "socket"] DOMAIN = "tcp_uart" MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_TCP_UART_ID = "tcp_uart_id" tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) @@ -46,15 +38,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -100,7 +83,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_parity(config[CONF_PARITY])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/binary_sensor.py b/esphome/components/tcp_uart/binary_sensor.py new file mode 100644 index 00000000000..8337973c837 --- /dev/null +++ b/esphome/components/tcp_uart/binary_sensor.py @@ -0,0 +1,25 @@ +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 CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/tcp_uart/sensor.py b/esphome/components/tcp_uart/sensor.py new file mode 100644 index 00000000000..099ca8734e1 --- /dev/null +++ b/esphome/components/tcp_uart/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index 3dead1fb65c..fa465aa55a8 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -17,9 +17,11 @@ void TcpUart::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -38,7 +40,9 @@ void TcpUart::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -58,9 +62,11 @@ void TcpUart::sync_link_() { // Unread bytes of the last session stay readable while down, never into the next one. this->rx_start_ = this->rx_end_ = 0; } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index 1e1d48e4c20..6ecfd1a0862 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -23,7 +26,9 @@ class TcpUart : public uart::UARTComponent, public Component { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -64,7 +69,9 @@ class TcpUart : public uart::UARTComponent, public Component { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif #ifdef USE_SENSOR sensor::Sensor *disconnects_sensor_{nullptr}; uint32_t disconnects_{0}; diff --git a/tests/components/tcp_uart/benchmark.yaml b/tests/components/tcp_uart/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/tcp_uart/benchmark.yaml +++ b/tests/components/tcp_uart/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/tcp_uart/common.yaml b/tests/components/tcp_uart/common.yaml index 97ac0247da3..97bcd24f6a7 100644 --- a/tests/components/tcp_uart/common.yaml +++ b/tests/components/tcp_uart/common.yaml @@ -10,8 +10,16 @@ tcp_uart: allowed_ips: - 192.0.2.20 - 192.0.2.0/24 + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server connected: name: TCP UART Server Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server disconnects: name: TCP UART Server Disconnects diff --git a/tests/components/tcp_uart/test-client.esp32-idf.yaml b/tests/components/tcp_uart/test-client.esp32-idf.yaml index 74bd26a98bc..921ce0cbc1a 100644 --- a/tests/components/tcp_uart/test-client.esp32-idf.yaml +++ b/tests/components/tcp_uart/test-client.esp32-idf.yaml @@ -7,7 +7,15 @@ tcp_uart: host: 192.0.2.10 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: TCP UART Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 disconnects: name: TCP UART Disconnects diff --git a/tests/integration/fixtures/tcp_uart_bridge.yaml b/tests/integration/fixtures/tcp_uart_bridge.yaml index 12791e7dd46..74a8710b19e 100644 --- a/tests/integration/fixtures/tcp_uart_bridge.yaml +++ b/tests/integration/fixtures/tcp_uart_bridge.yaml @@ -20,6 +20,10 @@ tcp_uart: host: 127.0.0.1 port: 18125 reconnect_interval: 3s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: Bridge Connected From 42e849967a94eb8f82f6654b88cb142c05d8be3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:14 -1000 Subject: [PATCH 541/651] [bq76972] Expose the sensors as a sensor platform (#21006) --- esphome/components/bq76972/__init__.py | 60 +--------------- esphome/components/bq76972/bq76972.cpp | 8 +++ esphome/components/bq76972/bq76972.h | 9 ++- esphome/components/bq76972/sensor.py | 73 ++++++++++++++++++++ tests/components/bq76972/common.yaml | 96 ++++++++++++++------------ 5 files changed, 142 insertions(+), 104 deletions(-) create mode 100644 esphome/components/bq76972/sensor.py diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py index 672d6428d26..46f674da269 100644 --- a/esphome/components/bq76972/__init__.py +++ b/esphome/components/bq76972/__init__.py @@ -1,18 +1,10 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import i2c, sensor +from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - UNIT_CELSIUS, - UNIT_VOLT, -) +from esphome.const import CONF_ADDRESS from esphome.types import ConfigType -AUTO_LOAD = ["sensor"] CODEOWNERS = ["@limpkin"] DEPENDENCIES = ["i2c"] DOMAIN = "bq76972" @@ -25,37 +17,7 @@ BQ76972Component = bq76972_ns.class_( 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, -) +CONF_BQ76972_ID = "bq76972_id" CONFIG_SCHEMA = ( cv.Schema( @@ -63,9 +25,6 @@ CONFIG_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)) @@ -79,19 +38,6 @@ async def to_code(config: ConfigType) -> None: 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( { diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index ea229307d5c..9426fbb2526 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -172,10 +172,12 @@ const LogString *BQ76972Component::configure_() { const bool write_regs = !regs_disabled; bool write_thermistors = false; +#ifdef USE_SENSOR for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr) write_thermistors = true; } +#endif if (write_regs || write_thermistors) { if (!this->enter_cfg_update_()) @@ -201,12 +203,14 @@ const LogString *BQ76972Component::write_config_(bool write_regs) { !this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1)) return LOG_STR("Writing regulator config failed"); } +#ifdef USE_SENSOR 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"); } +#endif return nullptr; } @@ -248,6 +252,7 @@ void BQ76972Component::program_i2c_address(uint8_t address) { ESP_LOGI(TAG, "I2C address set to 0x%02X", address); } +#ifdef USE_SENSOR template static bool any_set(const std::array &sensors) { for (auto *sensor : sensors) { if (sensor != nullptr) @@ -294,5 +299,8 @@ void BQ76972Component::update() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } +#else +void BQ76972Component::update() {} +#endif } // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/bq76972.h b/esphome/components/bq76972/bq76972.h index d27a4ce96f4..ccc4f45cfaf 100644 --- a/esphome/components/bq76972/bq76972.h +++ b/esphome/components/bq76972/bq76972.h @@ -1,8 +1,11 @@ #pragma once -#include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif #include @@ -19,9 +22,11 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; } void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; } +#ifdef USE_SENSOR 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; } +#endif void program_i2c_address(uint8_t address); @@ -43,9 +48,11 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { bool reg_disable_{false}; bool crc_mode_{false}; +#ifdef USE_SENSOR sensor::Sensor *stack_voltage_sensor_{nullptr}; std::array cell_sensors_{}; std::array temperature_sensors_{}; +#endif }; } // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/sensor.py b/esphome/components/bq76972/sensor.py new file mode 100644 index 00000000000..0c7efc41235 --- /dev/null +++ b/esphome/components/bq76972/sensor.py @@ -0,0 +1,73 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_VOLT, +) +from esphome.types import ConfigType + +from . import CONF_BQ76972_ID, BQ76972Component + +DEPENDENCIES = ["bq76972"] + +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(CONF_BQ76972_ID): cv.use_id(BQ76972Component), + 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}, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ76972_ID]) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_STACK_VOLTAGE, hub.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(hub.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(hub.set_cell_sensor(sens, idx)) diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml index 4b2e62eb20b..7270a5ce03c 100644 --- a/tests/components/bq76972/common.yaml +++ b/tests/components/bq76972/common.yaml @@ -1,60 +1,64 @@ bq76972: - id: bq76972_id + id: bms i2c_id: i2c_bus 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 + id: bms address: 0x09 - bq76972.program_i2c_address: !lambda "return 0x08;" + +sensor: + - platform: bq76972 + bq76972_id: bms + 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" From e034662dc6cf1f82fc19bb15d5a9d9ef109da6f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:32 -1000 Subject: [PATCH 542/651] [uart_tcp] Expose the connection entities as platforms (#21008) --- esphome/components/uart_tcp/__init__.py | 23 ++------------ esphome/components/uart_tcp/binary_sensor.py | 25 +++++++++++++++ esphome/components/uart_tcp/sensor.py | 31 +++++++++++++++++++ esphome/components/uart_tcp/uart_tcp.cpp | 6 ++++ esphome/components/uart_tcp/uart_tcp.h | 7 +++++ tests/components/uart_tcp/benchmark.yaml | 5 +-- tests/components/uart_tcp/common.yaml | 8 +++++ .../uart_tcp/test-client.esp32-idf.yaml | 8 +++++ tests/components/uart_tcp/test.host.yaml | 8 +++++ .../integration/fixtures/uart_tcp_bridge.yaml | 8 +++-- 10 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 esphome/components/uart_tcp/binary_sensor.py create mode 100644 esphome/components/uart_tcp/sensor.py diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 9d62d48d7c2..c7eba4a26a5 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_HOST, CONF_RECONNECT_INTERVAL, CONF_ROLE, @@ -15,9 +14,6 @@ from esphome.const import ( CONF_ID, CONF_PORT, CONF_UART_ID, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, ) from esphome.core import CORE import esphome.final_validate as fv @@ -26,10 +22,10 @@ from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DOMAIN = "uart_tcp" DEPENDENCIES = ["network", "uart"] -AUTO_LOAD = ["binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["socket"] MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_UART_TCP_ID = "uart_tcp_id" uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp") UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice) @@ -43,15 +39,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -144,7 +131,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/binary_sensor.py b/esphome/components/uart_tcp/binary_sensor.py new file mode 100644 index 00000000000..c8d19319337 --- /dev/null +++ b/esphome/components/uart_tcp/binary_sensor.py @@ -0,0 +1,25 @@ +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 CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/uart_tcp/sensor.py b/esphome/components/uart_tcp/sensor.py new file mode 100644 index 00000000000..ad44b56ad09 --- /dev/null +++ b/esphome/components/uart_tcp/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index d634ac579f2..ca6c2204bdd 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -16,9 +16,11 @@ void UartTcp::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -37,7 +39,9 @@ void UartTcp::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -57,9 +61,11 @@ void UartTcp::sync_link_() { // The driver kept whatever arrived while the link was down. this->discard_uart_(); } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h index 256e059133a..6080cc95514 100644 --- a/esphome/components/uart_tcp/uart_tcp.h +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -22,7 +25,9 @@ class UartTcp final : public Component, public uart::UARTDevice { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -53,7 +58,9 @@ class UartTcp final : public Component, public uart::UARTDevice { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif // Loop start time of the last socket-to-UART write; sizes the next paced write. uint32_t last_write_ms_{0}; #ifdef USE_SENSOR diff --git a/tests/components/uart_tcp/benchmark.yaml b/tests/components/uart_tcp/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/uart_tcp/benchmark.yaml +++ b/tests/components/uart_tcp/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/uart_tcp/common.yaml b/tests/components/uart_tcp/common.yaml index fd34757d0ee..cf86dc9da89 100644 --- a/tests/components/uart_tcp/common.yaml +++ b/tests/components/uart_tcp/common.yaml @@ -10,7 +10,15 @@ uart_tcp: allowed_ips: - 192.168.1.10 - 192.168.1.0/24 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test-client.esp32-idf.yaml b/tests/components/uart_tcp/test-client.esp32-idf.yaml index 5ba900347c4..6bce693f854 100644 --- a/tests/components/uart_tcp/test-client.esp32-idf.yaml +++ b/tests/components/uart_tcp/test-client.esp32-idf.yaml @@ -12,7 +12,15 @@ uart_tcp: host: 192.0.2.20 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test.host.yaml b/tests/components/uart_tcp/test.host.yaml index a0b82c568df..b793ae144c3 100644 --- a/tests/components/uart_tcp/test.host.yaml +++ b/tests/components/uart_tcp/test.host.yaml @@ -8,7 +8,15 @@ uart_tcp: uart_id: uart_bus host: 127.0.0.1 port: 44502 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/integration/fixtures/uart_tcp_bridge.yaml b/tests/integration/fixtures/uart_tcp_bridge.yaml index 7ad28316f75..cf6c8fad64b 100644 --- a/tests/integration/fixtures/uart_tcp_bridge.yaml +++ b/tests/integration/fixtures/uart_tcp_bridge.yaml @@ -24,11 +24,15 @@ uart_tcp: port: 18126 allowed_ips: - 127.0.0.1 - connected: - name: Bridge Connected - id: denied_bridge uart_id: denied_bus role: server port: 18127 allowed_ips: - 192.0.2.1 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge + connected: + name: Bridge Connected From 1197f22f0067961440d07af468c59a38a6e7f420 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:49 -1000 Subject: [PATCH 543/651] [fusb302b] Expose the sensors as platforms (#21004) --- esphome/components/fusb302b/__init__.py | 37 +----------------- esphome/components/fusb302b/sensor.py | 45 ++++++++++++++++++++++ esphome/components/fusb302b/text_sensor.py | 27 +++++++++++++ tests/components/fusb302b/common.yaml | 20 +++++++--- 4 files changed, 88 insertions(+), 41 deletions(-) create mode 100644 esphome/components/fusb302b/sensor.py create mode 100644 esphome/components/fusb302b/text_sensor.py diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index 26bf49b28e3..a1770605498 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -1,30 +1,21 @@ from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c, sensor, text_sensor +from esphome.components import i2c import esphome.config_validation as cv from esphome.const import ( - CONF_CURRENT, CONF_INTERRUPT_PIN, CONF_ON_CONNECT, CONF_ON_DISCONNECT, CONF_ON_ERROR, CONF_VOLTAGE, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_VOLTAGE, - ENTITY_CATEGORY_DIAGNOSTIC, - ICON_FLASH, - STATE_CLASS_MEASUREMENT, - UNIT_AMPERE, - UNIT_VOLT, ) from esphome.types import ConfigType -AUTO_LOAD = ["sensor", "text_sensor"] CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] DOMAIN = "fusb302b" -CONF_CONTRACT = "contract" +CONF_FUSB302B_ID = "fusb302b_id" CONF_ON_POWER_READY = "on_power_ready" CONF_ON_REQUEST_FAILED = "on_request_failed" CONF_REQUEST_VOLTAGE = "request_voltage" @@ -47,24 +38,6 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(FUSB302B), cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema, cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage, - cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( - icon=ICON_FLASH, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - 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=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}), cv.Optional(CONF_ON_POWER_READY): automation.validate_automation({}), @@ -101,12 +74,6 @@ async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config, interrupt_pin) cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) - sensors = sensor.sub_sensors(config) - await sensors(CONF_VOLTAGE, var.set_voltage_sensor) - await sensors(CONF_CURRENT, var.set_current_sensor) - text_sensors = text_sensor.sub_text_sensors(config) - await text_sensors(CONF_CONTRACT, var.set_contract_text_sensor) - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/fusb302b/sensor.py b/esphome/components/fusb302b/sensor.py new file mode 100644 index 00000000000..34dd35a60c7 --- /dev/null +++ b/esphome/components/fusb302b/sensor.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_CURRENT, + CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, +) +from esphome.types import ConfigType + +from . import CONF_FUSB302B_ID, FUSB302B + +DEPENDENCIES = ["fusb302b"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_FUSB302B_ID): cv.use_id(FUSB302B), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + 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=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_FUSB302B_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, hub.set_voltage_sensor) + await sensors(CONF_CURRENT, hub.set_current_sensor) diff --git a/esphome/components/fusb302b/text_sensor.py b/esphome/components/fusb302b/text_sensor.py new file mode 100644 index 00000000000..ee18af469e4 --- /dev/null +++ b/esphome/components/fusb302b/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 ENTITY_CATEGORY_DIAGNOSTIC, ICON_FLASH +from esphome.types import ConfigType + +from . import CONF_FUSB302B_ID, FUSB302B + +DEPENDENCIES = ["fusb302b"] + +CONF_CONTRACT = "contract" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_FUSB302B_ID): cv.use_id(FUSB302B), + cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( + icon=ICON_FLASH, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_FUSB302B_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_CONTRACT, hub.set_contract_text_sensor) diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml index e2c4ee87ca7..60c8cc615e9 100644 --- a/tests/components/fusb302b/common.yaml +++ b/tests/components/fusb302b/common.yaml @@ -3,12 +3,6 @@ fusb302b: i2c_id: i2c_bus interrupt_pin: ${interrupt_pin} request_voltage: 20 - contract: - name: PD Contract - voltage: - name: PD Voltage - current: - name: PD Current on_connect: - logger.log: "Source attached" on_disconnect: @@ -26,3 +20,17 @@ fusb302b: - fusb302b.request_voltage: id: pd voltage: !lambda "return 9;" + +sensor: + - platform: fusb302b + fusb302b_id: pd + voltage: + name: PD Voltage + current: + name: PD Current + +text_sensor: + - platform: fusb302b + fusb302b_id: pd + contract: + name: PD Contract From 3b7847d951a9cc70a8153885df2d254b600424c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:13:49 -1000 Subject: [PATCH 544/651] [components] Create SPI devices with new_spi_component (#20920) --- esphome/components/adc128s102/__init__.py | 5 +---- esphome/components/ads1118/__init__.py | 5 +---- esphome/components/atm90e26/sensor.py | 5 +---- esphome/components/cc1101/__init__.py | 4 +--- esphome/components/cs5460a/sensor.py | 4 +--- esphome/components/honeywellabp/sensor.py | 5 +---- esphome/components/mcp3008/__init__.py | 5 +---- esphome/components/sx126x/__init__.py | 4 +--- esphome/components/sx127x/__init__.py | 4 +--- 9 files changed, 9 insertions(+), 32 deletions(-) diff --git a/esphome/components/adc128s102/__init__.py b/esphome/components/adc128s102/__init__.py index 125b4113c57..56df70a170c 100644 --- a/esphome/components/adc128s102/__init__.py +++ b/esphome/components/adc128s102/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -20,6 +19,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/ads1118/__init__.py b/esphome/components/ads1118/__init__.py index 98129ecbea3..e7cc1b4f45a 100644 --- a/esphome/components/ads1118/__init__.py +++ b/esphome/components/ads1118/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@solomondg1"] @@ -26,6 +25,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 5a6b667535f..ed116faaba0 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_FORWARD_ACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, CONF_POWER_FACTOR, @@ -125,9 +124,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 2660d5bbde7..5375d6bf396 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -274,9 +274,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) for opt in CONFIG_MAP: key = opt.schema diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 54e452efc88..eab99fde147 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -103,9 +103,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) cg.add(var.set_samples(config[CONF_SAMPLES])) cg.add(var.set_phase_offset(config[CONF_PHASE_OFFSET])) diff --git a/esphome/components/honeywellabp/sensor.py b/esphome/components/honeywellabp/sensor.py index d7e7cf29a4e..2549610b8ed 100644 --- a/esphome/components/honeywellabp/sensor.py +++ b/esphome/components/honeywellabp/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, spi import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -52,9 +51,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_PRESSURE, var.set_pressure_sensor): diff --git a/esphome/components/mcp3008/__init__.py b/esphome/components/mcp3008/__init__.py index f29050f9fe5..aa3fb5c64ef 100644 --- a/esphome/components/mcp3008/__init__.py +++ b/esphome/components/mcp3008/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -22,6 +21,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 075aad534e7..3af042ea72b 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -238,9 +238,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 4b54c646338..08660415a62 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -221,9 +221,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), From ec9a207f42c7869d64daa1c49c95a68e3ef23681 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:15:21 -1000 Subject: [PATCH 545/651] [components] Create UART devices with new_uart_component (#20919) --- esphome/components/apc1/sensor.py | 5 +---- esphome/components/bl0906/sensor.py | 4 +--- esphome/components/bl0939/sensor.py | 5 +---- esphome/components/bl0940/sensor.py | 5 +---- esphome/components/bl0942/sensor.py | 5 +---- esphome/components/cm1106/sensor.py | 5 +---- esphome/components/cse7761/sensor.py | 5 +---- esphome/components/cse7766/sensor.py | 5 +---- esphome/components/daly_bms/__init__.py | 6 ++---- esphome/components/dfplayer/__init__.py | 6 ++---- esphome/components/dfrobot_sen0395/__init__.py | 4 +--- esphome/components/dfrobot_sen0609/__init__.py | 4 +--- esphome/components/gcja5/sensor.py | 5 +---- esphome/components/gps/__init__.py | 4 +--- esphome/components/hc8/sensor.py | 4 +--- esphome/components/hlk_fm22x/__init__.py | 5 +---- esphome/components/hlw8032/sensor.py | 5 +---- esphome/components/hydreon_rgxx/sensor.py | 5 +---- esphome/components/kamstrup_kmp/sensor.py | 5 +---- esphome/components/ld2410/__init__.py | 4 +--- esphome/components/ld2410s/__init__.py | 5 +---- esphome/components/ld2412/__init__.py | 6 ++---- esphome/components/ld2420/__init__.py | 5 +---- esphome/components/ld2450/__init__.py | 6 ++---- esphome/components/ld2460/__init__.py | 6 ++---- esphome/components/ld6002b/__init__.py | 6 ++---- esphome/components/mhz19/sensor.py | 4 +--- esphome/components/micronova/__init__.py | 5 +---- esphome/components/mitsubishi_cn105/__init__.py | 4 +--- esphome/components/mk2pvrouter/__init__.py | 6 ++---- esphome/components/pipsolar/__init__.py | 5 +---- esphome/components/pm1006/sensor.py | 5 +---- esphome/components/pmsx003/sensor.py | 5 +---- esphome/components/pylontech/__init__.py | 5 +---- esphome/components/pzem004t/sensor.py | 5 +---- esphome/components/rd03d/__init__.py | 6 ++---- esphome/components/rdm6300/__init__.py | 6 ++---- esphome/components/rf_bridge/__init__.py | 5 +---- esphome/components/sds011/sensor.py | 5 +---- esphome/components/seeed_mr60bha2/__init__.py | 5 +---- esphome/components/seeed_mr60fda2/__init__.py | 5 +---- esphome/components/senseair/sensor.py | 4 +--- esphome/components/serial_proxy/__init__.py | 6 ++---- esphome/components/sim800l/__init__.py | 6 ++---- esphome/components/sm300d2/sensor.py | 5 +---- esphome/components/sml/__init__.py | 6 ++---- esphome/components/smt100/sensor.py | 5 +---- esphome/components/sun_gtil2/__init__.py | 5 +---- esphome/components/systa_bus/__init__.py | 5 +---- esphome/components/t6615/sensor.py | 5 +---- esphome/components/teleinfo/__init__.py | 5 +---- esphome/components/tmc22xx/__init__.py | 4 +--- esphome/components/tuya/__init__.py | 6 ++---- esphome/components/uart_tcp/__init__.py | 4 +--- esphome/components/ufm01/__init__.py | 4 +--- esphome/components/uponor_smatrix/__init__.py | 6 ++---- esphome/components/vbus/__init__.py | 5 +---- esphome/components/zwave_proxy/__init__.py | 6 ++---- 58 files changed, 73 insertions(+), 220 deletions(-) diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py index b64a49903f2..f3f7e0203f9 100644 --- a/esphome/components/apc1/sensor.py +++ b/esphome/components/apc1/sensor.py @@ -7,7 +7,6 @@ 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, @@ -165,9 +164,7 @@ 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 25c50555dbc..d8e2f092c06 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -147,9 +147,7 @@ automation.register_apply_action( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FREQUENCY, var.set_frequency_sensor) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 5e33759dea4..45a2e6e983d 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_MODE, CONF_VOLTAGE, @@ -112,9 +111,7 @@ CONFIG_SCHEMA = ( 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) + var = await uart.new_uart_component(config) if (mode := config[CONF_MODE]) != DEFAULT_MODE: cg.add(var.set_mode(mode)) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 1cf7e8e6e38..962abf636d1 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_ENERGY, CONF_EXTERNAL_TEMPERATURE, - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_POWER, CONF_REFERENCE_VOLTAGE, @@ -227,9 +226,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index 397471cf7a9..a5e6ffb64db 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CURRENT, CONF_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, CONF_RESET, @@ -97,9 +96,7 @@ CONFIG_SCHEMA = ( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index fa179f9667d..43c1f44d3d1 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_BASELINE, CONF_CO2, CONF_CYCLE, - CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, ICON_MOLECULE_CO2, STATE_CLASS_MEASUREMENT, @@ -68,9 +67,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: """Code generation entry point.""" - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 1618cb43962..17c23242f7f 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -3,7 +3,6 @@ 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, @@ -121,9 +120,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config, config[CONF_CURRENT_INPUT]) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 86b6965e3a2..d5162230db0 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_APPARENT_POWER, CONF_CURRENT, CONF_ENERGY, - CONF_ID, CONF_POWER, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, @@ -94,9 +93,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index 01d0eef857f..e2234985d43 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.const import CONF_ADDRESS from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] @@ -37,7 +37,5 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 69b745df9cf..719a76ff07d 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -5,7 +5,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_LOOP import esphome.config_validation as cv -from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME +from esphome.const import CONF_DEVICE, CONF_FILE, CONF_VOLUME DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] @@ -51,9 +51,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index d4e7aed91d8..801c2654a01 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -42,9 +42,7 @@ CONFIG_SCHEMA = cv.All( 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 uart.new_uart_component(config) automation.register_apply_action( diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py index 76ca362949a..d588d70efd1 100644 --- a/esphome/components/dfrobot_sen0609/__init__.py +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -79,7 +79,5 @@ def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, RUN_MODES[config[CONF_MODE]]) diff --git a/esphome/components/gcja5/sensor.py b/esphome/components/gcja5/sensor.py index 49907443ff9..85110dafc25 100644 --- a/esphome/components/gcja5/sensor.py +++ b/esphome/components/gcja5/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -113,9 +112,7 @@ TYPES = { 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) + var = await uart.new_uart_component(config) for key, funcName in TYPES.items(): if key in config: diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index cc77dff496b..be1b13b70e9 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -96,9 +96,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("gps", require_rx=True 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) + var = await uart.new_uart_component(config) # Pre-create all sensor variables so automations that reference # sibling sensors don't deadlock waiting for unregistered IDs. diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 10074fbf10f..7ef0ce14c50 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -51,9 +51,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index 72d3cc2ada3..a013fe884d3 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -4,7 +4,6 @@ from esphome.components import uart import esphome.config_validation as cv from esphome.const import ( CONF_DIRECTION, - CONF_ID, CONF_NAME, CONF_ON_ENROLLMENT_DONE, CONF_ON_ENROLLMENT_FAILED, @@ -90,9 +89,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py index f2a8ffb2161..09cfe7320b4 100644 --- a/esphome/components/hlw8032/sensor.py +++ b/esphome/components/hlw8032/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_APPARENT_POWER, CONF_CURRENT, CONF_CURRENT_RESISTOR, - CONF_ID, CONF_POWER, CONF_POWER_FACTOR, CONF_VOLTAGE, @@ -75,9 +74,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index 222a5308ae1..eeab5303922 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_MODEL, CONF_MOISTURE, CONF_RESOLUTION, @@ -140,10 +139,8 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add_define( "HYDREON_RGXX_PROTOCOL_LIST(F, sep)", diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index f6c236b72df..ae00f86e264 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_COMMAND, CONF_CUSTOM, CONF_FLOW, - CONF_ID, CONF_POWER, CONF_VOLUME, DEVICE_CLASS_EMPTY, @@ -113,9 +112,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) # Standard sensors for key in [ diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index e2728aa73c7..a03d87d9168 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -72,9 +72,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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 uart.new_uart_component(config) CALIBRATION_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py index ecc90455d1c..f4e09cc1867 100644 --- a/esphome/components/ld2410s/__init__.py +++ b/esphome/components/ld2410s/__init__.py @@ -1,7 +1,6 @@ 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 AUTO_LOAD = ["ld24xx"] @@ -37,6 +36,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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 uart.new_uart_component(config) diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index fd63f78c3d4..9a1772e780d 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.const import CONF_THROTTLE from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -43,6 +43,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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 uart.new_uart_component(config) diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py index 87a54d278a7..b92e84d5965 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -1,7 +1,6 @@ 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 = ["@descipher"] @@ -36,6 +35,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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 uart.new_uart_component(config) diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 066ace871f8..dfca220b6a7 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE +from esphome.const import CONF_ON_DATA, CONF_THROTTLE from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -63,7 +63,5 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld2460/__init__.py b/esphome/components/ld2460/__init__.py index 88c3a173efe..84cba297190 100644 --- a/esphome/components/ld2460/__init__.py +++ b/esphome/components/ld2460/__init__.py @@ -2,7 +2,7 @@ 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.const import CONF_ON_DATA from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -41,7 +41,5 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index c7529f93149..48017a8f00e 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_WAKEUP_PIN +from esphome.const import CONF_WAKEUP_PIN from esphome.types import ConfigType from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE @@ -62,9 +62,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): pin = await cg.gpio_pin_expression(wakeup_pin_config) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 4daeef5059c..5c5b24f6f48 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -73,9 +73,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index ff06d0b913d..3745b1084ba 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -4,7 +4,6 @@ from esphome import pins 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, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.cpp_generator import MockObj @@ -106,9 +105,7 @@ async def to_code_micronova_listener( async def to_code(config: ConfigType) -> None: enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) - var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, enable_rx_pin) CORE.add_job(_final_step) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 442869eec53..ba51bd3a806 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -90,9 +90,7 @@ async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> 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) + var = await uart.new_uart_component(config) cg.add( var.set_telemetry_request_min_interval( config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] diff --git a/esphome/components/mk2pvrouter/__init__.py b/esphome/components/mk2pvrouter/__init__.py index 7df1f7104f5..3d84c40c9ed 100644 --- a/esphome/components/mk2pvrouter/__init__.py +++ b/esphome/components/mk2pvrouter/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TAG +from esphome.const import CONF_TAG from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -65,6 +65,4 @@ async def register_mk2pvrouter_listener(mk2pvrouter: MockObj, var: MockObj) -> N 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 uart.new_uart_component(config) diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index 54337b0b130..77b97b24719 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -1,7 +1,6 @@ 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 DEPENDENCIES = ["uart"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = cv.All( 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 uart.new_uart_component(config) diff --git a/esphome/components/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index 98c64066761..fc1bec8296b 100644 --- a/esphome/components/pm1006/sensor.py +++ b/esphome/components/pm1006/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_2_5, CONF_UPDATE_INTERVAL, DEVICE_CLASS_PM25, @@ -58,9 +57,7 @@ FINAL_VALIDATE_SCHEMA = validate_interval_uart 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 723b99a5040..55f0c0678b2 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_PM_0_3UM, CONF_PM_0_5UM, CONF_PM_1_0, @@ -328,9 +327,7 @@ 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) + var = await uart.new_uart_component(config) cg.add(var.set_type(config[CONF_TYPE])) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index e15f8f04c82..3fc194da057 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -6,7 +6,6 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -108,7 +107,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index 2b19cf84985..9a2b73ea4cc 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, CONF_ENERGY, - CONF_ID, CONF_POWER, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, @@ -65,9 +64,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index 96bf4ffd743..ff2eaebad59 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.const import CONF_THROTTLE from esphome.types import ConfigType CODEOWNERS = ["@jasstrong"] @@ -41,9 +41,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) if CONF_TRACKING_MODE in config: cg.add(var.set_tracking_mode(config[CONF_TRACKING_MODE])) diff --git a/esphome/components/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index 86eb2cd7380..18d8e551f95 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -2,7 +2,7 @@ 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_TAG, CONF_TRIGGER_ID +from esphome.const import CONF_ON_TAG, CONF_TRIGGER_ID from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -37,9 +37,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index e967335c180..e970df9faa8 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CODE, CONF_DURATION, CONF_HIGH, - CONF_ID, CONF_LENGTH, CONF_LOW, CONF_PROTOCOL, @@ -70,9 +69,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index fe1481df9a5..93a2b9b0212 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_2_5, CONF_PM_10_0, CONF_RX_ONLY, @@ -85,9 +84,7 @@ async def to_code(config: ConfigType) -> None: # Pop update_interval before register_component so it doesn't generate # a set_update_interval call — sds011 handles this via set_update_interval_min update_interval = config.pop(CONF_UPDATE_INTERVAL, None) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if update_interval is not None: cg.add(var.set_update_interval_min(update_interval)) diff --git a/esphome/components/seeed_mr60bha2/__init__.py b/esphome/components/seeed_mr60bha2/__init__.py index 7a85567510a..bf91e4b1e27 100644 --- a/esphome/components/seeed_mr60bha2/__init__.py +++ b/esphome/components/seeed_mr60bha2/__init__.py @@ -1,7 +1,6 @@ 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 = ["@limengdu"] @@ -38,6 +37,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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 uart.new_uart_component(config) diff --git a/esphome/components/seeed_mr60fda2/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index cc227415831..081581683a2 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -1,7 +1,6 @@ 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 = ["@limengdu"] @@ -39,6 +38,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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 uart.new_uart_component(config) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 2494e9a6d1f..5222d56a106 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -49,9 +49,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 6c3327e556f..35b65f6bae3 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -19,7 +19,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.usb_uart import is_usb_uart_channel import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID +from esphome.const import CONF_NAME, CONF_UART_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv @@ -94,9 +94,7 @@ async def _add_serial_proxy_count_define() -> None: 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) + var = await uart.new_uart_component(config) cg.add(cg.App.register_serial_proxy(var)) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_port_type(config[CONF_PORT_TYPE])) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index efc742610d9..2500e4684bf 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -2,7 +2,7 @@ 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_MESSAGE +from esphome.const import CONF_MESSAGE DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] @@ -66,10 +66,8 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index 4245283ab48..b63db068e63 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CO2, CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_PM_2_5, CONF_PM_10_0, CONF_TEMPERATURE, @@ -95,9 +94,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index 35ca231f023..587cb13662b 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -5,7 +5,7 @@ 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.const import CONF_ON_DATA from esphome.types import ConfigType CODEOWNERS = ["@alengwenus"] @@ -50,9 +50,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index fba10eab9ae..115434ee9ac 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_COUNTS, CONF_DIELECTRIC_CONSTANT, - CONF_ID, CONF_MOISTURE, CONF_PERMITTIVITY, CONF_TEMPERATURE, @@ -79,10 +78,8 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_COUNTS, var.set_counts_sensor) diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index 61643cf4883..b66f7075bea 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -1,7 +1,6 @@ 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 = ["@Mat931"] @@ -27,6 +26,4 @@ CONFIG_SCHEMA = ( 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 uart.new_uart_component(config) diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py index ae44017112b..ee193c4d3a2 100644 --- a/esphome/components/systa_bus/__init__.py +++ b/esphome/components/systa_bus/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -39,6 +38,4 @@ async def register_systa_bus_listener(systa_bus: MockObj, var: MockObj) -> None: 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 uart.new_uart_component(config) diff --git a/esphome/components/t6615/sensor.py b/esphome/components/t6615/sensor.py index 4b055516e79..42223b373ab 100644 --- a/esphome/components/t6615/sensor.py +++ b/esphome/components/t6615/sensor.py @@ -3,7 +3,6 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_CO2, - CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, @@ -44,9 +43,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 5a6a01f6695..1353b6a912f 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -1,7 +1,6 @@ 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 = ["@0hax", "@FredM67"] @@ -53,6 +52,4 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_HISTORICAL_MODE]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, config[CONF_HISTORICAL_MODE]) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index ce06cb82e3c..a3ac518d8eb 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -162,9 +162,7 @@ def final_validate(config: ConfigType) -> ConfigType: 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) + var = await uart.new_uart_component(config) await stepper.register_stepper(var, config) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index c05db51cdf9..51db405512c 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -2,7 +2,7 @@ from esphome import automation, pins import esphome.codegen as cg 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.const import CONF_SENSOR_DATAPOINT, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -83,9 +83,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await uart.new_uart_component(config) 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_)) diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index c7eba4a26a5..4a69029e6f7 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -116,9 +116,7 @@ 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) + var = await uart.new_uart_component(config) if config[CONF_ROLE] == "server": socket.require_tcp_listener() cg.add(var.set_server(True)) diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index 1846958e591..ced3e3d0af5 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -66,9 +66,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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 uart.new_uart_component(config) if _get_data().clear_action_used: cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index 5dc32259ea2..e23f4157a7e 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID, CONF_TIME_ID +from esphome.const import CONF_ADDRESS, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -66,9 +66,7 @@ UPONOR_SMATRIX_DEVICE_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: cg.add_global(uponor_smatrix_ns.using) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if time_id := config.get(CONF_TIME_ID): time_ = await cg.get_variable(time_id) diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 533acfbbb92..92709915ad4 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -1,7 +1,6 @@ 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 = ["@ssieb"] @@ -40,6 +39,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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 uart.new_uart_component(config) diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index e451fe79f4d..7ed4ec4b59e 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI +from esphome.const import CONF_POWER_SAVE_MODE, CONF_WIFI import esphome.final_validate as fv from esphome.types import ConfigType @@ -38,7 +38,5 @@ 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) + await uart.new_uart_component(config) cg.add_define("USE_ZWAVE_PROXY") From ec642f928f7c1d0b8806316d6c6ecb8072ecc37b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:15:36 -1000 Subject: [PATCH 546/651] [i2c] Add little-endian and signed byte decoding helpers (#20938) --- esphome/components/i2c/i2c.cpp | 5 ++ esphome/components/i2c/i2c.h | 18 ++++ esphome/core/helpers.h | 37 ++++++-- tests/components/core/test_encode_value.cpp | 48 +++++++++++ tests/components/i2c/test_i2c_le.cpp | 96 +++++++++++++++++++++ 5 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 tests/components/core/test_encode_value.cpp create mode 100644 tests/components/i2c/test_i2c_le.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 61382ccf21e..9d079f9f89e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -94,6 +94,11 @@ bool I2CDevice::write_byte_16(uint8_t a_register, uint16_t data) const { return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; } +bool I2CDevice::write_byte_16_le(uint8_t a_register, uint16_t data) const { + const uint8_t buffer[3] = {a_register, static_cast(data), static_cast(data >> 8)}; + return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; +} + I2CRegister &I2CRegister::operator=(uint8_t value) { this->parent_->write_register(this->register_, &value, 1); return *this; diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 6a9a6533436..d5690b92063 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -280,6 +280,21 @@ class I2CDevice { bool read_byte_16(uint8_t a_register, uint16_t *data) { return read_bytes_16(a_register, data, 1); } + /// Read len 16-bit values stored least significant byte first; no conversion is compiled on little-endian hosts. + bool read_bytes_16_le(uint8_t a_register, uint16_t *data, uint8_t len) { + if (this->read_register(a_register, reinterpret_cast(data), len * 2) != ERROR_OK) + return false; + for (size_t i = 0; i < len; i++) + data[i] = convert_little_endian(data[i]); + return true; + } + bool read_bytes_16_le(uint8_t a_register, int16_t *data, uint8_t len) { + return this->read_bytes_16_le(a_register, reinterpret_cast(data), len); + } + + /// Read a 16-bit value stored least significant byte first. + bool read_byte_16_le(uint8_t a_register, uint16_t *data) { return this->read_bytes_16_le(a_register, data, 1); } + bool write_bytes(uint8_t a_register, const uint8_t *data, uint8_t len) const { return write_register(a_register, data, len) == ERROR_OK; } @@ -298,6 +313,9 @@ class I2CDevice { bool write_byte_16(uint8_t a_register, uint16_t data) const; + /// Write a 16-bit value least significant byte first. + bool write_byte_16_le(uint8_t a_register, uint16_t data) const; + protected: uint8_t address_{0x00}; ///< store the address of the device on the bus I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 2989655a29a..c3e752c9690 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1015,19 +1015,42 @@ template constexpr int32_t sign_extend(uint32_t value) { } /// Encode a value from its constituent bytes (from most to least significant) in an array with length sizeof(T). -template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { - T val = 0; - for (size_t i = 0; i < sizeof(T); i++) { - val <<= 8; - val |= bytes[i]; +/// A signed T takes the two's complement of the assembled bits. +template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { + if constexpr (sizeof(T) == 1) { + return static_cast(bytes[0]); + } else { + std::make_unsigned_t val = 0; + for (size_t i = 0; i < sizeof(T); i++) { + val <<= 8; + val |= bytes[i]; + } + return static_cast(val); } - return val; } /// Encode a value from its constituent bytes (from most to least significant) in an std::array with length sizeof(T). -template::value, int> = 0> +template::value, int> = 0> constexpr T encode_value(const std::array bytes) { return encode_value(bytes.data()); } +/// Encode a value from its constituent bytes (from least to most significant) in an array with length sizeof(T). +/// A signed T takes the two's complement of the assembled bits. +template::value, int> = 0> +constexpr T encode_value_le(const uint8_t *bytes) { + // Loops here grow 16-bit callers by up to 48 bytes on ESP8266 through different inlining + if constexpr (sizeof(T) == 1) { + return static_cast(bytes[0]); + } else if constexpr (sizeof(T) == 2) { + return static_cast(encode_uint16(bytes[1], bytes[0])); + } else { + std::make_unsigned_t val = 0; + for (size_t i = sizeof(T); i > 0; i--) { + val <<= 8; + val |= bytes[i - 1]; + } + return static_cast(val); + } +} /// Decode a value into its constituent bytes (from most to least significant). template::value, int> = 0> constexpr std::array decode_value(T val) { diff --git a/tests/components/core/test_encode_value.cpp b/tests/components/core/test_encode_value.cpp new file mode 100644 index 00000000000..c76ae50935a --- /dev/null +++ b/tests/components/core/test_encode_value.cpp @@ -0,0 +1,48 @@ +#include + +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static constexpr uint8_t BYTES[] = {0x12, 0x34, 0x56, 0x78}; + +static_assert(encode_value_le(BYTES) == 0x3412); +static_assert(encode_value(BYTES) == 0x1234); + +TEST(EncodeValue, LittleEndianUnsigned) { + EXPECT_EQ(encode_value_le(BYTES), 0x12); + EXPECT_EQ(encode_value_le(BYTES + 1), 0x5634); + EXPECT_EQ(encode_value_le(BYTES), 0x78563412u); +} + +TEST(EncodeValue, BigEndianSigned) { + static constexpr uint8_t MINUS_ONE[] = {0xFF, 0xFF, 0xFF, 0xFF}; + static constexpr uint8_t MIN16[] = {0x80, 0x00}; + static constexpr uint8_t MIN32[] = {0x80, 0x00, 0x00, 0x00}; + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MIN16), INT16_MIN); + EXPECT_EQ(encode_value(MIN32), INT32_MIN); +} + +TEST(EncodeValue, LittleEndianSigned) { + static constexpr uint8_t MIN16[] = {0x00, 0x80}; + static constexpr uint8_t MAX16[] = {0xFF, 0x7F}; + static constexpr uint8_t MINUS_TWO[] = {0xFE, 0xFF}; + static constexpr uint8_t MINUS_256[] = {0x00, 0xFF, 0xFF, 0xFF}; + EXPECT_EQ(encode_value_le(MIN16), INT16_MIN); + EXPECT_EQ(encode_value_le(MAX16), INT16_MAX); + EXPECT_EQ(encode_value_le(MINUS_TWO), -2); + EXPECT_EQ(encode_value_le(MINUS_256), -256); +} + +TEST(EncodeValue, BoolStillCompiles) { + static constexpr uint8_t ONE[] = {0x01}; + static_assert(encode_value(ONE)); + static_assert(encode_value_le(ONE)); +} + +} // namespace esphome::core::testing diff --git a/tests/components/i2c/test_i2c_le.cpp b/tests/components/i2c/test_i2c_le.cpp new file mode 100644 index 00000000000..973490f28d3 --- /dev/null +++ b/tests/components/i2c/test_i2c_le.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +class MemoryBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + if (this->fail) + return ERROR_NOT_ACKNOWLEDGED; + if (write_count == 0) + return ERROR_INVALID_ARGUMENT; + uint8_t reg = write_buffer[0]; + for (size_t i = 1; i < write_count; i++) + this->memory[reg + i - 1] = write_buffer[i]; + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = this->memory[reg + i]; + this->last_write_count = write_count; + return ERROR_OK; + } + + std::array memory{}; + size_t last_write_count{0}; + bool fail{false}; +}; + +class I2CLittleEndianTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + MemoryBus bus_; + I2CDevice device_; +}; + +TEST_F(I2CLittleEndianTest, ReadByte16LittleEndian) { + this->bus_.memory[4] = 0x34; + this->bus_.memory[5] = 0x12; + uint16_t value = 0; + ASSERT_TRUE(this->device_.read_byte_16_le(4, &value)); + EXPECT_EQ(value, 0x1234); + ASSERT_TRUE(this->device_.read_byte_16(4, &value)); + EXPECT_EQ(value, 0x3412); +} + +TEST_F(I2CLittleEndianTest, ReadBytes16LittleEndian) { + const uint8_t raw[6] = {0x34, 0x12, 0xFF, 0xFF, 0x00, 0x80}; + std::copy(raw, raw + 6, this->bus_.memory.begin() + 8); + uint16_t values[3] = {}; + ASSERT_TRUE(this->device_.read_bytes_16_le(8, values, 3)); + EXPECT_EQ(values[0], 0x1234); + EXPECT_EQ(values[1], 0xFFFF); + EXPECT_EQ(values[2], 0x8000); + int16_t signed_values[3] = {}; + ASSERT_TRUE(this->device_.read_bytes_16_le(8, signed_values, 3)); + EXPECT_EQ(signed_values[0], 0x1234); + EXPECT_EQ(signed_values[1], -1); + EXPECT_EQ(signed_values[2], INT16_MIN); +} + +TEST_F(I2CLittleEndianTest, ReadBytes16FailureReturnsFalse) { + this->bus_.fail = true; + int16_t values[2] = {}; + EXPECT_FALSE(this->device_.read_bytes_16_le(0, values, 2)); +} + +TEST_F(I2CLittleEndianTest, ReadFailureReturnsFalse) { + this->bus_.fail = true; + uint16_t value = 0xBEEF; + EXPECT_FALSE(this->device_.read_byte_16_le(0, &value)); + EXPECT_EQ(value, 0xBEEF); +} + +TEST_F(I2CLittleEndianTest, WriteByte16LittleEndian) { + ASSERT_TRUE(this->device_.write_byte_16_le(2, 0xA1B2)); + EXPECT_EQ(this->bus_.last_write_count, 3u); + EXPECT_EQ(this->bus_.memory[2], 0xB2); + EXPECT_EQ(this->bus_.memory[3], 0xA1); + uint16_t value = 0; + ASSERT_TRUE(this->device_.read_byte_16_le(2, &value)); + EXPECT_EQ(value, 0xA1B2); +} + +TEST_F(I2CLittleEndianTest, WriteFailureReturnsFalse) { + this->bus_.fail = true; + EXPECT_FALSE(this->device_.write_byte_16_le(2, 0xA1B2)); +} + +} // namespace esphome::i2c::testing From c1687aba4c441a2783cd36745311d0acb1f2bcf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:16:21 -1000 Subject: [PATCH 547/651] [components] Let core report communication failures in dump_config (5/5) (#20874) --- esphome/components/shtcx/shtcx.cpp | 6 +--- esphome/components/sps30/sps30.cpp | 31 ++----------------- esphome/components/sps30/sps30.h | 10 ------ .../components/ssd1306_i2c/ssd1306_i2c.cpp | 7 +---- esphome/components/ssd1306_i2c/ssd1306_i2c.h | 2 -- .../components/ssd1327_i2c/ssd1327_i2c.cpp | 7 +---- esphome/components/ssd1327_i2c/ssd1327_i2c.h | 2 -- esphome/components/st7567_i2c/st7567_i2c.cpp | 7 +---- esphome/components/st7567_i2c/st7567_i2c.h | 2 -- esphome/components/stcc4/stcc4.cpp | 12 ++----- esphome/components/sts3x/sts3x.cpp | 7 ++--- esphome/components/stts22h/stts22h.cpp | 3 -- esphome/components/sx126x/sx126x.cpp | 8 ++--- esphome/components/sx127x/sx127x.cpp | 11 ++----- esphome/components/sx1509/sx1509.cpp | 9 ++---- esphome/components/sy6970/sy6970.cpp | 6 +--- esphome/components/tc74/tc74.cpp | 5 +-- esphome/components/tca8418/tca8418.cpp | 15 +++------ esphome/components/tca9555/tca9555.cpp | 7 ++--- esphome/components/tcs34725/tcs34725.cpp | 13 +++----- .../components/tlc59208f/tlc59208f_output.cpp | 19 +++--------- esphome/components/tmc22xx/tmc22xx.cpp | 3 -- esphome/components/tmp102/tmp102.cpp | 5 +-- esphome/components/tmp1075/tmp1075.cpp | 6 ++-- esphome/components/tmp117/tmp117.cpp | 7 ++--- esphome/components/tsl2561/tsl2561.cpp | 8 ++--- esphome/components/tsl2591/tsl2591.cpp | 4 +-- esphome/components/ttp229_lsf/ttp229_lsf.cpp | 11 +------ esphome/components/ttp229_lsf/ttp229_lsf.h | 4 --- esphome/components/veml3235/veml3235.cpp | 11 ++----- esphome/components/veml7700/veml7700.cpp | 7 +---- .../waveshare_io_ch32v003.cpp | 6 +--- esphome/components/xgzp68xx/xgzp68xx.cpp | 7 ++--- esphome/components/xl9535/xl9535.cpp | 6 +--- 34 files changed, 54 insertions(+), 220 deletions(-) diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index 68f2c4ea21e..814e09162ec 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -29,8 +29,7 @@ void SHTCXComponent::setup() { uint16_t device_id_register; if (!this->write_command(SHTCX_COMMAND_READ_ID_REGISTER) || !this->read_data(&device_id_register, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -53,9 +52,6 @@ void SHTCXComponent::dump_config() { " Model: %s (%04x)", LOG_STR_ARG(shtcx_type_to_string(this->type_)), this->sensor_id_); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index 3a2ebb14c39..42628e64c91 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -29,15 +29,13 @@ void SPS30Component::setup() { this->set_timeout(500, [this]() { /// Firmware version identification if (!this->get_register(SPS30_CMD_GET_FIRMWARE_VERSION, raw_firmware_version_, 1)) { - this->error_code_ = FIRMWARE_VERSION_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read firmware version")); return; } /// Serial number identification uint16_t raw_serial_number[8]; if (!this->get_register(SPS30_CMD_GET_SERIAL_NUMBER, raw_serial_number, 8, 1)) { - this->error_code_ = SERIAL_NUMBER_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read serial number")); return; } @@ -75,31 +73,6 @@ void SPS30Component::setup() { void SPS30Component::dump_config() { ESP_LOGCONFIG(TAG, "SPS30:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); - break; - case SERIAL_NUMBER_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request serial number"); - break; - case SERIAL_NUMBER_READ_FAILED: - ESP_LOGW(TAG, "Unable to read serial number"); - break; - case FIRMWARE_VERSION_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request firmware version"); - break; - case FIRMWARE_VERSION_READ_FAILED: - ESP_LOGW(TAG, "Unable to read firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Serial number: %s\n" diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index 10b89c844bc..c1e47f089ac 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -42,16 +42,6 @@ class SPS30Component final : public PollingComponent, public sensirion_common::S bool start_continuous_measurement_(); - enum ErrorCode : uint8_t { - COMMUNICATION_FAILED, - FIRMWARE_VERSION_REQUEST_FAILED, - FIRMWARE_VERSION_READ_FAILED, - SERIAL_NUMBER_REQUEST_FAILED, - SERIAL_NUMBER_READ_FAILED, - MEASUREMENT_INIT_FAILED, - UNKNOWN - } error_code_{UNKNOWN}; - sensor::Sensor *pm_1_0_sensor_{nullptr}; sensor::Sensor *pm_2_5_sensor_{nullptr}; sensor::Sensor *pm_4_0_sensor_{nullptr}; diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 1cdfed9f502..7081d766e32 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -10,8 +10,7 @@ void I2CSSD1306::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -32,10 +31,6 @@ void I2CSSD1306::dump_config() { LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1306::write_display_data() { diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.h b/esphome/components/ssd1306_i2c/ssd1306_i2c.h index 54c7d862870..e77f4464f5f 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.h +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.h @@ -14,8 +14,6 @@ class I2CSSD1306 final : public ssd1306_base::SSD1306, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::ssd1306_i2c diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp index 98328c800c1..11cf3296a15 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp @@ -10,8 +10,7 @@ void I2CSSD1327::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -23,10 +22,6 @@ void I2CSSD1327::dump_config() { ESP_LOGCONFIG(TAG, " Model: %s", this->model_str_()); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CSSD1327::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1327::write_display_data() { diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.h b/esphome/components/ssd1327_i2c/ssd1327_i2c.h index 75f854d3da3..7c4ae3bec11 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.h +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.h @@ -14,8 +14,6 @@ class I2CSSD1327 final : public ssd1327_base::SSD1327, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::ssd1327_i2c diff --git a/esphome/components/st7567_i2c/st7567_i2c.cpp b/esphome/components/st7567_i2c/st7567_i2c.cpp index 657a60f9549..ad672270a8a 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.cpp +++ b/esphome/components/st7567_i2c/st7567_i2c.cpp @@ -10,8 +10,7 @@ void I2CST7567::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ST7567::setup(); @@ -28,10 +27,6 @@ void I2CST7567::dump_config() { LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CST7567::command(uint8_t value) { this->write_byte(0x00, value); } diff --git a/esphome/components/st7567_i2c/st7567_i2c.h b/esphome/components/st7567_i2c/st7567_i2c.h index eea3068e030..f17ec419785 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.h +++ b/esphome/components/st7567_i2c/st7567_i2c.h @@ -14,8 +14,6 @@ class I2CST7567 final : public st7567_base::ST7567, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::st7567_i2c diff --git a/esphome/components/stcc4/stcc4.cpp b/esphome/components/stcc4/stcc4.cpp index ac7a998e5d6..a5e47584eb0 100644 --- a/esphome/components/stcc4/stcc4.cpp +++ b/esphome/components/stcc4/stcc4.cpp @@ -77,8 +77,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) (uint64_t(raw_product_id[4]) << 16) | raw_product_id[5]; ESP_LOGD(TAG, "Product ID: 0x%08" PRIX32 ", Serial: 0x%016" PRIX64, product_id, serial_number); if (product_id != STCC4_PRODUCT_ID) { - ESP_LOGE(TAG, "Unsupported product ID"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Unsupported product ID")); return; } @@ -106,8 +105,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) // Start continuous measurement if (!this->write_command(STCC4_CMD_START_CONTINUOUS_MEASUREMENT)) { - ESP_LOGE(TAG, "Failed to start continuous measurement"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Failed to start continuous measurement")); return; } this->schedule_continuous_update_(false); @@ -123,8 +121,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) return; } - ESP_LOGE(TAG, "Failed to stop continuous measurements and read product ID"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Failed to stop continuous measurements and read product ID")); } void STCC4Component::finish_setup_() { @@ -143,9 +140,6 @@ void STCC4Component::finish_setup_() { void STCC4Component::dump_config() { ESP_LOGCONFIG(TAG, "STCC4:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Measurement mode: %s", this->measurement_mode_ == MeasurementMode::CONTINUOUS ? LOG_STR_LITERAL("Continuous (1s)") : LOG_STR_LITERAL("Single shot")); diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index 8b97b2f4997..fd9569b951d 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -18,13 +18,13 @@ static const uint16_t STS3X_COMMAND_FETCH_DATA = 0xE000; void STS3XComponent::setup() { if (!this->write_command(STS3X_COMMAND_READ_SERIAL_NUMBER)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint16_t raw_serial_number[2]; if (!this->read_data(raw_serial_number, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint32_t serial_number = (uint32_t(raw_serial_number[0]) << 16); @@ -33,9 +33,6 @@ void STS3XComponent::setup() { void STS3XComponent::dump_config() { ESP_LOGCONFIG(TAG, "STS3x:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "STS3x", this); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 5132b2a3aa5..fa96c6a0227 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -37,9 +37,6 @@ void STTS22HComponent::dump_config() { LOG_SENSOR("", "STTS22H", this); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } float STTS22HComponent::read_temperature_() { diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 0bac0eb0466..6c95fe5f7a6 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -164,7 +164,7 @@ void SX126x::configure() { this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, sizeof(this->version_)); this->version_[sizeof(this->version_) - 1] = '\0'; if (strncmp(this->version_, "SX126", 5) != 0 && strncmp(this->version_, "LLCC68", 6) != 0) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unsupported chip version")); return; } @@ -494,8 +494,7 @@ void SX126x::wait_busy_() { uint32_t start = millis(); while (this->busy_pin_->digital_read()) { if (millis() - start > BUSY_TIMEOUT_MS) { - ESP_LOGE(TAG, "Wait busy timeout"); - this->mark_failed(); + this->mark_failed(LOG_STR("Wait busy timeout")); break; } } @@ -562,9 +561,6 @@ void SX126x::dump_config() { if (this->whitening_enable_) { ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_); } - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring SX126x failed"); - } } } // namespace esphome::sx126x diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index fb816094c29..f48e8de3a35 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -81,7 +81,7 @@ void SX127x::configure() { // check silicon version to make sure hw is ok if (this->read_register_(REG_VERSION) != 0x12) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unsupported chip version")); return; } @@ -367,8 +367,7 @@ void SX127x::run_image_cal() { uint32_t start = millis(); while (this->read_register_(REG_IMAGE_CAL) & IMAGE_CAL_RUNNING) { if (millis() - start > 20) { - ESP_LOGE(TAG, "Image cal failure"); - this->mark_failed(); + this->mark_failed(LOG_STR("Image cal failure")); break; } } @@ -390,8 +389,7 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) { break; } if (millis() - start > 20) { - ESP_LOGE(TAG, "Set mode failure"); - this->mark_failed(); + this->mark_failed(LOG_STR("Set mode failure")); return; } } @@ -508,9 +506,6 @@ void SX127x::dump_config() { this->preamble_polarity_, this->preamble_size_, this->preamble_detect_, this->preamble_errors_); } } - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring SX127x failed"); - } } } // namespace esphome::sx127x diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 169d35331f8..d883982bcd5 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -9,18 +9,18 @@ ESPHOME_LOG_TAG(TAG, "sx1509"); void SX1509Component::setup() { ESP_LOGV(TAG, " Resetting devices"); if (!this->write_byte(REG_RESET, 0x12)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->write_byte(REG_RESET, 0x34); uint16_t data; if (!this->read_byte_16(REG_INTERRUPT_MASK_A, &data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (data != 0xFF00) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unexpected reset value")); return; } clock_(INTERNAL_CLOCK_2MHZ); @@ -31,9 +31,6 @@ void SX1509Component::setup() { void SX1509Component::dump_config() { ESP_LOGCONFIG(TAG, "SX1509:"); - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up SX1509 failed!"); - } LOG_I2C_DEVICE(this); } diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 5e1611ea558..754cfb14ca8 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -60,8 +60,7 @@ void SY6970Component::setup() { // Try to read chip ID uint8_t reg_value; if (!this->read_byte(SY6970_REG_DEVICE_ID, ®_value)) { - ESP_LOGE(TAG, "Failed to communicate with SY6970"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -116,9 +115,6 @@ void SY6970Component::dump_config() { LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Communication with SY6970 failed!"); - } } void SY6970Component::update() { diff --git a/esphome/components/tc74/tc74.cpp b/esphome/components/tc74/tc74.cpp index 98428cd1225..7bba2b7fd75 100644 --- a/esphome/components/tc74/tc74.cpp +++ b/esphome/components/tc74/tc74.cpp @@ -16,7 +16,7 @@ static const uint8_t TC74_DATA_READY_MASK = 0x40; void TC74Component::setup() { uint8_t config_reg; if (this->read_register(TC74_REGISTER_CONFIGURATION, &config_reg, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->data_ready_ = config_reg & TC74_DATA_READY_MASK; @@ -27,9 +27,6 @@ void TC74Component::update() { this->read_temperature_(); } void TC74Component::dump_config() { LOG_SENSOR("", "TC74", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with TC74 failed!"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp index 7add67e9b09..204bc18c1e5 100644 --- a/esphome/components/tca8418/tca8418.cpp +++ b/esphome/components/tca8418/tca8418.cpp @@ -15,21 +15,18 @@ 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(); + this->mark_failed(LOG_STR("Failed to read from device - check wiring / address")); return; } if (!this->configure_pins_()) { - ESP_LOGE(TAG, "Failed to configure pins"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to configure pins")); 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(); + this->mark_failed(LOG_STR("Failed to write the configuration register")); return; } @@ -43,8 +40,7 @@ void TCA8418Component::setup() { // 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(); + this->mark_failed(LOG_STR("Failed to clear the event queue")); return; } @@ -162,9 +158,6 @@ void TCA8418Component::dump_config() { } 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/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 7a59c403f31..2b234983a20 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -16,11 +16,11 @@ ESPHOME_LOG_TAG(TAG, "tca9555"); void TCA9555Component::setup() { if (!this->read_gpio_modes_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->read_gpio_outputs_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -36,9 +36,6 @@ void TCA9555Component::dump_config() { ESP_LOGCONFIG(TAG, "TCA9555:"); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 8e79ac1c162..979137a9729 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -19,23 +19,23 @@ static const uint8_t TCS34725_REGISTER_CRGBDATAL = TCS34725_COMMAND_BIT | 0x14; void TCS34725Component::setup() { uint8_t id; if (this->read_register(TCS34725_REGISTER_ID, &id, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->write_config_register_(TCS34725_REGISTER_ATIME, this->integration_reg_) != i2c::ERROR_OK || this->write_config_register_(TCS34725_REGISTER_CONTROL, this->gain_reg_) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->write_config_register_(TCS34725_REGISTER_ENABLE, 0x01) != i2c::ERROR_OK) { // Power on (internal oscillator on) - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(3); if (this->write_config_register_(TCS34725_REGISTER_ENABLE, 0x03) != i2c::ERROR_OK) { // Power on (internal oscillator on) + RGBC ADC Enable - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -43,9 +43,6 @@ void TCS34725Component::setup() { void TCS34725Component::dump_config() { ESP_LOGCONFIG(TAG, "TCS34725:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Clear Channel", this->clear_sensor_); @@ -292,7 +289,7 @@ void TCS34725Component::update() { set_gain((TCS34725Gain) gain_reg_val_new); if (this->write_config_register_(TCS34725_REGISTER_ATIME, this->integration_reg_) != i2c::ERROR_OK || this->write_config_register_(TCS34725_REGISTER_CONTROL, this->gain_reg_) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); ESP_LOGW(TAG, "TCS34725I update timing failed!"); } else { this->integration_time_ = integration_time_next; diff --git a/esphome/components/tlc59208f/tlc59208f_output.cpp b/esphome/components/tlc59208f/tlc59208f_output.cpp index d76845b4f03..f73dcbd611b 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.cpp +++ b/esphome/components/tlc59208f/tlc59208f_output.cpp @@ -65,32 +65,27 @@ void TLC59208FOutput::setup() { // Reset all devices on the bus if (this->bus_->write_readv(TLC59208F_SWRST_ADDR >> 1, TLC59208F_SWRST_SEQ, sizeof TLC59208F_SWRST_SEQ, nullptr, 0) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "RESET failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("RESET failed")); return; } // Auto increment registers, and respond to all-call address if (!this->write_byte(TLC59208F_REG_MODE1, TLC59208F_MODE1_AI2 | TLC59208F_MODE1_ALLCALL)) { - ESP_LOGE(TAG, "MODE1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("MODE1 failed")); return; } if (!this->write_byte(TLC59208F_REG_MODE2, this->mode_)) { - ESP_LOGE(TAG, "MODE2 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("MODE2 failed")); return; } // Set all 3 outputs to be individually controlled // TODO: think of a way to support group dimming if (!this->write_byte(TLC59208F_REG_LEDOUT0, (LDR_PWM << 6) | (LDR_PWM << 4) | (LDR_PWM << 2) | (LDR_PWM << 0))) { - ESP_LOGE(TAG, "LEDOUT0 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("LEDOUT0 failed")); return; } if (!this->write_byte(TLC59208F_REG_LEDOUT1, (LDR_PWM << 6) | (LDR_PWM << 4) | (LDR_PWM << 2) | (LDR_PWM << 0))) { - ESP_LOGE(TAG, "LEDOUT1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("LEDOUT1 failed")); return; } delayMicroseconds(500); @@ -103,10 +98,6 @@ void TLC59208FOutput::dump_config() { "TLC59208F:\n" " Mode: 0x%02X", this->mode_); - - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up TLC59208F failed!"); - } } void TLC59208FOutput::loop() { diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 7bb756819bd..f68496bd0ca 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -256,9 +256,6 @@ void TMC22XXStepper::dump_config() { LOG_PIN(" INDEX Pin: ", this->index_pin_); LOG_PIN(" DIAG Pin: ", this->diag_pin_); LOG_STEPPER(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void TMC22XXStepper::loop() { diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 06d94f1ba86..7639c00cf95 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -96,7 +96,7 @@ void TMP102Component::setup_configured_() { #endif if (!this->write_configuration_()) { this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } #ifdef USE_TMP102_NUMBER @@ -123,9 +123,6 @@ void TMP102Component::setup_configured_() { void TMP102Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP102:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); #ifdef USE_TMP102_BINARY_SENSOR diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 9b152c1ad34..0cb2456cbd3 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -20,7 +20,7 @@ void TMP1075Sensor::setup() { uint8_t cfg; if (!this->read_byte(REG_CFGR, &cfg)) { ESP_LOGE(TAG, "'%s' - unable to read", this->name_.c_str()); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -42,10 +42,8 @@ void TMP1075Sensor::update() { void TMP1075Sensor::dump_config() { LOG_SENSOR("", "TMP1075 Sensor", this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " limit low : %.4f °C\n" " limit high : %.4f °C\n" diff --git a/esphome/components/tmp117/tmp117.cpp b/esphome/components/tmp117/tmp117.cpp index 5925b297c2b..3c76e098b1b 100644 --- a/esphome/components/tmp117/tmp117.cpp +++ b/esphome/components/tmp117/tmp117.cpp @@ -25,22 +25,19 @@ void TMP117Component::update() { } void TMP117Component::setup() { if (!this->write_config_(this->config_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } int16_t data; if (!this->read_data_(&data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void TMP117Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP117:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this); } diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index d1d1d66a265..f887b7d32ca 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -18,13 +18,13 @@ static const uint8_t TSL2561_REGISTER_DATA_1 = 0x0E; void TSL2561Sensor::setup() { uint8_t id; if (!this->tsl2561_read_byte(TSL2561_REGISTER_ID, &id)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t timing; if (!this->tsl2561_read_byte(TSL2561_REGISTER_TIMING, &timing)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -40,10 +40,6 @@ void TSL2561Sensor::dump_config() { LOG_SENSOR("", "TSL2561", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } - int gain = this->gain_ == TSL2561_GAIN_1X ? 1 : 16; ESP_LOGCONFIG(TAG, " Gain: %dx\n" diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 493f4ad3627..46b0f4c8054 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -73,10 +73,8 @@ void TSL2591Component::dump_config() { ESP_LOGCONFIG(TAG, "TSL2591:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Name: %s", this->name_); TSL2591ComponentGain raw_gain = this->component_gain_; diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index 7ed06c6d6f2..bf5c3f67284 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -8,22 +8,13 @@ ESPHOME_LOG_TAG(TAG, "ttp229_lsf"); void TTP229LSFComponent::setup() { uint8_t data[2]; if (this->read(data, 2) != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void TTP229LSFComponent::dump_config() { ESP_LOGCONFIG(TAG, "ttp229:"); LOG_I2C_DEVICE(this); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case NONE: - default: - break; - } } void TTP229LSFComponent::loop() { uint16_t touched = 0; diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.h b/esphome/components/ttp229_lsf/ttp229_lsf.h index 50e2baa7f7a..e221ded0855 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.h +++ b/esphome/components/ttp229_lsf/ttp229_lsf.h @@ -26,10 +26,6 @@ class TTP229LSFComponent final : public Component, public i2c::I2CDevice { protected: std::vector channels_{}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_{NONE}; }; } // namespace esphome::ttp229_lsf diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 3a856696782..09340f3bdee 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -21,16 +21,14 @@ static const uint8_t MAX_ADJUSTMENTS_PER_UPDATE = 2; void VEML3235Sensor::setup() { uint8_t device_id[] = {0, 0}; if (!this->refresh_config_reg()) { - ESP_LOGE(TAG, "Unable to write configuration"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to write configuration")); return; } if ((this->read_register(ID_REG, device_id, sizeof device_id) != i2c::ERROR_OK)) { - ESP_LOGE(TAG, "Unable to read ID"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read ID")); } else if (device_id[0] != DEVICE_ID) { ESP_LOGE(TAG, "Incorrect device ID - expected 0x%.2x, read 0x%.2x", DEVICE_ID, device_id[0]); - this->mark_failed(); + this->mark_failed(LOG_STR("Incorrect device ID")); } } @@ -187,9 +185,6 @@ void VEML3235Sensor::dump_config() { LOG_SENSOR("", "VEML3235", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Auto-gain enabled: %s", YESNO(this->auto_gain_)); if (this->auto_gain_) { diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 236b4bb2a98..210e68705bb 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -80,8 +80,7 @@ static const char *get_gain_str(Gain gain) { void VEML7700Component::setup() { auto err = this->configure_(); if (err != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Sensor configuration failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Sensor configuration failed")); } else { this->state_ = State::INITIAL_SETUP_COMPLETED; } @@ -109,10 +108,6 @@ void VEML7700Component::dump_config() { LOG_SENSOR(" ", "FAKE_IR channel lux", this->fake_infrared_sensor_); LOG_SENSOR(" ", "Actual gain", this->actual_gain_sensor_); LOG_SENSOR(" ", "Actual integration time", this->actual_integration_time_sensor_); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void VEML7700Component::update() { diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 159390086f5..0347cfa083c 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -20,8 +20,7 @@ void WaveshareIOCH32V003Component::setup() { bool step2 = this->write_gpio_outputs_(); if (!step1 || !step2) { - ESP_LOGE(TAG, "Failed to initialize Waveshare IO expander"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to initialize Waveshare IO expander")); return; } @@ -46,9 +45,6 @@ void WaveshareIOCH32V003Component::loop() { this->reset_pin_cache_(); } void WaveshareIOCH32V003Component::dump_config() { ESP_LOGCONFIG(TAG, "WaveshareIO:"); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } uint16_t WaveshareIOCH32V003Component::get_adc_value() { diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index b184a855f6f..8ab42d24fe0 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -93,11 +93,11 @@ void XGZP68XXComponent::setup() { // Display some sample bits to confirm we are talking to the sensor if (i2c::ErrorCode::ERROR_OK != this->read_register(SYSCONFIG_ADDRESS, &config1, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (i2c::ErrorCode::ERROR_OK != this->read_register(PCONFIG_ADDRESS, &config2, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ESP_LOGD(TAG, "sys_config 0x%x, p_config 0x%x", config1, config2); @@ -111,9 +111,6 @@ void XGZP68XXComponent::dump_config() { ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->pressure_oversampling_)); } LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Connection failed"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index 228274b3211..9be536353c8 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -9,7 +9,7 @@ void XL9535Component::setup() { // Check to see if the device can read from the register uint8_t port = 0; if (this->read_register(XL9535_INPUT_PORT_0_REGISTER, &port, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -17,10 +17,6 @@ void XL9535Component::setup() { void XL9535Component::dump_config() { ESP_LOGCONFIG(TAG, "XL9535:"); LOG_I2C_DEVICE(this); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool XL9535Component::digital_read(uint8_t pin) { From 74bcec34ad22e136bd9169050f2e47e148f9f5f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:18:36 -1000 Subject: [PATCH 548/651] [i2c] Add I2CDevice::update_register for register read-modify-write (#20854) --- esphome/components/i2c/i2c.cpp | 9 ++ esphome/components/i2c/i2c.h | 5 ++ .../i2c/test_i2c_update_register.cpp | 82 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 tests/components/i2c/test_i2c_update_register.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 9d079f9f89e..8aa1fbe3cf3 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -75,6 +75,15 @@ bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { return true; } +bool I2CDevice::update_register(uint8_t a_register, uint8_t mask, uint8_t value) { + uint8_t data; + if (!this->read_byte(a_register, &data)) + return false; + const uint8_t updated = (data & ~mask) | (value & mask); + ESP_LOGV(TAG, "Register 0x%02X: 0x%02X -> 0x%02X", a_register, data, updated); + return this->write_byte(a_register, updated); +} + bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const { const size_t size = 1 + len * 2; SmallBufferWithHeapFallback<17> buffer_alloc(size); // Register plus up to 8 words diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index d5690b92063..1c3137c6d5f 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -316,6 +316,11 @@ class I2CDevice { /// Write a 16-bit value least significant byte first. bool write_byte_16_le(uint8_t a_register, uint16_t data) const; + /// Read a register, replace the bits in @p mask with those of @p value and write it back. + /// The write always happens; bits of @p value outside @p mask are ignored. Logs both values at VERBOSE. + /// @return false if the read or the write failed (nothing is written after a failed read) + bool update_register(uint8_t a_register, uint8_t mask, uint8_t value); + protected: uint8_t address_{0x00}; ///< store the address of the device on the bus I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance diff --git a/tests/components/i2c/test_i2c_update_register.cpp b/tests/components/i2c/test_i2c_update_register.cpp new file mode 100644 index 00000000000..f973a726cad --- /dev/null +++ b/tests/components/i2c/test_i2c_update_register.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +static constexpr uint8_t ADDRESS = 0x40; +static constexpr uint8_t REG = 0x12; + +class RegisterBus final : public I2CBus { + public: + std::array regs{}; + std::vector writes; + bool fail_read{false}; + bool fail_write{false}; + + ErrorCode write_readv(uint8_t address, const uint8_t *out, size_t out_size, uint8_t *in, size_t in_size) override { + EXPECT_EQ(address, ADDRESS); + if (out_size == 0) + return ERROR_TIMEOUT; + const uint8_t reg = out[0]; + if (in_size > 0) { + if (this->fail_read) + return ERROR_TIMEOUT; + for (size_t i = 0; i < in_size; i++) + in[i] = this->regs[reg + i]; + return ERROR_OK; + } + if (this->fail_write) + return ERROR_TIMEOUT; + this->writes.push_back(reg); + for (size_t i = 1; i < out_size; i++) + this->regs[reg + i - 1] = out[i]; + return ERROR_OK; + } +}; + +class UpdateRegisterTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(ADDRESS); + } + + RegisterBus bus_; + I2CDevice device_; +}; + +TEST_F(UpdateRegisterTest, ReplacesOnlyMaskedBits) { + this->bus_.regs[REG] = 0b1010'0101; + EXPECT_TRUE(this->device_.update_register(REG, 0b0000'1111, 0b0000'0011)); + EXPECT_EQ(this->bus_.regs[REG], 0b1010'0011); +} + +TEST_F(UpdateRegisterTest, IgnoresValueBitsOutsideMask) { + this->bus_.regs[REG] = 0x00; + EXPECT_TRUE(this->device_.update_register(REG, 0x0F, 0xFF)); + EXPECT_EQ(this->bus_.regs[REG], 0x0F); +} + +TEST_F(UpdateRegisterTest, WritesEvenWhenUnchanged) { + this->bus_.regs[REG] = 0x3C; + EXPECT_TRUE(this->device_.update_register(REG, 0x0C, 0x0C)); + EXPECT_EQ(this->bus_.regs[REG], 0x3C); + EXPECT_EQ(this->bus_.writes, std::vector{REG}); +} + +TEST_F(UpdateRegisterTest, FailedReadWritesNothing) { + this->bus_.fail_read = true; + EXPECT_FALSE(this->device_.update_register(REG, 0xFF, 0x01)); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +TEST_F(UpdateRegisterTest, FailedWriteReturnsFalse) { + this->bus_.fail_write = true; + EXPECT_FALSE(this->device_.update_register(REG, 0xFF, 0x01)); +} + +} // namespace esphome::i2c::testing From 32dc3b85351dd5ffc90fac187f9e70c040bf2deb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:19:24 -1000 Subject: [PATCH 549/651] [http_request] Test ota.http_request.flash with every field (#20829) --- tests/components/http_request/common.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/components/http_request/common.yaml b/tests/components/http_request/common.yaml index 62d0a7941aa..f20bd1a23f0 100644 --- a/tests/components/http_request/common.yaml +++ b/tests/components/http_request/common.yaml @@ -42,6 +42,20 @@ button: md5: 0123456789abcdef0123456789abcdef url: http://my.ha.net:8123/local/esphome/firmware.bin + - ota.http_request.flash: + id: http_request_ota + md5_url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.md5");' + username: user + password: !lambda 'return std::string("pass");' + url: http://my.ha.net:8123/local/esphome/firmware.bin + + - ota.http_request.flash: + id: http_request_ota + md5: !lambda 'return std::string("0123456789abcdef0123456789abcdef");' + username: !lambda 'return std::string("user");' + password: pass + url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.bin");' + - logger.log: "This message should be not displayed (reboot)" update: From 68fa7b9847075aab6ecf58008a047d574093c53b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:20:00 -1000 Subject: [PATCH 550/651] [wifi] Test the wifi.connected and wifi.enabled conditions (#20841) --- tests/components/wifi/common.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/wifi/common.yaml b/tests/components/wifi/common.yaml index 10a8a61c661..63bd820e673 100644 --- a/tests/components/wifi/common.yaml +++ b/tests/components/wifi/common.yaml @@ -14,6 +14,14 @@ esphome: condition: wifi.ap_active then: - logger.log: "WiFi AP is active!" + - if: + condition: wifi.connected + then: + - logger.log: "WiFi is connected!" + - if: + condition: wifi.enabled + then: + - logger.log: "WiFi is enabled!" - wifi.roam wifi: From 626d84bc641007476d8eec710904e6aabb13cae2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:20:52 -1000 Subject: [PATCH 551/651] [sensor][number] Test in_range with every bound combination (#20847) --- tests/components/template/common-base.yaml | 32 ++++ .../fixtures/in_range_conditions.yaml | 158 ++++++++++++++++++ tests/integration/test_in_range_conditions.py | 62 +++++++ 3 files changed, 252 insertions(+) create mode 100644 tests/integration/fixtures/in_range_conditions.yaml create mode 100644 tests/integration/test_in_range_conditions.py diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index f87a4e0320e..76a5e0b1a2f 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -76,6 +76,38 @@ esphome: date: "2021-01-01" binary_sensor: + - platform: template + id: template_sensor_above + condition: + sensor.in_range: + id: template_template_sens + above: 10.0 + - platform: template + id: template_sensor_between + condition: + sensor.in_range: + id: template_template_sens + above: 10.0 + below: 30.0 + - platform: template + id: template_number_above + condition: + number.in_range: + id: template_number + above: 10.0 + - platform: template + id: template_number_below + condition: + number.in_range: + id: template_number + below: 90.0 + - platform: template + id: template_number_between + condition: + number.in_range: + id: template_number + above: 10.0 + below: 90.0 - platform: template id: template_some_binary_sensor name: "Garage Door Open" diff --git a/tests/integration/fixtures/in_range_conditions.yaml b/tests/integration/fixtures/in_range_conditions.yaml new file mode 100644 index 00000000000..f7f636aa584 --- /dev/null +++ b/tests/integration/fixtures/in_range_conditions.yaml @@ -0,0 +1,158 @@ +esphome: + name: test-in-range-conditions + +host: + +api: + actions: + - action: run_cases + then: + - script.execute: + id: check + label: neg + value: !lambda "return -10.0f;" + - script.wait: check + - script.execute: + id: check + label: low + value: !lambda "return 0.0f;" + - script.wait: check + - script.execute: + id: check + label: mid + value: !lambda "return 5.0f;" + - script.wait: check + - script.execute: + id: check + label: high + value: !lambda "return 10.0f;" + - script.wait: check + - script.execute: + id: check + label: over + value: !lambda "return 20.0f;" + - script.wait: check + - script.execute: + id: check + label: nan + value: !lambda "return NAN;" + - script.wait: check + - script.execute: + id: check + label: neginf + value: !lambda "return -INFINITY;" + - script.wait: check + - script.execute: + id: check + label: posinf + value: !lambda "return INFINITY;" + - script.wait: check + - logger.log: "INRANGE done" + +logger: + level: DEBUG + +sensor: + - platform: template + id: range_sensor + name: Range Sensor + +number: + - platform: template + id: range_number + name: Range Number + optimistic: true + min_value: -100 + max_value: 100 + step: 0.5 + +script: + - id: check + parameters: + label: string + value: float + then: + # Set the states directly so NaN and infinities reach the conditions unchanged + - lambda: |- + id(range_sensor).publish_state(value); + id(range_number).publish_state(value); + - if: + condition: + sensor.in_range: + id: range_sensor + above: 0 + then: + - logger.log: + format: "INRANGE %s sensor_above 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_above 0" + args: [label.c_str()] + - if: + condition: + sensor.in_range: + id: range_sensor + below: 10 + then: + - logger.log: + format: "INRANGE %s sensor_below 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_below 0" + args: [label.c_str()] + - if: + condition: + sensor.in_range: + id: range_sensor + above: 0 + below: 10 + then: + - logger.log: + format: "INRANGE %s sensor_both 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_both 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + above: 0 + then: + - logger.log: + format: "INRANGE %s number_above 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_above 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + below: 10 + then: + - logger.log: + format: "INRANGE %s number_below 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_below 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + above: 0 + below: 10 + then: + - logger.log: + format: "INRANGE %s number_both 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_both 0" + args: [label.c_str()] diff --git a/tests/integration/test_in_range_conditions.py b/tests/integration/test_in_range_conditions.py new file mode 100644 index 00000000000..667f7fe43d4 --- /dev/null +++ b/tests/integration/test_in_range_conditions.py @@ -0,0 +1,62 @@ +"""Test sensor.in_range and number.in_range against a fixed truth table.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Bounds in the fixture: above 0, below 10, both inclusive. A NaN state is never in range. +# Columns: above only, below only, both bounds. +EXPECTED = { + "neg": (0, 1, 0), # -10 + "low": (1, 1, 1), # 0, the lower bound + "mid": (1, 1, 1), # 5 + "high": (1, 1, 1), # 10, the upper bound + "over": (1, 0, 0), # 20 + "nan": (0, 0, 0), + "neginf": (0, 1, 0), + "posinf": (1, 0, 0), +} +CONDITIONS = ("above", "below", "both") +RESULT_RE = re.compile(r"INRANGE (\w+) (sensor|number)_(above|below|both) ([01])") + + +@pytest.mark.asyncio +async def test_in_range_conditions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Each in_range condition gives the same result for sensors and numbers.""" + loop = asyncio.get_running_loop() + results: dict[tuple[str, str, str], int] = {} + done = loop.create_future() + + def check_output(line: str) -> None: + if match := RESULT_RE.search(line): + label, entity, condition, value = match.groups() + results[(label, entity, condition)] = int(value) + elif "INRANGE done" in line and not done.done(): + done.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service = next((s for s in services if s.name == "run_cases"), None) + assert service is not None, "run_cases action not found" + await client.execute_service(service, {}) + await asyncio.wait_for(done, timeout=10.0) + + expected = { + (label, entity, condition): row[index] + for label, row in EXPECTED.items() + for entity in ("sensor", "number") + for index, condition in enumerate(CONDITIONS) + } + assert results == expected From 21362f8c92ab8e8d5002569ef508e0a98c9afc6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:21:18 -1000 Subject: [PATCH 552/651] [ethernet] Test the ethernet.connected condition (#20844) --- tests/components/ethernet/test-w5500.esp32-idf.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index c99dfaf5281..ebd7aa360ab 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -38,6 +38,11 @@ button: - platform: template name: "Disable Ethernet" on_press: + - if: + condition: + ethernet.connected: + then: + - logger.log: "Disabling connected Ethernet" - ethernet.disable: binary_sensor: From 34e6d3ec9c75a21efb5ba73ea0587f9aeca678e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:21:36 -1000 Subject: [PATCH 553/651] [pylontech] Restore the CONF_ID import (#21013) --- esphome/components/pylontech/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 3fc194da057..ed18556002f 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -6,6 +6,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv +from esphome.const import CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType From a2754f655c1f5d297cd163ed27798e38bcd391e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:06 -1000 Subject: [PATCH 554/651] [dfrobot_sen0609][http_request] Use ESPHOME_strstr_P (#20825) --- .../dfrobot_sen0609/dfrobot_sen0609.cpp | 18 +++++------------- .../http_request/http_request_arduino.cpp | 3 ++- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index e1a1dbe89d0..802142bb8de 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -64,15 +64,6 @@ static bool starts_with(const char *str, ESPHOME_PGM_P prefix) { return ESPHOME_strncmp_P(str, prefix, ESPHOME_strlen_P(prefix)) == 0; } -static bool contains(const char *str, ESPHOME_PGM_P needle) { - const size_t len = ESPHOME_strlen_P(needle); - for (; *str != '\0'; str++) { - if (ESPHOME_strncmp_P(str, needle, len) == 0) - return true; - } - return false; -} - // Splits "a,b,c*" in place; returns the number of fields stored in `fields` static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { uint8_t count = 0; @@ -94,9 +85,9 @@ static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { static bool answer_means_done(Operation operation, const char *line) { switch (operation) { case Operation::OPERATION_STOP: - return contains(line, ESPHOME_PSTR("stopped already")); + return ESPHOME_strstr_P(line, ESPHOME_PSTR("stopped already")) != nullptr; case Operation::OPERATION_START: - return contains(line, ESPHOME_PSTR("started already")); + return ESPHOME_strstr_P(line, ESPHOME_PSTR("started already")) != nullptr; case Operation::OPERATION_SAVE: return starts_with(line, ESPHOME_PSTR("no parameter")); default: @@ -261,11 +252,12 @@ void DFRobotSEN0609Component::handle_line_(char *line) { this->finish_operation_(); } else if (starts_with(line, ESPHOME_PSTR("Response "))) { this->parse_response_(line + 9); - } else if (contains(line, ESPHOME_PSTR("not stopped"))) { + } else if (ESPHOME_strstr_P(line, ESPHOME_PSTR("not stopped")) != nullptr) { // Stop the sensor before the next attempt this->stopped_ = false; this->fail_operation_(); - } else if (starts_with(line, ESPHOME_PSTR("Error")) || contains(line, ESPHOME_PSTR("not recognized"))) { + } else if (starts_with(line, ESPHOME_PSTR("Error")) || + ESPHOME_strstr_P(line, ESPHOME_PSTR("not recognized")) != nullptr) { ESP_LOGW(TAG, "Command '%s' rejected: %s", this->command_, line); this->fail_operation_(); } else { diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 2062122adf6..814d598132e 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -10,6 +10,7 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" // Include BearSSL error constants for TLS failure diagnostics #ifdef USE_ESP8266 @@ -39,7 +40,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const uint32_t start = millis(); - bool secure = strstr(url, "https:") != nullptr; + bool secure = ESPHOME_strstr_P(url, ESPHOME_PSTR("https:")) != nullptr; container->set_secure(secure); container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); From af5efabdc6313eb5a841c54796afc351b7426977 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:22 -1000 Subject: [PATCH 555/651] [light] Test light.toggle with a transition length (#20832) --- tests/components/light/common.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index c1e58c673f6..95b587df45d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -83,6 +83,12 @@ esphome: call.perform(); - light.toggle: test_binary_light + - light.toggle: + id: test_monochromatic_light + transition_length: 500ms + - light.toggle: + id: test_monochromatic_light + transition_length: !lambda "return 250;" - light.turn_off: test_rgb_light - if: condition: From 5df6c65cc249adef35af836f089eb7a5a91f4fa8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:35 -1000 Subject: [PATCH 556/651] [api] Test api.connected without options and with a lambda (#20835) --- tests/components/api/common-base.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 7f6cb83f16c..e64ba88f112 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -5,6 +5,17 @@ esphome: condition: api.connected: state_subscription_only: true + - if: + condition: + api.connected: + then: + - logger.log: "API client connected" + - if: + condition: + api.connected: + state_subscription_only: !lambda "return true;" + then: + - logger.log: "API client subscribed to states" - homeassistant.event: event: esphome.button_pressed data: From 1dbad7b951d9d215706a71751b3eaaf5e3e7d8ec Mon Sep 17 00:00:00 2001 From: Lorenzo Prosseda Date: Sun, 18 Oct 2026 01:37:58 +0000 Subject: [PATCH 557/651] [airton] Add support to climate for Airton IR A/C units (#7753) Co-authored-by: Arjan <44190435+vingerha@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/airton/__init__.py | 2 + esphome/components/airton/airton.cpp | 319 ++++++++++++++++++ esphome/components/airton/airton.h | 137 ++++++++ esphome/components/airton/climate.py | 70 ++++ esphome/components/airton/select/__init__.py | 37 ++ esphome/components/airton/select/direction.h | 17 + esphome/components/airton/switch/__init__.py | 41 +++ .../components/airton/switch/airton_switch.h | 19 ++ tests/components/airton/common-bare.yaml | 13 + tests/components/airton/common.yaml | 34 ++ tests/components/airton/test.esp32-idf.yaml | 4 + tests/components/airton/test.esp8266-ard.yaml | 4 + 13 files changed, 700 insertions(+) create mode 100644 esphome/components/airton/__init__.py create mode 100644 esphome/components/airton/airton.cpp create mode 100644 esphome/components/airton/airton.h create mode 100644 esphome/components/airton/climate.py create mode 100644 esphome/components/airton/select/__init__.py create mode 100644 esphome/components/airton/select/direction.h create mode 100644 esphome/components/airton/switch/__init__.py create mode 100644 esphome/components/airton/switch/airton_switch.h create mode 100644 tests/components/airton/common-bare.yaml create mode 100644 tests/components/airton/common.yaml create mode 100644 tests/components/airton/test.esp32-idf.yaml create mode 100644 tests/components/airton/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 0def07d0237..19f1194bd95 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -30,6 +30,9 @@ esphome/components/airthings_ble/* @jeromelaban esphome/components/airthings_wave_base/* @jeromelaban @ncareau esphome/components/airthings_wave_mini/* @ncareau esphome/components/airthings_wave_plus/* @jeromelaban @precurse +esphome/components/airton/* @Procsiab +esphome/components/airton/select/* @Procsiab +esphome/components/airton/switch/* @Procsiab esphome/components/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/alpha3/* @jan-hofmeier esphome/components/am2315c/* @swoboda1337 diff --git a/esphome/components/airton/__init__.py b/esphome/components/airton/__init__.py new file mode 100644 index 00000000000..675b3186690 --- /dev/null +++ b/esphome/components/airton/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@Procsiab"] +DOMAIN = "airton" diff --git a/esphome/components/airton/airton.cpp b/esphome/components/airton/airton.cpp new file mode 100644 index 00000000000..a20ddc510c2 --- /dev/null +++ b/esphome/components/airton/airton.cpp @@ -0,0 +1,319 @@ +#include "airton.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::airton { + +ESPHOME_LOG_TAG(TAG, "airton.climate"); + +// Distinct from the climate's own RESTORE_STATE_VERSION, which shares the entity's preference key +static constexpr uint32_t RESTORE_SETTINGS_VERSION = 0x2E86A1F3UL; + +// From IRutils.h of IRremoteESP8266 library +static uint8_t checksum(const uint8_t *frame) { + return static_cast(0x7F - sum_bytes(frame, AIRTON_STATE_FRAME_SIZE - 1)) ^ 0x2C; +} + +void AirtonClimate::setup() { + climate_ir::ClimateIR::setup(); + // The flap position is the one source of truth for the swing mode the climate state restored + this->settings_pref_ = this->make_entity_preference(RESTORE_SETTINGS_VERSION); + this->settings_pref_.load(&this->settings_); + this->sync_swing_mode_(); + this->publish_settings_(); + this->publish_state(); +} + +void AirtonClimate::publish_settings_() { +#ifdef USE_SWITCH + if (this->sleep_mode_switch_ != nullptr) + this->sleep_mode_switch_->publish_state(this->settings_.sleep_state); + if (this->display_switch_ != nullptr) + this->display_switch_->publish_state(this->settings_.display_state); +#endif +#ifdef USE_SELECT + if (this->vertical_direction_select_ != nullptr) + this->vertical_direction_select_->publish_state(static_cast(this->settings_.vertical_direction)); +#endif +} + +void AirtonClimate::save_settings_() { this->settings_pref_.save(&this->settings_); } + +void AirtonClimate::sync_swing_mode_() { + this->swing_mode = this->settings_.vertical_direction == VerticalDirection::VERTICAL_DIRECTION_SWING + ? climate::CLIMATE_SWING_VERTICAL + : climate::CLIMATE_SWING_OFF; +} + +// The unit has no sleep mode while it only fans or picks the mode itself +bool AirtonClimate::sleep_mode_allowed_() const { + return this->mode != climate::CLIMATE_MODE_FAN_ONLY && this->mode != climate::CLIMATE_MODE_HEAT_COOL; +} + +// Each setter stores and saves before it publishes: an automation on the entity runs inside +// publish_state() and may set the value again, which must not be undone afterwards +bool AirtonClimate::set_sleep_mode_state(bool state) { + state = state && this->sleep_mode_allowed_(); + const bool changed = state != this->settings_.sleep_state; + if (changed) { + this->settings_.sleep_state = state; + this->save_settings_(); + } +#ifdef USE_SWITCH + if (this->sleep_mode_switch_ != nullptr) + this->sleep_mode_switch_->publish_state(state); +#endif + return changed; +} + +bool AirtonClimate::set_display_state(bool state) { + const bool changed = state != this->settings_.display_state; + if (changed) { + this->settings_.display_state = state; + this->save_settings_(); + } +#ifdef USE_SWITCH + if (this->display_switch_ != nullptr) + this->display_switch_->publish_state(state); +#endif + return changed; +} + +bool AirtonClimate::set_vertical_direction_state(VerticalDirection direction) { + const bool changed = direction != this->settings_.vertical_direction; + if (changed) { + this->settings_.vertical_direction = direction; + this->sync_swing_mode_(); + this->save_settings_(); + } +#ifdef USE_SELECT + if (this->vertical_direction_select_ != nullptr) + this->vertical_direction_select_->publish_state(static_cast(direction)); +#endif + return changed; +} + +void AirtonClimate::send_settings() { + this->transmit_state(); + this->publish_state(); +} + +void AirtonClimate::control(const climate::ClimateCall &call) { + // ClimateIR::control() sends and publishes the whole state afterwards + // Only a change of swing mode moves the flap, so a repeated swing-off keeps a fixed position + if (auto swing_mode = call.get_swing_mode(); swing_mode.has_value() && *swing_mode != this->swing_mode) { + this->set_vertical_direction_state(*swing_mode == climate::CLIMATE_SWING_VERTICAL + ? VerticalDirection::VERTICAL_DIRECTION_SWING + : VerticalDirection::VERTICAL_DIRECTION_OFF); + } + ClimateIR::control(call); +} + +void AirtonClimate::transmit_state() { + // Entering fan-only or automatic mode drops sleep mode, as the remote does + if (!this->sleep_mode_allowed_()) + this->set_sleep_mode_state(false); + // Sampled valid state + // Power: On, Mode: 2 (Dry), Fan: 1 (Quiet), Temp: 20C, Swing(V): On, Econo: Off, Turbo: Off, Light: On, Health: On, + // Sleep: Off. 0x74C461041A11D3 + // The turbo bit above the fan speed stays clear, as the remote sends it + uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = { + AIRTON_HEADER_BYTE0, + AIRTON_HEADER_BYTE1, + static_cast(this->operation_mode_() | (this->fan_speed_() << 4)), + this->temperature_(), + static_cast(this->settings_.vertical_direction), + this->operation_settings_(), + 0, + }; + remote_state[6] = checksum(remote_state); + + ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X", remote_state[6], remote_state[5], remote_state[4], + remote_state[3], remote_state[2], remote_state[1], remote_state[0]); + + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + data->set_carrier_frequency(AIRTON_IR_FREQUENCY); + data->mark(AIRTON_HEADER_MARK); + data->space(AIRTON_HEADER_SPACE); + for (uint8_t payload_byte : remote_state) { + for (uint8_t payload_bit_cursor = 0; payload_bit_cursor < 8; payload_bit_cursor++) { + data->mark(AIRTON_BIT_MARK); + bool bit = payload_byte & (1 << payload_bit_cursor); + data->space(bit ? AIRTON_ONE_SPACE : AIRTON_ZERO_SPACE); + } + } + + data->mark(AIRTON_BIT_MARK); + data->space(AIRTON_MESSAGE_SPACE); + + transmit.perform(); +} + +uint8_t AirtonClimate::operation_mode_() { + uint8_t operating_mode = 0b1000; // First bit is for power state + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + operating_mode |= AIRTON_MODE_COOL; + break; + case climate::CLIMATE_MODE_DRY: + operating_mode |= AIRTON_MODE_DRY; + break; + case climate::CLIMATE_MODE_HEAT: + operating_mode |= AIRTON_MODE_HEAT; + break; + case climate::CLIMATE_MODE_HEAT_COOL: + operating_mode |= AIRTON_MODE_AUTO; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + operating_mode |= AIRTON_MODE_FAN; + break; + case climate::CLIMATE_MODE_OFF: + default: + operating_mode = 0b0111 & this->previous_mode_; // Set previous mode with power state bit off + } + this->previous_mode_ = operating_mode; + return operating_mode; +} + +uint8_t AirtonClimate::fan_speed_() { + uint8_t fan_speed; + switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { + case climate::CLIMATE_FAN_LOW: + fan_speed = AIRTON_FAN_1; + break; + case climate::CLIMATE_FAN_MEDIUM: + fan_speed = AIRTON_FAN_3; + break; + case climate::CLIMATE_FAN_HIGH: + fan_speed = AIRTON_FAN_5; + break; + case climate::CLIMATE_FAN_AUTO: + default: + fan_speed = AIRTON_FAN_AUTO; + } + return fan_speed; +} + +uint8_t AirtonClimate::temperature_() { + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + return AIRTON_AUTO_MODE_TEMP - AIRTON_TEMP_MIN; + default: + return static_cast(roundf(clamp(this->target_temperature, AIRTON_TEMP_MIN, AIRTON_TEMP_MAX))) - + AIRTON_TEMP_MIN; + } +} + +// The bits of this packet's byte have the following meanings (from MSB to LSB) +// Light, Health, Unknown, HeatOn, Unknown, NotAutoOn, Sleep, Econo +uint8_t AirtonClimate::operation_settings_() { + uint8_t settings = 0; + if (this->mode == climate::CLIMATE_MODE_HEAT) { // Set heating bit if on the corresponding mode + settings |= (1 << 4); + } + if (this->settings_.display_state) { + settings |= (1 << 7); + } + if (this->settings_.sleep_state) { + settings |= (1 << 1); + } + settings |= 0b01000000; // Health, as the remote always sends it + if (this->mode != climate::CLIMATE_MODE_HEAT_COOL) { + settings |= 0b00000100; // NotAutoOn: clear only while the unit picks the mode itself + } + return settings; +} + +void AirtonClimate::parse_state_frame_(const uint8_t *frame) { + uint8_t mode = frame[2]; + // A later "off" repeats the mode the remote last sent, as the remote itself does + this->previous_mode_ = mode & 0b00001111; + if (mode & 0b00001000) { // The power bit + switch (mode & 0b00000111) { + case AIRTON_MODE_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + case AIRTON_MODE_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case AIRTON_MODE_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + case AIRTON_MODE_AUTO: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + case AIRTON_MODE_FAN: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + } + } else { + this->mode = climate::CLIMATE_MODE_OFF; + } + uint8_t fan_mode = (frame[2] & 0b01110000) >> 4; + + switch (fan_mode) { + case AIRTON_FAN_1: + case AIRTON_FAN_2: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case AIRTON_FAN_3: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case AIRTON_FAN_4: + case AIRTON_FAN_5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case AIRTON_FAN_AUTO: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + this->target_temperature = (frame[3] & 0b00001111) + AIRTON_TEMP_MIN; + + const uint8_t direction = frame[4] & 0b00001111; + if (direction <= static_cast(VerticalDirection::VERTICAL_DIRECTION_DOWN)) { + this->set_vertical_direction_state(static_cast(direction)); + } else { + ESP_LOGV(TAG, "Unknown vertical direction %u", direction); + } + this->set_display_state((frame[5] & 0b10000000) != 0); + this->set_sleep_mode_state((frame[5] & 0b00000010) != 0); + + this->publish_state(); +} + +bool AirtonClimate::on_receive(remote_base::RemoteReceiveData data) { + uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = {}; + if (!data.expect_item(AIRTON_HEADER_MARK, AIRTON_HEADER_SPACE)) { + ESP_LOGV(TAG, "Wrong header encoding detected!"); + return false; + } + for (int i = 0; i < AIRTON_STATE_FRAME_SIZE; i++) { + for (int j = 0; j < 8; j++) { + if (data.expect_item(AIRTON_BIT_MARK, AIRTON_ONE_SPACE)) { + remote_state[i] |= 1 << j; + } else if (!data.expect_item(AIRTON_BIT_MARK, AIRTON_ZERO_SPACE)) { + ESP_LOGV(TAG, "Wrong modulation encoding for: Byte %d, bit %d", i, j); + return false; + } + } + } + + if (remote_state[0] != AIRTON_HEADER_BYTE0 || remote_state[1] != AIRTON_HEADER_BYTE1) { + ESP_LOGV(TAG, "Wrong header contents: %02X %02X", remote_state[1], remote_state[0]); + return false; + } + const uint8_t expected = checksum(remote_state); + if (remote_state[AIRTON_STATE_FRAME_SIZE - 1] != expected) { + ESP_LOGV(TAG, "Checksum error: calculated %02X, received %02X", expected, + remote_state[AIRTON_STATE_FRAME_SIZE - 1]); + return false; + } + ESP_LOGV(TAG, "Received: %02X %02X %02X %02X %02X %02X %02X", remote_state[6], remote_state[5], remote_state[4], + remote_state[3], remote_state[2], remote_state[1], remote_state[0]); + this->parse_state_frame_(remote_state); + return true; +} + +} // namespace esphome::airton diff --git a/esphome/components/airton/airton.h b/esphome/components/airton/airton.h new file mode 100644 index 00000000000..46762f421a8 --- /dev/null +++ b/esphome/components/airton/airton.h @@ -0,0 +1,137 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/core/defines.h" +#include "esphome/core/preferences.h" + +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif + +namespace esphome::airton { + +// Values for Airton SMVH09B-2A2A3NH IR Controllers +static constexpr uint8_t AIRTON_TEMP_MIN = 16; // Celsius +static constexpr uint8_t AIRTON_TEMP_MAX = 31; // Celsius +static constexpr uint8_t AIRTON_AUTO_MODE_TEMP = 25; // The fixed setpoint the unit uses in auto mode + +static constexpr uint8_t AIRTON_MODE_AUTO = 0b000; +static constexpr uint8_t AIRTON_MODE_COOL = 0b001; +static constexpr uint8_t AIRTON_MODE_HEAT = 0b100; +static constexpr uint8_t AIRTON_MODE_DRY = 0b010; +static constexpr uint8_t AIRTON_MODE_FAN = 0b011; + +static constexpr uint8_t AIRTON_FAN_AUTO = 0b000; +static constexpr uint8_t AIRTON_FAN_1 = 0b001; +static constexpr uint8_t AIRTON_FAN_2 = 0b010; +static constexpr uint8_t AIRTON_FAN_3 = 0b011; +static constexpr uint8_t AIRTON_FAN_4 = 0b100; +static constexpr uint8_t AIRTON_FAN_5 = 0b101; + +static constexpr uint32_t AIRTON_IR_FREQUENCY = 38000; +static constexpr uint32_t AIRTON_HEADER_MARK = 6630; +static constexpr uint32_t AIRTON_HEADER_SPACE = 3350; +static constexpr uint32_t AIRTON_BIT_MARK = 400; +static constexpr uint32_t AIRTON_ONE_SPACE = 1260; +static constexpr uint32_t AIRTON_ZERO_SPACE = 430; +static constexpr uint32_t AIRTON_MESSAGE_SPACE = 100000; + +static constexpr uint8_t AIRTON_STATE_FRAME_SIZE = 7; +// Every frame starts with these two bytes +static constexpr uint8_t AIRTON_HEADER_BYTE0 = 0xD3; +static constexpr uint8_t AIRTON_HEADER_BYTE1 = 0x11; + +// Position of the air outlet flap; the values are the nibble the frame carries and the index +// of the option in the vertical direction select +enum class VerticalDirection : uint8_t { + VERTICAL_DIRECTION_OFF = 0, + VERTICAL_DIRECTION_SWING = 1, + VERTICAL_DIRECTION_UP = 2, + VERTICAL_DIRECTION_MIDDLE_UP = 3, + VERTICAL_DIRECTION_MIDDLE = 4, + VERTICAL_DIRECTION_MIDDLE_DOWN = 5, + VERTICAL_DIRECTION_DOWN = 6, +}; + +// The on/off settings the switch platform exposes +enum class AirtonSetting : uint8_t { + AIRTON_SETTING_SLEEP_MODE, + AIRTON_SETTING_DISPLAY, +}; + +// The settings the unit keeps that the climate state does not carry; saved to flash when they change +struct AirtonSettings { + bool sleep_state; + bool display_state; + VerticalDirection vertical_direction; +}; + +class AirtonClimate : public climate_ir::ClimateIR { + public: + AirtonClimate() + : climate_ir::ClimateIR(AIRTON_TEMP_MIN, AIRTON_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}) {} + void setup() override; + void control(const climate::ClimateCall &call) override; + + // The direction the settings start from until a saved one is restored in setup() + void set_default_vertical_direction(VerticalDirection direction) { this->settings_.vertical_direction = direction; } + + // The setters update the state, publish their entity and save; they return whether anything changed. + // A change made here (switch, select, action) is then sent with send_settings(); a frame from the + // remote only reports what the unit already did. + bool set_sleep_mode_state(bool state); + bool set_display_state(bool state); + bool set_vertical_direction_state(VerticalDirection direction); + void send_settings(); + void set_setting(AirtonSetting setting, bool state) { + const bool changed = setting == AirtonSetting::AIRTON_SETTING_DISPLAY ? this->set_display_state(state) + : this->set_sleep_mode_state(state); + if (changed) + this->send_settings(); + } + +#ifdef USE_SWITCH + void set_sleep_mode_switch(switch_::Switch *sw) { this->sleep_mode_switch_ = sw; } + void set_display_switch(switch_::Switch *sw) { this->display_switch_ = sw; } +#endif +#ifdef USE_SELECT + void set_vertical_direction_select(select::Select *sel) { this->vertical_direction_select_ = sel; } +#endif + + protected: + void publish_settings_(); + void save_settings_(); + void sync_swing_mode_(); + bool sleep_mode_allowed_() const; + + void transmit_state() override; + uint8_t operation_mode_(); + uint8_t fan_speed_(); + uint8_t temperature_(); + uint8_t operation_settings_(); + + bool on_receive(remote_base::RemoteReceiveData data) override; + void parse_state_frame_(const uint8_t *frame); + +#ifdef USE_SWITCH + switch_::Switch *sleep_mode_switch_{nullptr}; + switch_::Switch *display_switch_{nullptr}; +#endif +#ifdef USE_SELECT + select::Select *vertical_direction_select_{nullptr}; +#endif + ESPPreferenceObject settings_pref_; + // The unit's own defaults: display lit, no sleep mode, flap still + AirtonSettings settings_{false, true, VerticalDirection::VERTICAL_DIRECTION_OFF}; + // The last mode sent, so turning off repeats it with the power bit cleared + uint8_t previous_mode_{0}; +}; + +} // namespace esphome::airton diff --git a/esphome/components/airton/climate.py b/esphome/components/airton/climate.py new file mode 100644 index 00000000000..fdfef45aaa4 --- /dev/null +++ b/esphome/components/airton/climate.py @@ -0,0 +1,70 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import climate_ir +import esphome.config_validation as cv +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@Procsiab"] + +airton_ns = cg.esphome_ns.namespace("airton") +AirtonClimate = airton_ns.class_("AirtonClimate", climate_ir.ClimateIR) + +CONF_AIRTON_ID = "airton_id" +CONF_SLEEP_MODE = "sleep_mode" +CONF_VERTICAL_DIRECTION = "vertical_direction" + +VerticalDirection = airton_ns.enum("VerticalDirection", is_class=True) +# In the order of the enum, so the option index is the direction value +VERTICAL_DIRECTIONS = { + "off": VerticalDirection.VERTICAL_DIRECTION_OFF, + "swing": VerticalDirection.VERTICAL_DIRECTION_SWING, + "up": VerticalDirection.VERTICAL_DIRECTION_UP, + "middle-up": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_UP, + "middle": VerticalDirection.VERTICAL_DIRECTION_MIDDLE, + "middle-down": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_DOWN, + "down": VerticalDirection.VERTICAL_DIRECTION_DOWN, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(AirtonClimate).extend( + { + cv.Optional(CONF_VERTICAL_DIRECTION, default="off"): cv.enum( + VERTICAL_DIRECTIONS + ), + } +) + +AIRTON_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(AirtonClimate), + } +) + +for _name, _call in ( + ( + "climate.airton.display_on", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_DISPLAY, true)", + ), + ( + "climate.airton.display_off", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_DISPLAY, false)", + ), + ( + "climate.airton.sleep_on", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_SLEEP_MODE, true)", + ), + ( + "climate.airton.sleep_off", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_SLEEP_MODE, false)", + ), +): + automation.register_apply_action( + _name, AIRTON_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + + +async def to_code(config: ConfigType) -> None: + var = await climate_ir.new_climate_ir(config) + # The default until a saved setting is restored in setup(); off is the member's initial value + if (direction := config[CONF_VERTICAL_DIRECTION]) != "off": + cg.add(var.set_default_vertical_direction(direction)) diff --git a/esphome/components/airton/select/__init__.py b/esphome/components/airton/select/__init__.py new file mode 100644 index 00000000000..1772701611a --- /dev/null +++ b/esphome/components/airton/select/__init__.py @@ -0,0 +1,37 @@ +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 ..climate import ( + CONF_AIRTON_ID, + CONF_VERTICAL_DIRECTION, + VERTICAL_DIRECTIONS, + AirtonClimate, + airton_ns, +) + +CODEOWNERS = ["@Procsiab"] + +VerticalDirectionSelect = airton_ns.class_("VerticalDirectionSelect", select.Select) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate), + cv.Optional(CONF_VERTICAL_DIRECTION): select.select_schema( + VerticalDirectionSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AIRTON_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_VERTICAL_DIRECTION, + hub.set_vertical_direction_select, + options=list(VERTICAL_DIRECTIONS), + ) diff --git a/esphome/components/airton/select/direction.h b/esphome/components/airton/select/direction.h new file mode 100644 index 00000000000..4697385c161 --- /dev/null +++ b/esphome/components/airton/select/direction.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../airton.h" + +namespace esphome::airton { + +class VerticalDirectionSelect : public select::Select, public Parented { + protected: + // The options are in the order of the enum, so the index is the direction; the parent publishes the state + void control(size_t index) override { + if (this->parent_->set_vertical_direction_state(static_cast(index))) + this->parent_->send_settings(); + } +}; + +} // namespace esphome::airton diff --git a/esphome/components/airton/switch/__init__.py b/esphome/components/airton/switch/__init__.py new file mode 100644 index 00000000000..94cbb5c8c4a --- /dev/null +++ b/esphome/components/airton/switch/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_DISPLAY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import CONF_AIRTON_ID, CONF_SLEEP_MODE, AirtonClimate, airton_ns + +CODEOWNERS = ["@Procsiab"] + +AirtonSwitch = airton_ns.class_("AirtonSwitch", switch.Switch) +AirtonSetting = airton_ns.enum("AirtonSetting", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate), + cv.Optional(CONF_DISPLAY): switch.switch_schema( + AirtonSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="DISABLED", + ), + cv.Optional(CONF_SLEEP_MODE): switch.switch_schema( + AirtonSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="DISABLED", + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AIRTON_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches( + CONF_DISPLAY, hub.set_display_switch, AirtonSetting.AIRTON_SETTING_DISPLAY + ) + await switches( + CONF_SLEEP_MODE, + hub.set_sleep_mode_switch, + AirtonSetting.AIRTON_SETTING_SLEEP_MODE, + ) diff --git a/esphome/components/airton/switch/airton_switch.h b/esphome/components/airton/switch/airton_switch.h new file mode 100644 index 00000000000..d887bacad64 --- /dev/null +++ b/esphome/components/airton/switch/airton_switch.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../airton.h" + +namespace esphome::airton { + +// One of the unit's on/off settings; the parent sends the state and publishes it back +class AirtonSwitch : public switch_::Switch, public Parented { + public: + explicit AirtonSwitch(AirtonSetting setting) : setting_(setting) {} + + protected: + void write_state(bool state) override { this->parent_->set_setting(this->setting_, state); } + + AirtonSetting setting_; +}; + +} // namespace esphome::airton diff --git a/tests/components/airton/common-bare.yaml b/tests/components/airton/common-bare.yaml new file mode 100644 index 00000000000..e2529bbf155 --- /dev/null +++ b/tests/components/airton/common-bare.yaml @@ -0,0 +1,13 @@ +# The climate with USE_SWITCH defined by another platform and USE_SELECT not defined at all; the ESP8266 build +# covers this path and the ESP32 build the full one +climate: + - platform: airton + id: airton_bare + name: Airton Climate + transmitter_id: xmitr + receiver_id: rcvr + +switch: + - platform: template + name: Unrelated Switch + optimistic: true diff --git a/tests/components/airton/common.yaml b/tests/components/airton/common.yaml new file mode 100644 index 00000000000..b5b053f3734 --- /dev/null +++ b/tests/components/airton/common.yaml @@ -0,0 +1,34 @@ +esphome: + on_boot: + then: + - climate.airton.display_on: airton_climate + - climate.airton.display_off: + id: airton_climate + - climate.airton.sleep_on: airton_climate + - climate.airton.sleep_off: airton_climate + +sensor: + - platform: template + id: airton_sensor + lambda: "return 21;" + +climate: + - platform: airton + id: airton_climate + name: Airton Climate + transmitter_id: xmitr + receiver_id: rcvr + sensor: airton_sensor + vertical_direction: middle + +switch: + - platform: airton + sleep_mode: + name: Sleep Mode + display: + name: Display Light + +select: + - platform: airton + vertical_direction: + name: Vertical Direction diff --git a/tests/components/airton/test.esp32-idf.yaml b/tests/components/airton/test.esp32-idf.yaml new file mode 100644 index 00000000000..9e2cd6bed7e --- /dev/null +++ b/tests/components/airton/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 + airton: !include common.yaml diff --git a/tests/components/airton/test.esp8266-ard.yaml b/tests/components/airton/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3b62daa505f --- /dev/null +++ b/tests/components/airton/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 + airton: !include common-bare.yaml From ee117886b90829b58ab6e203a78c7da1fd300341 Mon Sep 17 00:00:00 2001 From: Bofr5555 <65888788+Bofr5555@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:42:55 +0200 Subject: [PATCH 558/651] [vevor_dtx] Add Vevor weather station component (#17224) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/vevor_dtx/__init__.py | 48 ++++ esphome/components/vevor_dtx/binary_sensor.py | 26 +++ esphome/components/vevor_dtx/sensor.py | 107 +++++++++ esphome/components/vevor_dtx/text_sensor.py | 24 ++ esphome/components/vevor_dtx/vevor_dtx.cpp | 159 +++++++++++++ esphome/components/vevor_dtx/vevor_dtx.h | 63 ++++++ tests/components/vevor_dtx/__init__.py | 16 ++ tests/components/vevor_dtx/common.yaml | 39 ++++ .../components/vevor_dtx/test.esp32-idf.yaml | 3 + .../vevor_dtx/test.esp8266-ard.yaml | 3 + .../components/vevor_dtx/test.rp2040-ard.yaml | 3 + .../validate-two-hubs.esp32-idf.yaml | 21 ++ tests/components/vevor_dtx/vevor_dtx_test.cpp | 208 ++++++++++++++++++ .../common/remote_receiver/rp2040-ard.yaml | 12 + 15 files changed, 733 insertions(+) create mode 100644 esphome/components/vevor_dtx/__init__.py create mode 100644 esphome/components/vevor_dtx/binary_sensor.py create mode 100644 esphome/components/vevor_dtx/sensor.py create mode 100644 esphome/components/vevor_dtx/text_sensor.py create mode 100644 esphome/components/vevor_dtx/vevor_dtx.cpp create mode 100644 esphome/components/vevor_dtx/vevor_dtx.h create mode 100644 tests/components/vevor_dtx/__init__.py create mode 100644 tests/components/vevor_dtx/common.yaml create mode 100644 tests/components/vevor_dtx/test.esp32-idf.yaml create mode 100644 tests/components/vevor_dtx/test.esp8266-ard.yaml create mode 100644 tests/components/vevor_dtx/test.rp2040-ard.yaml create mode 100644 tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml create mode 100644 tests/components/vevor_dtx/vevor_dtx_test.cpp create mode 100644 tests/test_build_components/common/remote_receiver/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 19f1194bd95..1f1375ba968 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -691,6 +691,7 @@ esphome/components/vbus/* @ssieb esphome/components/veml3235/* @kbx81 esphome/components/veml7700/* @latonita esphome/components/version/* @esphome/core +esphome/components/vevor_dtx/* @Bofr5555 esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher diff --git a/esphome/components/vevor_dtx/__init__.py b/esphome/components/vevor_dtx/__init__.py new file mode 100644 index 00000000000..03522c6f1d1 --- /dev/null +++ b/esphome/components/vevor_dtx/__init__.py @@ -0,0 +1,48 @@ +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import TimePeriod +from esphome.types import ConfigType + +AUTO_LOAD = ["remote_base"] +CODEOWNERS = ["@Bofr5555"] +DEPENDENCIES = ["remote_receiver"] +DOMAIN = "vevor_dtx" +MULTI_CONF = True + +CONF_BIT_TIME = "bit_time" +CONF_STATION_ID = "station_id" +CONF_VEVOR_DTX_ID = "vevor_dtx_id" + +vevor_dtx_ns = cg.esphome_ns.namespace("vevor_dtx") +VevorDtxComponent = vevor_dtx_ns.class_( + "VevorDtxComponent", cg.Component, remote_base.RemoteReceiverListener +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(VevorDtxComponent), + cv.Optional(CONF_BIT_TIME, default="88us"): cv.All( + cv.positive_time_period_microseconds, + cv.Range( + min=TimePeriod(microseconds=50), + max=TimePeriod(microseconds=150), + ), + ), + cv.Optional(CONF_STATION_ID): cv.hex_uint16_t, + } + ) + .extend(remote_base.REMOTE_LISTENER_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await remote_base.register_listener(var, config) + cg.add(var.set_bit_time_us(config[CONF_BIT_TIME])) + if (station_id := config.get(CONF_STATION_ID)) is not None: + cg.add(var.set_station_id(station_id)) diff --git a/esphome/components/vevor_dtx/binary_sensor.py b/esphome/components/vevor_dtx/binary_sensor.py new file mode 100644 index 00000000000..02ed7f291cc --- /dev/null +++ b/esphome/components/vevor_dtx/binary_sensor.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_BATTERY +from esphome.types import ConfigType + +from . import CONF_VEVOR_DTX_ID, VevorDtxComponent + +DEPENDENCIES = ["vevor_dtx"] + +CONF_LOW_BATTERY = "low_battery" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_VEVOR_DTX_ID): cv.use_id(VevorDtxComponent), + cv.Optional(CONF_LOW_BATTERY): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_BATTERY + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_VEVOR_DTX_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_LOW_BATTERY, hub.set_low_battery_binary_sensor) diff --git a/esphome/components/vevor_dtx/sensor.py b/esphome/components/vevor_dtx/sensor.py new file mode 100644 index 00000000000..2a44912222b --- /dev/null +++ b/esphome/components/vevor_dtx/sensor.py @@ -0,0 +1,107 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_HUMIDITY, + CONF_ILLUMINANCE, + CONF_TEMPERATURE, + CONF_WIND_DIRECTION_DEGREES, + CONF_WIND_SPEED, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_WIND_DIRECTION, + DEVICE_CLASS_WIND_SPEED, + ICON_COUNTER, + ICON_GRAIN, + ICON_SIGN_DIRECTION, + ICON_WEATHER_WINDY, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_DEGREES, + UNIT_LUX, + UNIT_METER_PER_SECOND, + UNIT_MILLIMETER, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from . import CONF_VEVOR_DTX_ID, VevorDtxComponent + +DEPENDENCIES = ["vevor_dtx"] + +CONF_RAIN = "rain" +CONF_TX_COUNTER = "tx_counter" +CONF_UV_INDEX = "uv_index" +CONF_WIND_GUST = "wind_gust" + +_WIND_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_METER_PER_SECOND, + icon=ICON_WEATHER_WINDY, + accuracy_decimals=1, + device_class=DEVICE_CLASS_WIND_SPEED, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_VEVOR_DTX_ID): cv.use_id(VevorDtxComponent), + 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=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_WIND_SPEED): _WIND_SCHEMA, + cv.Optional(CONF_WIND_GUST): _WIND_SCHEMA, + cv.Optional(CONF_WIND_DIRECTION_DEGREES): sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + icon=ICON_SIGN_DIRECTION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_WIND_DIRECTION, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RAIN): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETER, + icon=ICON_GRAIN, + accuracy_decimals=1, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_UV_INDEX): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TX_COUNTER): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_COUNTER, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_VEVOR_DTX_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_WIND_SPEED, hub.set_wind_speed_sensor) + await sensors(CONF_WIND_GUST, hub.set_wind_gust_sensor) + await sensors(CONF_WIND_DIRECTION_DEGREES, hub.set_wind_direction_sensor) + await sensors(CONF_RAIN, hub.set_rain_sensor) + await sensors(CONF_UV_INDEX, hub.set_uv_index_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_TX_COUNTER, hub.set_tx_counter_sensor) diff --git a/esphome/components/vevor_dtx/text_sensor.py b/esphome/components/vevor_dtx/text_sensor.py new file mode 100644 index 00000000000..38810b8d4db --- /dev/null +++ b/esphome/components/vevor_dtx/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_SENSOR_ID +from esphome.types import ConfigType + +from . import CONF_VEVOR_DTX_ID, VevorDtxComponent + +DEPENDENCIES = ["vevor_dtx"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_VEVOR_DTX_ID): cv.use_id(VevorDtxComponent), + cv.Optional(CONF_SENSOR_ID): text_sensor.text_sensor_schema( + icon="mdi:identifier", + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_VEVOR_DTX_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_SENSOR_ID, hub.set_sensor_id_text_sensor) diff --git a/esphome/components/vevor_dtx/vevor_dtx.cpp b/esphome/components/vevor_dtx/vevor_dtx.cpp new file mode 100644 index 00000000000..b42c3803fe5 --- /dev/null +++ b/esphome/components/vevor_dtx/vevor_dtx.cpp @@ -0,0 +1,159 @@ +#include "vevor_dtx.h" + +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::vevor_dtx { + +ESPHOME_LOG_TAG(TAG, "vevor_dtx"); + +// The five sync bytes and the two constant header bytes that start every frame, matched bit by bit +static constexpr uint64_t SYNC_AND_HEADER = 0xAAAACACA54AA00ULL; +static constexpr uint64_t SYNC_AND_HEADER_MASK = (1ULL << 56) - 1; +static constexpr uint8_t HEADER_SIZE = 2; +// Wind arrives in 1/8.333 km/h steps and gust in 1/1.25 km/h (0.8 km/h) steps; both are published in m/s +static constexpr float WIND_SPEED_MS_PER_RAW = 1.0f / (8.333f * 3.6f); +static constexpr float WIND_GUST_MS_PER_RAW = 1.0f / (1.25f * 3.6f); +static constexpr float RAIN_MM_PER_RAW = 0.233f; + +// Wind, direction, rain and light arrive with an offset of one on each byte; 0x00 wraps to 0xFF +static uint16_t offset_word(uint8_t high, uint8_t low) { + return encode_uint16(static_cast(high - 1), static_cast(low - 1)); +} + +void VevorDtxComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Vevor DTX:\n" + " Bit time: %u us", + this->bit_time_us_); + if (this->station_id_ != 0) { + ESP_LOGCONFIG(TAG, " Station ID: 0x%04X", this->station_id_); + } +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Wind speed", this->wind_speed_sensor_); + LOG_SENSOR(" ", "Wind gust", this->wind_gust_sensor_); + LOG_SENSOR(" ", "Wind direction", this->wind_direction_sensor_); + LOG_SENSOR(" ", "Rain", this->rain_sensor_); + LOG_SENSOR(" ", "UV index", this->uv_index_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "TX counter", this->tx_counter_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Sensor ID", this->sensor_id_text_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Low battery", this->low_battery_binary_sensor_); +#endif +} + +bool VevorDtxComponent::on_receive(remote_base::RemoteReceiveData data) { + Payload payload{}; + if (!this->decode_timings_(data.get_raw_data(), payload)) { + ESP_LOGVV(TAG, "No Vevor DTX frame in %" PRId32 " timings", data.size()); + return false; + } + return this->publish_(payload); +} + +// The frame is NRZ at a fixed bit time: a high run of n bit times is n one bits. The bits are shifted +// through a register until the sync word appears, then the payload is collected and its checksum tested. +// A run is never treated as a gap, since a payload may hold long runs of zeros; a gap simply shifts zeros +// through the register, and one longer than a frame is cut to a frame's worth, which has the same effect. +bool VevorDtxComponent::decode_timings_(const remote_base::RawTimings &timings, Payload &payload) const { + uint64_t stream = 0; + uint16_t collected = 0; // payload bits gathered since the sync word, 0 while still searching for it + + for (const int32_t timing : timings) { + const uint32_t duration = std::abs(timing); + const uint32_t cells = + std::clamp((duration + this->bit_time_us_ / 2) / this->bit_time_us_, 1, FRAME_BITS); + const uint64_t bit = timing > 0 ? 1 : 0; + for (uint32_t i = 0; i < cells; i++) { + stream = (stream << 1) | bit; + if (collected == 0) { + if ((stream & SYNC_AND_HEADER_MASK) != SYNC_AND_HEADER) { + continue; + } + payload[0] = uint8_t(SYNC_AND_HEADER >> 8); + payload[1] = uint8_t(SYNC_AND_HEADER); + collected = HEADER_SIZE * 8; + continue; + } + payload[collected / 8] = uint8_t((payload[collected / 8] << 1) | bit); + if (++collected < FRAME_BITS) { + continue; + } + // The byte after the checksum repeats the counter plus one; it rejects frames whose sum matches by chance + if (sum_bytes(payload.data(), CHECKSUM_INDEX) == payload[CHECKSUM_INDEX] && + payload[TX_COUNTER_INDEX + 2] == uint8_t(payload[TX_COUNTER_INDEX] + 1)) { + return true; + } + ESP_LOGV(TAG, "Rejected frame: sum 0x%02X received 0x%02X, counter %u followed by %u", + sum_bytes(payload.data(), CHECKSUM_INDEX), payload[CHECKSUM_INDEX], payload[TX_COUNTER_INDEX], + payload[TX_COUNTER_INDEX + 2]); + collected = 0; + } + } + return false; +} + +bool VevorDtxComponent::publish_(const Payload &payload) { + const uint16_t sensor_id = encode_uint16(payload[2], payload[3]); + if (this->station_id_ != 0 && sensor_id != this->station_id_) { + ESP_LOGV(TAG, "Ignoring station 0x%04X", sensor_id); + return false; + } + const float temperature = (float(encode_uint16(payload[5], payload[6])) - 500.0f) / 10.0f; + const uint8_t humidity = payload[7]; + const float wind_speed = float(offset_word(payload[8], payload[9])) * WIND_SPEED_MS_PER_RAW; + const float wind_gust = float(payload[10]) * WIND_GUST_MS_PER_RAW; + const float wind_direction = float(offset_word(payload[11], payload[12]) & 0x0FFF); + const float rain = float(offset_word(payload[13], payload[14])) * RAIN_MM_PER_RAW; + const int8_t uv_index = int8_t(payload[15] & 0x1F) - 1; + // The top bit of the light word selects a tenfold range + const uint16_t lux_raw = offset_word(payload[16], payload[17]); + const float illuminance = (lux_raw & 0x8000) != 0 ? float(lux_raw & 0x7FFF) * 10.0f : float(lux_raw); + const uint8_t tx_counter = payload[18]; + + ESP_LOGD(TAG, + "Vevor DTX id=0x%04X status=0x%02X temp=%.1fC hum=%u%% wind=%.1fm/s gust=%.1fm/s dir=%.0f " + "rain=%.1f uv=%d raw=0x%02X lux=%.0f raw=0x%04X tx=%u", + sensor_id, payload[4], temperature, humidity, wind_speed, wind_gust, wind_direction, rain, uv_index, + payload[15], illuminance, lux_raw, tx_counter); + +#ifdef USE_SENSOR + const auto publish = [](sensor::Sensor *sensor, float value) { + if (sensor != nullptr) + sensor->publish_state(value); + }; + publish(this->temperature_sensor_, temperature); + publish(this->humidity_sensor_, humidity); + publish(this->wind_speed_sensor_, wind_speed); + publish(this->wind_gust_sensor_, wind_gust); + publish(this->wind_direction_sensor_, wind_direction); + publish(this->rain_sensor_, rain); + publish(this->uv_index_sensor_, uv_index); + publish(this->illuminance_sensor_, illuminance); + publish(this->tx_counter_sensor_, tx_counter); +#endif +#ifdef USE_TEXT_SENSOR + if (this->sensor_id_text_sensor_ != nullptr) { + char text[7]; + snprintf(text, sizeof(text), "0x%04X", sensor_id); + this->sensor_id_text_sensor_->publish_state(text); + } +#endif +#ifdef USE_BINARY_SENSOR + if (this->low_battery_binary_sensor_ != nullptr) + this->low_battery_binary_sensor_->publish_state((payload[4] & 0x80) != 0); +#endif + return true; +} + +} // namespace esphome::vevor_dtx diff --git a/esphome/components/vevor_dtx/vevor_dtx.h b/esphome/components/vevor_dtx/vevor_dtx.h new file mode 100644 index 00000000000..56eea99415d --- /dev/null +++ b/esphome/components/vevor_dtx/vevor_dtx.h @@ -0,0 +1,63 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/remote_base/remote_base.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 + +#include + +namespace esphome::vevor_dtx { + +class VevorDtxComponent : public Component, public remote_base::RemoteReceiverListener { +#ifdef USE_SENSOR + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(wind_speed) + SUB_SENSOR(wind_gust) + SUB_SENSOR(wind_direction) + SUB_SENSOR(rain) + SUB_SENSOR(uv_index) + SUB_SENSOR(illuminance) + SUB_SENSOR(tx_counter) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(sensor_id) +#endif +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(low_battery) +#endif + + public: + void dump_config() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + void set_bit_time_us(uint8_t bit_time_us) { this->bit_time_us_ = bit_time_us; } + void set_station_id(uint16_t station_id) { this->station_id_ = station_id; } + + protected: + static constexpr uint8_t PAYLOAD_SIZE = 21; + static constexpr uint8_t CHECKSUM_INDEX = 19; + static constexpr uint8_t TX_COUNTER_INDEX = 18; + // The frame ends with the counter echo; trailing zero bits that merge into the receiver's idle entry come + // back when that entry is cut to a frame's worth of zeros + static constexpr uint16_t FRAME_BITS = PAYLOAD_SIZE * 8; + + using Payload = std::array; + + bool decode_timings_(const remote_base::RawTimings &timings, Payload &payload) const; + bool publish_(const Payload &payload); + + uint16_t station_id_{0}; // 0 accepts every station in range + uint8_t bit_time_us_{88}; +}; + +} // namespace esphome::vevor_dtx diff --git a/tests/components/vevor_dtx/__init__.py b/tests/components/vevor_dtx/__init__.py new file mode 100644 index 00000000000..d2fd62c61b2 --- /dev/null +++ b/tests/components/vevor_dtx/__init__.py @@ -0,0 +1,16 @@ +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: + # The gtests attach every entity kind the hub can publish to + for platform in ("sensor", "binary_sensor", "text_sensor"): + CORE.testing_ensure_platform_registered(platform) + + manifest.to_code = to_code_testing + # The frames come from the test, not from a receiver on a GPIO + manifest.dependencies = ["remote_base", "sensor", "binary_sensor", "text_sensor"] + # The host test config holds one plain hub entry; a multi-instance component would get an empty list + manifest.multi_conf = False diff --git a/tests/components/vevor_dtx/common.yaml b/tests/components/vevor_dtx/common.yaml new file mode 100644 index 00000000000..2d5de1e69d6 --- /dev/null +++ b/tests/components/vevor_dtx/common.yaml @@ -0,0 +1,39 @@ +vevor_dtx: + id: vevor_dtx_hub + receiver_id: rcvr + bit_time: 88us + station_id: 0x1234 + +sensor: + - platform: vevor_dtx + vevor_dtx_id: vevor_dtx_hub + temperature: + name: Outdoor Temperature + humidity: + name: Outdoor Humidity + wind_speed: + name: Wind Speed + wind_gust: + name: Wind Gust + wind_direction_degrees: + name: Wind Direction + rain: + name: Rain + uv_index: + name: UV Index + illuminance: + name: Illuminance + tx_counter: + name: TX Counter + +text_sensor: + - platform: vevor_dtx + vevor_dtx_id: vevor_dtx_hub + sensor_id: + name: Sensor ID + +binary_sensor: + - platform: vevor_dtx + vevor_dtx_id: vevor_dtx_hub + low_battery: + name: Low Battery diff --git a/tests/components/vevor_dtx/test.esp32-idf.yaml b/tests/components/vevor_dtx/test.esp32-idf.yaml new file mode 100644 index 00000000000..493b275f743 --- /dev/null +++ b/tests/components/vevor_dtx/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + vevor_dtx: !include common.yaml diff --git a/tests/components/vevor_dtx/test.esp8266-ard.yaml b/tests/components/vevor_dtx/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4ac20e92d06 --- /dev/null +++ b/tests/components/vevor_dtx/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + vevor_dtx: !include common.yaml diff --git a/tests/components/vevor_dtx/test.rp2040-ard.yaml b/tests/components/vevor_dtx/test.rp2040-ard.yaml new file mode 100644 index 00000000000..910202057f7 --- /dev/null +++ b/tests/components/vevor_dtx/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/rp2040-ard.yaml + vevor_dtx: !include common.yaml diff --git a/tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml b/tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml new file mode 100644 index 00000000000..43c3df671cd --- /dev/null +++ b/tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml @@ -0,0 +1,21 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + +# Two stations on one receiver, each with its own hub +vevor_dtx: + - id: roof_station + receiver_id: rcvr + station_id: 0x1234 + - id: garden_station + receiver_id: rcvr + station_id: 0x5678 + +sensor: + - platform: vevor_dtx + vevor_dtx_id: roof_station + temperature: + name: Roof Temperature + - platform: vevor_dtx + vevor_dtx_id: garden_station + temperature: + name: Garden Temperature diff --git a/tests/components/vevor_dtx/vevor_dtx_test.cpp b/tests/components/vevor_dtx/vevor_dtx_test.cpp new file mode 100644 index 00000000000..33fb2d94778 --- /dev/null +++ b/tests/components/vevor_dtx/vevor_dtx_test.cpp @@ -0,0 +1,208 @@ +#include + +#include + +#include "esphome/components/vevor_dtx/vevor_dtx.h" +#include "esphome/core/helpers.h" + +namespace esphome::vevor_dtx::testing { + +static constexpr uint32_t BIT_TIME_US = 88; +static constexpr int32_t FRAME_GAP_US = -20000; + +class VevorDtxUnderTest : public VevorDtxComponent { + public: + using VevorDtxComponent::decode_timings_; + using VevorDtxComponent::Payload; +}; + +// Payload of one transmission: station 0x1234 with a low battery, 21.5 °C, 55 %, 9.96 km/h wind, +// 20 km/h gust, 180°, 23.3 mm of rain, UV index 5, 1234 lx, transmission counter `tx` +static VevorDtxUnderTest::Payload sample_payload(uint8_t tx = 7) { + VevorDtxUnderTest::Payload p{0xAA, 0x00, 0x12, 0x34, 0x80, 0x02, 0xCB, 55, 0x01, 0x54, 25, + 0x01, 0xB5, 0x01, 0x65, 6, 0x05, 0xD3, tx, 0, uint8_t(tx + 1)}; + p[19] = sum_bytes(p.data(), 19); + return p; +} + +// Run lengths of the NRZ bit stream for the sync bytes plus payload, framed by the inter-frame gap on both +// sides; `lead_bits` extra one bits in front shift the byte alignment the receiver sees +static remote_base::RawTimings timings(const VevorDtxUnderTest::Payload &payload, uint8_t lead_bits = 0) { + std::vector bits(lead_bits, true); + const auto push_byte = [&bits](uint8_t byte) { + for (int i = 7; i >= 0; i--) { + bits.push_back(((byte >> i) & 1) != 0); + } + }; + for (uint8_t byte : {0xAA, 0xAA, 0xCA, 0xCA, 0x54}) { + push_byte(byte); + } + for (uint8_t byte : payload) { + push_byte(byte); + } + remote_base::RawTimings out{FRAME_GAP_US}; + bool level = bits[0]; + uint32_t run = 0; + for (bool bit : bits) { + if (bit == level) { + run++; + continue; + } + out.push_back(level ? int32_t(run * BIT_TIME_US) : -int32_t(run * BIT_TIME_US)); + level = bit; + run = 1; + } + out.push_back(level ? int32_t(run * BIT_TIME_US) : -int32_t(run * BIT_TIME_US)); + out.push_back(FRAME_GAP_US); + return out; +} + +static remote_base::RemoteReceiveData receive_data(const remote_base::RawTimings &raw) { + return remote_base::RemoteReceiveData(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); +} + +TEST(VevorDtxTest, DecodesFrame) { + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload payload{}; + ASSERT_TRUE(hub.decode_timings_(timings(sample_payload()), payload)); + EXPECT_EQ(payload, sample_payload()); +} + +TEST(VevorDtxTest, DecodesFrameAtEveryBitOffset) { + VevorDtxUnderTest hub; + for (uint8_t lead = 1; lead < 8; lead++) { + VevorDtxUnderTest::Payload payload{}; + ASSERT_TRUE(hub.decode_timings_(timings(sample_payload(), lead), payload)) << "lead bits " << int(lead); + EXPECT_EQ(payload, sample_payload()); + } +} + +// A payload may hold a run of zeros longer than any inter-bit gap; it must not be taken for a frame gap +TEST(VevorDtxTest, DecodesAFrameWithALongZeroRun) { + auto payload = sample_payload(0); + payload[7] = 49; + payload[16] = 0x02; + payload[17] = 0x00; + payload[20] = 1; + payload[19] = sum_bytes(payload.data(), 19); + ASSERT_EQ(payload[19], 0); // 32 zero bits in a row across bytes 17 to 20 + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload decoded{}; + ASSERT_TRUE(hub.decode_timings_(timings(payload), decoded)); + EXPECT_EQ(decoded[7], 49); + EXPECT_EQ(decoded[19], 0); +} + +// The receiver ends its capture at idle, so the frame's trailing low bits merge into one long gap +TEST(VevorDtxTest, DecodesAFrameWhoseTailMergesIntoTheIdleGap) { + // Counter 15 is echoed as 0x10, so the frame's last four bits are low + const auto payload = sample_payload(15); + ASSERT_EQ(payload[20] & 0x0F, 0); + auto raw = timings(payload); + // Drop the trailing zero run and the gap, and end the capture in one idle entry instead + raw.pop_back(); + if (raw.back() < 0) + raw.pop_back(); + raw.push_back(FRAME_GAP_US); + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload decoded{}; + ASSERT_TRUE(hub.decode_timings_(raw, decoded)); + EXPECT_EQ(decoded[19], payload[19]); +} + +// The byte after the checksum must echo the counter plus one; the checksum does not cover it +TEST(VevorDtxTest, RejectsAWrongCounterEcho) { + auto payload = sample_payload(); + payload[20] = payload[18] + 2; + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload decoded{}; + EXPECT_FALSE(hub.decode_timings_(timings(payload), decoded)); +} + +TEST(VevorDtxTest, RejectsBadChecksum) { + VevorDtxUnderTest hub; + auto corrupted = sample_payload(); + corrupted[7] ^= 0x01; + VevorDtxUnderTest::Payload payload{}; + EXPECT_FALSE(hub.decode_timings_(timings(corrupted), payload)); +} + +TEST(VevorDtxTest, PublishesDecodedValues) { + VevorDtxUnderTest hub; + sensor::Sensor temperature, humidity, wind_speed, wind_gust, wind_direction, rain, uv_index, illuminance, tx_counter; + text_sensor::TextSensor sensor_id; + binary_sensor::BinarySensor low_battery; + hub.set_temperature_sensor(&temperature); + hub.set_humidity_sensor(&humidity); + hub.set_wind_speed_sensor(&wind_speed); + hub.set_wind_gust_sensor(&wind_gust); + hub.set_wind_direction_sensor(&wind_direction); + hub.set_rain_sensor(&rain); + hub.set_uv_index_sensor(&uv_index); + hub.set_illuminance_sensor(&illuminance); + hub.set_tx_counter_sensor(&tx_counter); + hub.set_sensor_id_text_sensor(&sensor_id); + hub.set_low_battery_binary_sensor(&low_battery); + + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload())))); + + EXPECT_NEAR(temperature.state, 21.5f, 0.01f); + EXPECT_NEAR(humidity.state, 55.0f, 0.01f); + EXPECT_NEAR(wind_speed.state, 9.96f / 3.6f, 0.01f); + EXPECT_NEAR(wind_gust.state, 20.0f / 3.6f, 0.01f); + EXPECT_NEAR(wind_direction.state, 180.0f, 0.01f); + EXPECT_NEAR(rain.state, 23.3f, 0.01f); + EXPECT_NEAR(uv_index.state, 5.0f, 0.01f); + EXPECT_NEAR(illuminance.state, 1234.0f, 0.01f); + EXPECT_NEAR(tx_counter.state, 7.0f, 0.01f); + EXPECT_EQ(sensor_id.state, "0x1234"); + EXPECT_TRUE(low_battery.state); +} + +// A byte that wrapped past 0xFF and a light word with the tenfold bit set +TEST(VevorDtxTest, UndoesTheOffsetPerByte) { + VevorDtxUnderTest hub; + sensor::Sensor rain, illuminance; + hub.set_rain_sensor(&rain); + hub.set_illuminance_sensor(&illuminance); + auto payload = sample_payload(); + payload[13] = 0x01; // rain 255: the low byte is 255 + 1 wrapped to 0 + payload[14] = 0x00; + payload[16] = 0x80; // light 0x7F00 = 32512 lx without the tenfold bit + payload[17] = 0x01; + payload[19] = sum_bytes(payload.data(), 19); + EXPECT_TRUE(hub.on_receive(receive_data(timings(payload)))); + EXPECT_NEAR(rain.state, 255 * 0.233f, 0.01f); + EXPECT_NEAR(illuminance.state, 32512.0f, 0.01f); +} + +TEST(VevorDtxTest, DecodesAFrameAfterNoise) { + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload payload{}; + // A noise burst, the gap, then the frame: the gap resets the search and the frame still decodes + remote_base::RawTimings raw{FRAME_GAP_US, 300, -500, 1000, -176}; + const auto frame = timings(sample_payload()); + raw.insert(raw.end(), frame.begin(), frame.end()); + ASSERT_TRUE(hub.decode_timings_(raw, payload)); + EXPECT_EQ(payload, sample_payload()); +} + +TEST(VevorDtxTest, IgnoresOtherStations) { + VevorDtxUnderTest hub; + hub.set_station_id(0x9999); + EXPECT_FALSE(hub.on_receive(receive_data(timings(sample_payload())))); + hub.set_station_id(0x1234); + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload())))); +} + +TEST(VevorDtxTest, TxCounterIsTheStationsByte) { + VevorDtxUnderTest hub; + sensor::Sensor tx_counter; + hub.set_tx_counter_sensor(&tx_counter); + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload(250))))); + EXPECT_NEAR(tx_counter.state, 250.0f, 0.01f); + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload(3))))); + EXPECT_NEAR(tx_counter.state, 3.0f, 0.01f); +} + +} // namespace esphome::vevor_dtx::testing diff --git a/tests/test_build_components/common/remote_receiver/rp2040-ard.yaml b/tests/test_build_components/common/remote_receiver/rp2040-ard.yaml new file mode 100644 index 00000000000..fe13e2dbc1b --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for RP2040 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: GPIO5 + +remote_receiver: + id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25% From 1307faef06d60941f65d4cbcfec46e5dbd9981ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:31:17 -1000 Subject: [PATCH 559/651] [core] Add Component::status_comm_result (#20863) --- esphome/core/component.cpp | 9 ++++++ esphome/core/component.h | 5 ++++ .../core/test_status_comm_result.cpp | 30 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 tests/components/core/test_status_comm_result.cpp diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index edcd922aebd..f65ec469ef0 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -290,6 +290,15 @@ void Component::status_set_warning(const LogString *message) { ESP_LOGW(TAG, "%s set Warning flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } +bool Component::status_comm_result(bool ok) { return this->status_comm_result(ok, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } +bool Component::status_comm_result(bool ok, const LogString *message) { + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(message); + } + return ok; +} void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } void Component::status_set_error(const LogString *message) { if (this->set_error_flag_(message) && message != nullptr) { diff --git a/esphome/core/component.h b/esphome/core/component.h index 0e95f8d4e90..df51af65df5 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -285,6 +285,11 @@ class Component { void status_set_warning(const char *message); void status_set_warning(const LogString *message); + /// Clears the warning when `ok`, else sets it with `message` (default "Communication failed"); returns `ok`. + /// Replaces the `if (!read()) { status_set_warning(...); return; } status_clear_warning();` pattern. + bool status_comm_result(bool ok); + bool status_comm_result(bool ok, const LogString *message); + void status_set_error(); // Set error flag without message void status_set_error(const LogString *message); diff --git a/tests/components/core/test_status_comm_result.cpp b/tests/components/core/test_status_comm_result.cpp new file mode 100644 index 00000000000..125b43a1b8a --- /dev/null +++ b/tests/components/core/test_status_comm_result.cpp @@ -0,0 +1,30 @@ +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" + +namespace esphome { + +TEST(StatusCommResult, FailureSetsWarningAndSuccessClearsIt) { + Component component; + EXPECT_FALSE(component.status_comm_result(false)); + EXPECT_TRUE(component.status_has_warning()); + EXPECT_TRUE(component.status_comm_result(true)); + EXPECT_FALSE(component.status_has_warning()); +} + +TEST(StatusCommResult, CustomMessageOverload) { + Component component; + EXPECT_FALSE(component.status_comm_result(false, LOG_STR("Bad CRC"))); + EXPECT_TRUE(component.status_has_warning()); + EXPECT_TRUE(component.status_comm_result(true, LOG_STR("Bad CRC"))); + EXPECT_FALSE(component.status_has_warning()); +} + +TEST(StatusCommResult, SuccessWithoutWarningStaysClear) { + Component component; + EXPECT_TRUE(component.status_comm_result(true)); + EXPECT_FALSE(component.status_has_warning()); +} + +} // namespace esphome From 968d57c56b0b2d1e209cc2f8f44b40dde31101b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:32:13 -1000 Subject: [PATCH 560/651] [core][time] Add BCD helpers and a shared RTC BCD calendar codec (#20860) --- esphome/components/time/real_time_clock.cpp | 33 +++++++++++++ esphome/components/time/real_time_clock.h | 6 +++ esphome/core/helpers.h | 5 ++ tests/components/core/test_bcd.cpp | 26 +++++++++++ tests/components/time/bcd_calendar.cpp | 51 +++++++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 tests/components/core/test_bcd.cpp create mode 100644 tests/components/time/bcd_calendar.cpp diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 5f6b7d150f9..a46f9712291 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -107,4 +107,37 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { this->time_sync_callback_.call(); } +// Bits of each calendar byte that hold the value, byte 0 lowest; byte 3 is the one-hot weekday. +// Packed into one constant so it stays in code rather than RAM on ESP8266. +static constexpr uint64_t BCD_CALENDAR_MASKS = 0xFF1F3F7F3F7F7FULL; + +ESPTime decode_bcd_calendar(const uint8_t *raw) { + uint8_t value[7]; + for (size_t i = 0; i < 7; i++) + value[i] = bcd_to_uint8(raw[i] & static_cast(BCD_CALENDAR_MASKS >> (8 * i))); + const uint8_t weekday = raw[3] & static_cast(BCD_CALENDAR_MASKS >> 24); + ESPTime time{ + .second = value[0], + .minute = value[1], + .hour = value[2], + .day_of_week = static_cast(weekday ? __builtin_ctz(weekday) + 1 : 1), + .day_of_month = value[4], + .day_of_year = 1, + .month = value[5], + .year = static_cast(value[6] + 2000), + .is_dst = false, + .timestamp = 0, + }; + time.recalc_timestamp_utc(false); + return time; +} + +void encode_bcd_calendar(const ESPTime &time, uint8_t *raw) { + const uint8_t value[7] = { + time.second, time.minute, time.hour, 0, time.day_of_month, time.month, static_cast(time.year % 100)}; + for (size_t i = 0; i < 7; i++) + raw[i] = uint8_to_bcd(value[i]); + raw[3] = 1 << (time.day_of_week - 1); +} + } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index f98adb67b5b..a87778e470c 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -44,4 +44,10 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; +/// Decode the 7-byte BCD calendar (second, minute, hour, one-hot weekday, day, month, year since 2000) +/// shared by Epson RTCs such as the RX8025T, RX8111 and RX8130, and fill in the UTC timestamp. +ESPTime decode_bcd_calendar(const uint8_t *raw); +/// Encode @p time into that 7-byte layout; @p time must be valid (is_valid()), with the year 2000 to 2099. +void encode_bcd_calendar(const ESPTime &time, uint8_t *raw); + } // namespace esphome::time diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c3e752c9690..d0e3c91ab0b 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1062,6 +1062,11 @@ constexpr std::array decode_value(T val) { return ret; } +/// Decode a two-digit packed BCD byte (tens in the high nibble) to its value, e.g. 0x59 to 59. +constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } +/// Encode a value from 0 to 99 as a two-digit packed BCD byte, e.g. 59 to 0x59. +constexpr uint8_t uint8_to_bcd(uint8_t value) { return ((value / 10) << 4) | (value % 10); } + /// Reverse the order of 8 bits. inline uint8_t reverse_bits(uint8_t x) { x = ((x & 0xAA) >> 1) | ((x & 0x55) << 1); diff --git a/tests/components/core/test_bcd.cpp b/tests/components/core/test_bcd.cpp new file mode 100644 index 00000000000..5d90d5aafcd --- /dev/null +++ b/tests/components/core/test_bcd.cpp @@ -0,0 +1,26 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static_assert(bcd_to_uint8(0x59) == 59); +static_assert(uint8_to_bcd(59) == 0x59); + +TEST(Bcd, RoundTripsEveryTwoDigitValue) { + for (uint8_t value = 0; value < 100; value++) { + const uint8_t bcd = uint8_to_bcd(value); + EXPECT_EQ(bcd >> 4, value / 10); + EXPECT_EQ(bcd & 0x0F, value % 10); + EXPECT_EQ(bcd_to_uint8(bcd), value); + } +} + +TEST(Bcd, DecodesBoundaryBytes) { + EXPECT_EQ(bcd_to_uint8(0x00), 0); + EXPECT_EQ(bcd_to_uint8(0x09), 9); + EXPECT_EQ(bcd_to_uint8(0x10), 10); + EXPECT_EQ(bcd_to_uint8(0x99), 99); +} + +} // namespace esphome::core::testing diff --git a/tests/components/time/bcd_calendar.cpp b/tests/components/time/bcd_calendar.cpp new file mode 100644 index 00000000000..c66454fe1b6 --- /dev/null +++ b/tests/components/time/bcd_calendar.cpp @@ -0,0 +1,51 @@ +#include + +#include + +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::time::testing { + +// 2026-04-16 23:59:58, a Thursday (day_of_week 5, one-hot bit 4). +static constexpr uint8_t RAW[7] = {0x58, 0x59, 0x23, 0x10, 0x16, 0x04, 0x26}; + +TEST(BcdCalendar, DecodesEveryField) { + const ESPTime time = decode_bcd_calendar(RAW); + EXPECT_EQ(time.second, 58); + EXPECT_EQ(time.minute, 59); + EXPECT_EQ(time.hour, 23); + EXPECT_EQ(time.day_of_week, 5); + EXPECT_EQ(time.day_of_month, 16); + EXPECT_EQ(time.month, 4); + EXPECT_EQ(time.year, 2026); + EXPECT_EQ(time.timestamp, 1776383998); + EXPECT_TRUE(time.is_valid(true, false)); +} + +TEST(BcdCalendar, MasksStatusBits) { + // Bit 7 of seconds/minutes, the top two bits of hour and day and the top three of month are not time. + const uint8_t raw[7] = {0xD8, 0xD9, 0xE3, 0x90, 0xD6, 0xE4, 0x26}; + const ESPTime time = decode_bcd_calendar(raw); + EXPECT_EQ(time.second, 58); + EXPECT_EQ(time.minute, 59); + EXPECT_EQ(time.hour, 23); + EXPECT_EQ(time.day_of_week, 5); + EXPECT_EQ(time.day_of_month, 16); + EXPECT_EQ(time.month, 4); +} + +TEST(BcdCalendar, EmptyWeekdayDecodesAsSunday) { + uint8_t raw[7]; + std::copy(RAW, RAW + 7, raw); + raw[3] = 0; + EXPECT_EQ(decode_bcd_calendar(raw).day_of_week, 1); +} + +TEST(BcdCalendar, EncodeRoundTrips) { + uint8_t raw[7]; + encode_bcd_calendar(decode_bcd_calendar(RAW), raw); + for (size_t i = 0; i < 7; i++) + EXPECT_EQ(raw[i], RAW[i]) << "byte " << i; +} + +} // namespace esphome::time::testing From a8ab035b59c1594f59c80f04f33fbf1eae4c1be5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:33:05 -1000 Subject: [PATCH 561/651] [lock] Filter on_lock and on_unlock with when (#20858) --- esphome/components/lock/__init__.py | 10 ++++++---- esphome/components/lock/automation.h | 22 ---------------------- 2 files changed, 6 insertions(+), 26 deletions(-) delete mode 100644 esphome/components/lock/automation.h diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index fb9a79f81fe..af12fc61b28 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -32,8 +32,6 @@ LockCall = lock_ns.class_("LockCall") LockPublishAction = lock_ns.class_("LockPublishAction", automation.Action) -LockStateForwarder = lock_ns.class_("LockStateForwarder") - LockState = lock_ns.enum("LockState") LOCK_STATES = LockState.options( @@ -90,12 +88,16 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_LOCK, "add_on_state_callback", - forwarder=LockStateForwarder.template(LockState.LOCK_STATE_LOCKED), + params=[(LockState, "state")], + forward=[], + when="state == lock::LOCK_STATE_LOCKED", ), automation.CallbackAutomation( CONF_ON_UNLOCK, "add_on_state_callback", - forwarder=LockStateForwarder.template(LockState.LOCK_STATE_UNLOCKED), + params=[(LockState, "state")], + forward=[], + when="state == lock::LOCK_STATE_UNLOCKED", ), ) diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h deleted file mode 100644 index e578a056439..00000000000 --- a/esphome/components/lock/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/components/lock/lock.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" - -namespace esphome::lock { - -/// Callback forwarder that triggers an Automation<> only when a specific lock state is entered. -/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. -template struct LockStateForwarder { - Automation<> *automation; - void operator()(LockState state) const { - if (state == State) - this->automation->trigger(); - } -}; - -static_assert(sizeof(LockStateForwarder) <= sizeof(void *)); -static_assert(std::is_trivially_copyable_v>); - -} // namespace esphome::lock From 5ad63e3bb628fabf69e6a9a8f9deb74d31120b10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:38:07 -1000 Subject: [PATCH 562/651] [http_request] Only treat a URL as secure when its scheme is https (#20867) --- esphome/components/http_request/http_request_arduino.cpp | 3 ++- esphome/components/http_request/http_request_idf.cpp | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 814d598132e..e24531094da 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -40,7 +40,8 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const uint32_t start = millis(); - bool secure = ESPHOME_strstr_P(url, ESPHOME_PSTR("https:")) != nullptr; + // Only the scheme counts; "https:" later in the URL (a query string) does not make it secure + bool secure = ESPHOME_strncasecmp_P(url, ESPHOME_PSTR("https:"), 6) == 0; container->set_secure(secure); container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 879abb662b7..cc04c5472c0 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -9,6 +9,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE #include "esp_crt_bundle.h" @@ -77,7 +78,8 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch return nullptr; } - bool secure = strstr(url, "https:") != nullptr; + // Only the scheme counts; "https:" later in the URL (a query string) does not make it secure + bool secure = ESPHOME_strncasecmp_P(url, ESPHOME_PSTR("https:"), 6) == 0; esp_http_client_config_t config = {}; From 6580f4f314d1e6734547745f7655b3251f90816c Mon Sep 17 00:00:00 2001 From: embe <31192394+mbrudka@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:43:07 +0200 Subject: [PATCH 563/651] [micro_wake_word] Allow configurable capture buffer duration (#20942) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 12 ++++++ .../micro_wake_word/micro_wake_word.cpp | 4 +- .../micro_wake_word/micro_wake_word.h | 5 +++ .../micro_wake_word/test_init.py | 37 ++++++++++++++++++- .../test-internal.esp32-idf.yaml | 1 + .../micro_wake_word/test.esp32-s3-idf.yaml | 1 + 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index ca65fa1a5e6..6756e93c486 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -48,6 +48,7 @@ CONF_TASK_CORE = "task_core" CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" +CONF_RING_BUFFER_DURATION = "ring_buffer_duration" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" CONF_PREFER_INTERNAL_RAM = "prefer_internal_ram" CONF_PROBABILITY_CUTOFF = "probability_cutoff" @@ -433,6 +434,11 @@ def _validate_task_core(config: ConfigType) -> ConfigType: return config +# The capture buffer holds at least one feature window and at most a second of audio +MIN_RING_BUFFER_DURATION = cv.TimePeriod(milliseconds=120) +MAX_RING_BUFFER_DURATION = cv.TimePeriod(seconds=1) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -455,6 +461,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, cv.Optional(CONF_TASK_CORE, default=-1): cv.int_range(min=-1, max=1), cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, + cv.Optional(CONF_RING_BUFFER_DURATION): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=MIN_RING_BUFFER_DURATION, max=MAX_RING_BUFFER_DURATION), + ), 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." @@ -635,6 +645,8 @@ async def to_code(config): cg.add(var.add_wake_word_model(wake_word_model)) cg.add(var.set_features_step_size(feature_step_size)) + if (ring_buffer_duration := config.get(CONF_RING_BUFFER_DURATION)) is not None: + cg.add(var.set_ring_buffer_duration_ms(ring_buffer_duration.total_milliseconds)) cg.add(var.set_stop_after_detection(config[CONF_STOP_AFTER_DETECTION])) if on_wake_word_detection_config := config.get(CONF_ON_WAKE_WORD_DETECTED): diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 872dd7bd79a..0c9d071e6f3 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -27,8 +27,6 @@ static const ssize_t DETECTION_QUEUE_LENGTH = 5; static const size_t DATA_TIMEOUT_MS = 50; -static const uint32_t RING_BUFFER_DURATION_MS = 120; - #ifdef CONFIG_IDF_TARGET_ESP32P4 // ESP32-P4 PIE-optimized esp-nn kernels (e.g. depthwise_conv_s8_ch1_pie) require // significantly more stack than other variants, causing stack protection faults at 3072. @@ -181,7 +179,7 @@ void MicroWakeWord::inference_task(void *params) { if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { // Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample. const size_t ring_buffer_size = - (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + (stream_info.ms_to_bytes(this_mww->ring_buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); if (temp_ring_buffer == nullptr) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 862d40733bf..5aa87cf1b73 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -24,6 +24,8 @@ namespace esphome::micro_wake_word { +static constexpr uint16_t DEFAULT_RING_BUFFER_DURATION_MS = 120; + enum State { STARTING, DETECTING_WAKE_WORD, @@ -54,6 +56,8 @@ class MicroWakeWord final : public Component void set_features_step_size(uint8_t step_size) { this->features_step_size_ = step_size; } + void set_ring_buffer_duration_ms(uint16_t duration_ms) { this->ring_buffer_duration_ms_ = duration_ms; } + void set_microphone_source(microphone::MicrophoneSource *microphone_source) { this->microphone_source_ = microphone_source; } @@ -126,6 +130,7 @@ class MicroWakeWord final : public Component bool task_stack_in_psram_{false}; uint8_t features_step_size_; + uint16_t ring_buffer_duration_ms_{DEFAULT_RING_BUFFER_DURATION_MS}; // at most a second, see the schema // Audio frontend handles generating spectrogram features struct FrontendConfig frontend_config_; diff --git a/tests/component_tests/micro_wake_word/test_init.py b/tests/component_tests/micro_wake_word/test_init.py index 5e57653585f..0d2e6b87985 100644 --- a/tests/component_tests/micro_wake_word/test_init.py +++ b/tests/component_tests/micro_wake_word/test_init.py @@ -7,8 +7,15 @@ from typing import Any import pytest -from esphome.components.micro_wake_word import LOCAL_SCHEMA +from esphome.components.micro_wake_word import ( + CONF_RING_BUFFER_DURATION, + CONFIG_SCHEMA, + LOCAL_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import PlatformFramework from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable MANIFEST: dict[str, Any] = { "type": "micro", @@ -27,6 +34,34 @@ MANIFEST: dict[str, Any] = { } +def test_ring_buffer_duration_is_absent_by_default( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + assert CONF_RING_BUFFER_DURATION not in CONFIG_SCHEMA({}) + + +@pytest.mark.parametrize( + ("duration", "expected_ms"), + [("120ms", 120), ("400ms", 400), ("800ms", 800), ("1s", 1000)], +) +def test_ring_buffer_duration_is_configurable( + duration: str, expected_ms: int, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({CONF_RING_BUFFER_DURATION: duration}) + assert config[CONF_RING_BUFFER_DURATION].total_milliseconds == expected_ms + + +@pytest.mark.parametrize("duration", ["119ms", "1001ms", "0ms"]) +def test_ring_buffer_duration_schema_rejects_out_of_range( + duration: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_RING_BUFFER_DURATION: duration}) + + def _registered_files() -> list[Path]: """Files components registered for bundling this run.""" data = CORE.data.get("bundle") diff --git a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml index bddab918c2a..0b072cadcc6 100644 --- a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml @@ -7,3 +7,4 @@ packages: micro_wake_word: prefer_internal_ram: true + ring_buffer_duration: 800ms 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 3ed671a764d..f4709dd5502 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -8,3 +8,4 @@ packages: micro_wake_word: task_core: 1 prefer_internal_ram: true + ring_buffer_duration: 400ms From 265754c4672d01a4e3380e580afd1057281d5b8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 17:43:16 -1000 Subject: [PATCH 564/651] [components] Drop get_setup_priority overrides that return the default (#21014) --- esphome/components/bmi270/bmi270.h | 1 - esphome/components/lsm6ds/lsm6ds.h | 1 - esphome/components/motion/motion_component.h | 1 - esphome/components/qmi8658/qmi8658.h | 1 - esphome/components/resampler/speaker/resampler_speaker.h | 1 - esphome/components/router/speaker/router_speaker.h | 2 -- esphome/components/sen6x/sen6x.h | 1 - .../sensor/waveshare_io_ch32v003_sensor.cpp | 2 -- .../waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h | 1 - esphome/components/xpt2046/touchscreen/xpt2046.cpp | 2 -- esphome/components/xpt2046/touchscreen/xpt2046.h | 1 - 11 files changed, 14 deletions(-) diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index a1fefdffe80..da12ea7973c 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -136,7 +136,6 @@ class BMI270Component final : public motion::MotionComponent, public i2c::I2CDev // Lifecycle void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } // Configuration setters void set_accel_range(BMI270AccelRange r) { this->accel_range_ = r; } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index d754b08a252..9b3c442dbf0 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -96,7 +96,6 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev public: void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } // Configuration setters (called from Python codegen) void set_accel_range(LSM6DSAccelRange r) { this->accel_range_ = r; } diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index 2ca920dbe0e..e2674dbdef6 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -65,7 +65,6 @@ class MotionComponent : public PollingComponent { void setup() override; void update() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } void set_matrix(const std::array &m) { memcpy(this->base_matrix_, m.data(), sizeof(this->base_matrix_)); diff --git a/esphome/components/qmi8658/qmi8658.h b/esphome/components/qmi8658/qmi8658.h index ce31a2b7a9f..aea668d7e5a 100644 --- a/esphome/components/qmi8658/qmi8658.h +++ b/esphome/components/qmi8658/qmi8658.h @@ -88,7 +88,6 @@ class QMI8658Component : public motion::MotionComponent, public i2c::I2CDevice { // Lifecycle void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } // Configuration setters void set_accel_range(QMI8658AccelRange r) { this->accel_range_ = r; } diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index 3255bf1fe8c..22ecb9e5207 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -16,7 +16,6 @@ namespace esphome::resampler { class ResamplerSpeaker final : public Component, public speaker::Speaker { public: - float get_setup_priority() const override { return esphome::setup_priority::DATA; } void dump_config() override; void setup() override; void loop() override; diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 2ce4c1a906d..f743d00fcce 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -14,8 +14,6 @@ namespace esphome::router { class Router final : public Component, public speaker::Speaker { public: - float get_setup_priority() const override { return setup_priority::DATA; } - void setup() override; void loop() override; void dump_config() override; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index e73f93f5a4c..234ad75cc40 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -82,7 +82,6 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S SUB_SENSOR(hcho) public: - float get_setup_priority() const override { return setup_priority::DATA; } void setup() override; void dump_config() override; void update() override; diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp index 71cb9ba79fd..42623319b60 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp @@ -6,8 +6,6 @@ namespace esphome::waveshare_io_ch32v003 { ESPHOME_LOG_TAG(TAG, "waveshare_io_ch32v003.sensor"); -float WaveshareIOCH32V003Sensor::get_setup_priority() const { return setup_priority::DATA; } - void WaveshareIOCH32V003Sensor::dump_config() { ESP_LOGCONFIG(TAG, "WaveshareIOCH32V003Sensor:\n" diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h index 01beab5137b..2e7af7b07dc 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h @@ -19,7 +19,6 @@ class WaveshareIOCH32V003Sensor : public sensor::Sensor, void update() override; void dump_config() override; - float get_setup_priority() const override; float sample() override; protected: diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index afb8f6cd5c8..903f8d6859a 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -82,8 +82,6 @@ void XPT2046Component::dump_config() { LOG_UPDATE_INTERVAL(this); } -// float XPT2046Component::get_setup_priority() const { return setup_priority::DATA; } - int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t value3) { int16_t delta_a, delta_b, delta_c; int16_t reta = 0; diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.h b/esphome/components/xpt2046/touchscreen/xpt2046.h index 8fe9b7cc436..0b74fe2c203 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.h +++ b/esphome/components/xpt2046/touchscreen/xpt2046.h @@ -22,7 +22,6 @@ class XPT2046Component final : public Touchscreen, void setup() override; void dump_config() override; - // float get_setup_priority() const override; protected: static int16_t best_two_avg(int16_t value1, int16_t value2, int16_t value3); From 2fe1333cba6d8efc8219153b992f238076d2bc4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 18:34:38 -1000 Subject: [PATCH 565/651] [display] Add add_writer for display lambdas (#21027) --- esphome/components/display/__init__.py | 15 ++++++++ tests/unit_tests/components/test_display.py | 41 +++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/unit_tests/components/test_display.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 22e3d9320b0..be8586b3b25 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -27,6 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.final_validate import full_config +from esphome.types import ConfigType DOMAIN = "display" IS_PLATFORM_COMPONENT = True @@ -291,6 +292,20 @@ async def register_display(var, config): await setup_display_core_(var, config) +async def add_writer( + var: cg.MockObj, config: ConfigType, ref: cg.MockObj = DisplayRef +) -> None: + """Pass the configured lambda, if any, to var.set_writer. + + ref is the type of `it`; drivers with their own drawing class pass theirs. + """ + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(ref, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) + + @automation.register_action( "display.page.show", DisplayPageShowAction, diff --git a/tests/unit_tests/components/test_display.py b/tests/unit_tests/components/test_display.py new file mode 100644 index 00000000000..a98dd80ec0e --- /dev/null +++ b/tests/unit_tests/components/test_display.py @@ -0,0 +1,41 @@ +"""Tests for display.add_writer.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from esphome import codegen as cg +from esphome.components import display +from esphome.const import CONF_LAMBDA + + +@pytest.mark.parametrize( + ("kwargs", "ref"), + [({}, display.DisplayRef), ({"ref": "MyDisplayRef"}, "MyDisplayRef")], +) +@pytest.mark.asyncio +async def test_add_writer_sets_the_lambda(kwargs: dict, ref: object) -> None: + var = MagicMock(name="var") + lambda_config = MagicMock(name="lambda") + with ( + patch.object(cg, "process_lambda", AsyncMock(return_value="fn")) as process, + patch.object(cg, "add") as add, + ): + await display.add_writer(var, {CONF_LAMBDA: lambda_config}, **kwargs) + + process.assert_awaited_once_with(lambda_config, [(ref, "it")], return_type=cg.void) + var.set_writer.assert_called_once_with("fn") + add.assert_called_once_with(var.set_writer.return_value) + + +@pytest.mark.asyncio +async def test_add_writer_without_lambda_does_nothing() -> None: + var = MagicMock(name="var") + with ( + patch.object(cg, "process_lambda", AsyncMock()) as process, + patch.object(cg, "add") as add, + ): + await display.add_writer(var, {}) + + process.assert_not_awaited() + add.assert_not_called() From 0bb78186f2e4a08b608e9e8587f40a6415a9afb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 18:34:38 -1000 Subject: [PATCH 566/651] [components] Set display lambdas with display.add_writer (#21028) --- esphome/components/addressable_light/display.py | 6 +----- esphome/components/hub75/display.py | 7 +------ esphome/components/inkplate/display.py | 6 +----- esphome/components/it8951/display.py | 6 +----- esphome/components/lcd_gpio/display.py | 11 ++--------- esphome/components/lcd_pcf8574/display.py | 12 +++--------- esphome/components/max7219/display.py | 8 ++------ esphome/components/max7219digit/display.py | 7 +------ esphome/components/mipi_dsi/display.py | 7 +------ esphome/components/mipi_rgb/display.py | 7 +------ esphome/components/pcd8544/display.py | 6 +----- esphome/components/pixoo/display.py | 8 ++------ .../pvvx_mithermometer/display/__init__.py | 7 +------ esphome/components/sdl/display.py | 7 +------ esphome/components/sh1122_base/__init__.py | 8 ++------ esphome/components/snapshot/display/__init__.py | 7 +------ esphome/components/ssd1306_base/__init__.py | 7 +------ esphome/components/ssd1322_base/__init__.py | 14 ++------------ esphome/components/ssd1325_base/__init__.py | 14 ++------------ esphome/components/ssd1327_base/__init__.py | 8 ++------ esphome/components/ssd1331_base/__init__.py | 8 ++------ esphome/components/ssd1351_base/__init__.py | 8 ++------ esphome/components/st7567_base/__init__.py | 7 +------ esphome/components/st7701s/display.py | 7 +------ esphome/components/st7920/display.py | 8 ++------ esphome/components/tm1621/display.py | 7 +------ esphome/components/tm1637/display.py | 7 +------ esphome/components/tm1638/display.py | 8 +------- 28 files changed, 40 insertions(+), 183 deletions(-) diff --git a/esphome/components/addressable_light/display.py b/esphome/components/addressable_light/display.py index 1db01b40f90..3315d01f51a 100644 --- a/esphome/components/addressable_light/display.py +++ b/esphome/components/addressable_light/display.py @@ -56,8 +56,4 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_pixel_mapper(pixel_mapper_template_)) - if lambda_config := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 408126e1ad1..b6aa35b8a7b 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_CLK_PIN, CONF_GAMMA_CORRECT, CONF_ID, - CONF_LAMBDA, CONF_OE_PIN, CONF_ROTATION, CONF_UPDATE_INTERVAL, @@ -603,11 +602,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], hub75_config) await display.register_display(var, config) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) automation.register_apply_action( diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 93eecbbe919..c7600d39bbc 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -164,11 +164,7 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await i2c.register_i2c_device(var, config) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) cg.add(var.set_greyscale(config[CONF_GREYSCALE])) if transform := config.get(CONF_TRANSFORM): diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index d9c80ebf43d..28636e5e26e 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -366,11 +366,7 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=False) - if lambda_config := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) if reset_pin := config.get(CONF_RESET_PIN): cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) if busy_pin := config.get(CONF_BUSY_PIN): diff --git a/esphome/components/lcd_gpio/display.py b/esphome/components/lcd_gpio/display.py index 10e21c87d19..0e99b471b3c 100644 --- a/esphome/components/lcd_gpio/display.py +++ b/esphome/components/lcd_gpio/display.py @@ -1,12 +1,11 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import lcd_base +from esphome.components import display, lcd_base import esphome.config_validation as cv from esphome.const import ( CONF_DATA_PINS, CONF_ENABLE_PIN, CONF_ID, - CONF_LAMBDA, CONF_RS_PIN, CONF_RW_PIN, ) @@ -54,10 +53,4 @@ async def to_code(config: ConfigType) -> None: rw = await cg.gpio_pin_expression(config[CONF_RW_PIN]) cg.add(var.set_rw_pin(rw)) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], - [(GPIOLCDDisplay.operator("ref"), "it")], - return_type=cg.void, - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, GPIOLCDDisplay.operator("ref")) diff --git a/esphome/components/lcd_pcf8574/display.py b/esphome/components/lcd_pcf8574/display.py index 85a79e99cec..d97ef5b5699 100644 --- a/esphome/components/lcd_pcf8574/display.py +++ b/esphome/components/lcd_pcf8574/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg -from esphome.components import i2c, lcd_base +from esphome.components import display, i2c, lcd_base import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA +from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -24,10 +24,4 @@ async def to_code(config: ConfigType) -> None: await lcd_base.setup_lcd_display(var, config) await i2c.register_i2c_device(var, config) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], - [(PCF8574LCDDisplay.operator("ref"), "it")], - return_type=cg.void, - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, PCF8574LCDDisplay.operator("ref")) diff --git a/esphome/components/max7219/display.py b/esphome/components/max7219/display.py index b21f66b553d..76943c65280 100644 --- a/esphome/components/max7219/display.py +++ b/esphome/components/max7219/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTENSITY, CONF_LAMBDA, CONF_NUM_CHIPS +from esphome.const import CONF_ID, CONF_INTENSITY, CONF_NUM_CHIPS from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -36,8 +36,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_intensity(config[CONF_INTENSITY])) cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(MAX7219ComponentRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, MAX7219ComponentRef) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index 2271bc09f48..1a982946f68 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_FLIP_X, CONF_ID, CONF_INTENSITY, - CONF_LAMBDA, CONF_NUM_CHIPS, CONF_STATE, ) @@ -97,11 +96,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE])) cg.add(var.set_flip_x(config[CONF_FLIP_X])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(MAX7219ComponentRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, MAX7219ComponentRef) MAX7219_OFF_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 97af7a33243..6dd96d21bcb 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -45,7 +45,6 @@ from esphome.const import ( CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -224,8 +223,4 @@ async def to_code(config: ConfigType) -> None: if model.rotation_as_transform(config): config[CONF_ROTATION] = 0 await display.register_display(var, config) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index e973d78ae4d..4c87bf19987 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -65,7 +65,6 @@ from esphome.const import ( CONF_IGNORE_STRAPPING_WARNING, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MODEL, CONF_NUMBER, CONF_RED, @@ -340,8 +339,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_vsync_pin(pin)) await display.register_display(var, config) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/pcd8544/display.py b/esphome/components/pcd8544/display.py index 42cef674884..c63d18b3cc4 100644 --- a/esphome/components/pcd8544/display.py +++ b/esphome/components/pcd8544/display.py @@ -54,8 +54,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_contrast(config[CONF_CONTRAST])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/pixoo/display.py b/esphome/components/pixoo/display.py index 764f06d603a..3aaf378f727 100644 --- a/esphome/components/pixoo/display.py +++ b/esphome/components/pixoo/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_MODEL +from esphome.const import CONF_ID, CONF_MODEL from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -36,8 +36,4 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) - 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_)) + await display.add_writer(var, config) diff --git a/esphome/components/pvvx_mithermometer/display/__init__.py b/esphome/components/pvvx_mithermometer/display/__init__.py index 436f83630c1..1b8c0b5be30 100644 --- a/esphome/components/pvvx_mithermometer/display/__init__.py +++ b/esphome/components/pvvx_mithermometer/display/__init__.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, CONF_DISCONNECT_DELAY, CONF_ID, - CONF_LAMBDA, CONF_TIME_ID, CONF_VALIDITY_PERIOD, ) @@ -49,8 +48,4 @@ async def to_code(config: ConfigType) -> None: time_ = await cg.get_variable(config[CONF_TIME_ID]) cg.add(var.set_time(time_)) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(PVVXDisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, PVVXDisplayRef) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 77b0001c556..58dff4430eb 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, - CONF_LAMBDA, CONF_POSITION, CONF_WIDTH, CONF_X, @@ -189,8 +188,4 @@ async def to_code(config: ConfigType) -> None: else: cg.add(var.set_position(position[CONF_X], position[CONF_Y])) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py index 4f2b026a5ed..e8828685368 100644 --- a/esphome/components/sh1122_base/__init__.py +++ b/esphome/components/sh1122_base/__init__.py @@ -2,7 +2,7 @@ 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.const import CONF_BRIGHTNESS, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -36,8 +36,4 @@ async def setup_sh1122(var: MockObj, config: ConfigType) -> 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_)) + await display.add_writer(var, config) diff --git a/esphome/components/snapshot/display/__init__.py b/esphome/components/snapshot/display/__init__.py index 68429f164bd..eaecc869197 100644 --- a/esphome/components/snapshot/display/__init__.py +++ b/esphome/components/snapshot/display/__init__.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, - CONF_LAMBDA, CONF_WIDTH, PLATFORM_HOST, ) @@ -54,8 +53,4 @@ async def to_code(config: ConfigType) -> None: (width, height) = dimensions cg.add(var.set_dimensions(width, height)) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index 9577f9d4f7d..b51fb6f8f22 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_FLIP_X, CONF_FLIP_Y, CONF_INVERT, - CONF_LAMBDA, CONF_MODEL, CONF_OFFSET_X, CONF_OFFSET_Y, @@ -100,8 +99,4 @@ async def setup_ssd1306(var: MockObj, config: ConfigType) -> None: cg.add(var.init_offset_y(config[CONF_OFFSET_Y])) if CONF_INVERT in config: cg.add(var.init_invert(config[CONF_INVERT])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1322_base/__init__.py b/esphome/components/ssd1322_base/__init__.py index d66ae8d5c97..72f8bd27235 100644 --- a/esphome/components/ssd1322_base/__init__.py +++ b/esphome/components/ssd1322_base/__init__.py @@ -2,13 +2,7 @@ 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_EXTERNAL_VCC, - CONF_LAMBDA, - CONF_MODEL, - CONF_RESET_PIN, -) +from esphome.const import CONF_BRIGHTNESS, CONF_EXTERNAL_VCC, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -46,8 +40,4 @@ async def setup_ssd1322(var: MockObj, config: ConfigType) -> None: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: cg.add(var.set_external_vcc(config[CONF_EXTERNAL_VCC])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1325_base/__init__.py b/esphome/components/ssd1325_base/__init__.py index fbbf329d53b..e148808d15f 100644 --- a/esphome/components/ssd1325_base/__init__.py +++ b/esphome/components/ssd1325_base/__init__.py @@ -2,13 +2,7 @@ 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_EXTERNAL_VCC, - CONF_LAMBDA, - CONF_MODEL, - CONF_RESET_PIN, -) +from esphome.const import CONF_BRIGHTNESS, CONF_EXTERNAL_VCC, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -50,8 +44,4 @@ async def setup_ssd1325(var: MockObj, config: ConfigType) -> None: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: cg.add(var.set_external_vcc(config[CONF_EXTERNAL_VCC])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1327_base/__init__.py b/esphome/components/ssd1327_base/__init__.py index 2c9c0faf3b2..93e4c75d3b1 100644 --- a/esphome/components/ssd1327_base/__init__.py +++ b/esphome/components/ssd1327_base/__init__.py @@ -2,7 +2,7 @@ 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.const import CONF_BRIGHTNESS, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -37,8 +37,4 @@ async def setup_ssd1327(var: MockObj, config: ConfigType) -> None: cg.add(var.set_reset_pin(reset)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1331_base/__init__.py b/esphome/components/ssd1331_base/__init__.py index 2345acdf650..5f71570b6ae 100644 --- a/esphome/components/ssd1331_base/__init__.py +++ b/esphome/components/ssd1331_base/__init__.py @@ -2,7 +2,7 @@ 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_RESET_PIN +from esphome.const import CONF_BRIGHTNESS, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -28,8 +28,4 @@ async def setup_ssd1331(var: MockObj, config: ConfigType) -> None: cg.add(var.set_reset_pin(reset)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1351_base/__init__.py b/esphome/components/ssd1351_base/__init__.py index f693004d633..2c44cd18403 100644 --- a/esphome/components/ssd1351_base/__init__.py +++ b/esphome/components/ssd1351_base/__init__.py @@ -2,7 +2,7 @@ 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.const import CONF_BRIGHTNESS, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -38,8 +38,4 @@ async def setup_ssd1351(var: MockObj, config: ConfigType) -> None: cg.add(var.set_reset_pin(reset)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/st7567_base/__init__.py b/esphome/components/st7567_base/__init__.py index 8fdc0a7f7af..a045cf34ccd 100644 --- a/esphome/components/st7567_base/__init__.py +++ b/esphome/components/st7567_base/__init__.py @@ -4,7 +4,6 @@ from esphome.components import display import esphome.config_validation as cv from esphome.const import ( CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_RESET_PIN, @@ -51,8 +50,4 @@ async def setup_st7567(var: MockObj, config: ConfigType) -> None: cg.add(var.init_mirror_x(transform[CONF_MIRROR_X])) cg.add(var.init_mirror_y(transform[CONF_MIRROR_Y])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 2805837ee01..79430f14176 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -30,7 +30,6 @@ from esphome.const import ( CONF_IGNORE_STRAPPING_WARNING, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_NUMBER, @@ -235,11 +234,7 @@ async def to_code(config: ConfigType) -> None: (width, height) = dimensions cg.add(var.set_dimensions(width, height)) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) pin = await cg.gpio_pin_expression(config[CONF_DE_PIN]) cg.add(var.set_de_pin(pin)) diff --git a/esphome/components/st7920/display.py b/esphome/components/st7920/display.py index 853e38a2795..ce4fe5d364c 100644 --- a/esphome/components/st7920/display.py +++ b/esphome/components/st7920/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv -from esphome.const import CONF_HEIGHT, CONF_ID, CONF_LAMBDA, CONF_WIDTH +from esphome.const import CONF_HEIGHT, CONF_ID, CONF_WIDTH from esphome.types import ConfigType AUTO_LOAD = ["display"] @@ -31,11 +31,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await spi.register_spi_device(var, config, write_only=True) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(ST7920Ref, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, ST7920Ref) cg.add(var.set_width(config[CONF_WIDTH])) cg.add(var.set_height(config[CONF_HEIGHT])) diff --git a/esphome/components/tm1621/display.py b/esphome/components/tm1621/display.py index e8a40507d84..4a83d363fe4 100644 --- a/esphome/components/tm1621/display.py +++ b/esphome/components/tm1621/display.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CS_PIN, CONF_DATA_PIN, CONF_ID, - CONF_LAMBDA, CONF_READ_PIN, CONF_WRITE_PIN, ) @@ -40,8 +39,4 @@ async def to_code(config: ConfigType) -> None: write = await cg.gpio_pin_expression(config[CONF_WRITE_PIN]) cg.add(var.set_write_pin(write)) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(TM1621DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, TM1621DisplayRef) diff --git a/esphome/components/tm1637/display.py b/esphome/components/tm1637/display.py index ec4d32561b8..ebdd9449d65 100644 --- a/esphome/components/tm1637/display.py +++ b/esphome/components/tm1637/display.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_ID, CONF_INTENSITY, CONF_INVERTED, - CONF_LAMBDA, CONF_LENGTH, ) from esphome.types import ConfigType @@ -46,8 +45,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_inverted(config[CONF_INVERTED])) cg.add(var.set_length(config[CONF_LENGTH])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(TM1637DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, TM1637DisplayRef) diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index d6491129c66..74ee8b3293d 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_DIO_PIN, CONF_ID, CONF_INTENSITY, - CONF_LAMBDA, CONF_STB_PIN, ) from esphome.types import ConfigType @@ -47,9 +46,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_intensity(config[CONF_INTENSITY])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(TM1638ComponentRef, "it")], return_type=cg.void - ) - - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, TM1638ComponentRef) From 032a2b7200a8895f9821cc6bbbfaa91f18b9ba78 Mon Sep 17 00:00:00 2001 From: Michele Porelli Date: Sat, 17 Oct 2026 23:58:40 -0700 Subject: [PATCH 567/651] [core] Let entities choose flash or RTC storage for their preferences (#21017) Co-authored-by: J. Nick Koston --- esphome/core/entity_base.cpp | 13 +++- esphome/core/entity_base.h | 12 +++- .../host/entity_preference_test.cpp | 71 +++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 tests/components/host/entity_preference_test.cpp diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index cbb14a37302..51cdb19eb42 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -136,7 +136,7 @@ StringRef EntityBase::get_object_id_to(std::span buf) c return StringRef(buf.data(), len); } -ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { +uint32_t EntityBase::entity_preference_key_(uint32_t version) { // The key hashes the sanitized object_id, so multiple entity names can collide on one // key and overwrite each other's stored preferences ("Living Room" and "living_room", // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name @@ -144,9 +144,16 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - uint32_t key = this->get_preference_hash() ^ version; + return this->get_preference_hash() ^ version; #pragma GCC diagnostic pop - return global_preferences->make_preference(size, key); +} + +ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { + return global_preferences->make_preference(size, this->entity_preference_key_(version)); +} + +ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version, bool in_flash) { + return global_preferences->make_preference(size, this->entity_preference_key_(version), in_flash); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8796e9f067a..3250ad74730 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -190,6 +190,12 @@ class EntityBase { return this->make_entity_preference_(sizeof(T), version); } + /// As make_entity_preference(), stored in flash or RTC memory where the backend supports the choice. + template ESPPreferenceObject make_entity_preference(uint32_t version, bool in_flash) { + static_assert(std::is_trivially_copyable::value, "T must be trivially copyable"); + return this->make_entity_preference_(sizeof(T), version, in_flash); + } + protected: friend void ::setup(); friend void ::original_setup(); @@ -206,9 +212,11 @@ class EntityBase { void set_device_(Device *device) { this->device_ = device; } #endif - /// Non-template helper for make_entity_preference() to avoid code bloat. - /// When the preference hash algorithm changes, migration logic goes here. + /// Non-template helpers for make_entity_preference() to avoid code bloat. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); + ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version, bool in_flash); + /// Key for both helpers; preference hash migration goes here. + uint32_t entity_preference_key_(uint32_t version); void calc_object_id_(); diff --git a/tests/components/host/entity_preference_test.cpp b/tests/components/host/entity_preference_test.cpp new file mode 100644 index 00000000000..3ee6f21132d --- /dev/null +++ b/tests/components/host/entity_preference_test.cpp @@ -0,0 +1,71 @@ +#ifdef USE_HOST +#include +#include +#include +#include +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" +#include "esphome/core/entity_base.h" + +namespace esphome::host::testing { +namespace fs = std::filesystem; + +/// Same object_id_hash_ means same preference key. +class TestEntity : public EntityBase { + public: + void set_test_id(uint32_t hash) { this->object_id_hash_ = hash; } +}; + +class EntityPreferenceTest : public ::testing::Test { + protected: + void SetUp() override { + this->temp_dir_ = fs::temp_directory_path() / "esphome_entity_preference_test"; + fs::remove_all(this->temp_dir_); + fs::create_directories(this->temp_dir_); + setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1); + App.pre_setup("test_entity_pref", strlen("test_entity_pref"), "", 0); + setup_preferences(); + // Clear the in-memory store so values do not leak between tests. + host_preferences->reset(); + } + + void TearDown() override { + unsetenv("ESPHOME_PREFDIR"); + fs::remove_all(this->temp_dir_); + } + + fs::path temp_dir_; +}; + +// in_flash must not change the key; host ignores the storage type, so only the key is checked. +TEST_F(EntityPreferenceTest, InFlashOverloadKeepsTheEntityKey) { + TestEntity entity; + entity.set_test_id(0x6001); + for (const bool in_flash : {false, true}) { + const uint32_t saved = in_flash ? 0xF1A5 : 0x0C7C; + auto with_storage = entity.make_entity_preference(7, in_flash); + ASSERT_TRUE(with_storage.save(&saved)); + global_preferences->sync(); + + uint32_t loaded = 0; + auto without_storage = entity.make_entity_preference(7); + ASSERT_TRUE(without_storage.load(&loaded)) << "in_flash=" << in_flash; + EXPECT_EQ(loaded, saved) << "in_flash=" << in_flash; + } +} + +TEST_F(EntityPreferenceTest, InFlashOverloadStillAppliesTheVersion) { + TestEntity entity; + entity.set_test_id(0x6002); + const uint32_t saved = 42; + auto v1 = entity.make_entity_preference(1, true); + ASSERT_TRUE(v1.save(&saved)); + global_preferences->sync(); + + uint32_t loaded = 0; + auto v2 = entity.make_entity_preference(2, true); + EXPECT_FALSE(v2.load(&loaded)) << "a different version must give a different key"; +} + +} // namespace esphome::host::testing +#endif // USE_HOST From 54a28b3e3b55bde5337a302695942845d1f6f10c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:37:48 -1000 Subject: [PATCH 568/651] [bl0906] Read stale receive bytes instead of spinning on flush() (#21035) --- esphome/components/bl0906/bl0906.cpp | 4 +- .../fixtures/uart_mock_bl0906_stray_rx.yaml | 64 +++++++++++++++++++ .../test_uart_mock_bl0906_stray_rx.py | 57 +++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml create mode 100644 tests/integration/test_uart_mock_bl0906_stray_rx.py diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 38edb6ed919..35f663ffa0c 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,7 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) void BL0906::loop() { while (this->available()) - this->flush(); + this->read(); if (this->current_stage_ == STAGE_IDLE) { // Woken up between cycles to drain the action queue. Go back to sleep. @@ -92,7 +92,7 @@ void BL0906::advance_stage_() { void BL0906::setup() { while (this->available()) - this->flush(); + this->read(); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); // Calibration (1: register address; 2: value before calibration; 3: value after calibration) this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 diff --git a/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml new file mode 100644 index 00000000000..d5f8a7837ce --- /dev/null +++ b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml @@ -0,0 +1,64 @@ +esphome: + name: uart-mock-bl0906-stray-rx + +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 DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +# Answers register reads; the first temperature answer has one stray trailing byte +uart_mock: + id: mock_uart + baud_rate: 19200 + on_tx: + - then: + - lambda: |- + // Command and register address arrive as separate one-byte writes + static bool read_pending = false; + static bool stray_sent = false; + if (data.size() != 1) + return; + if (!read_pending) { + read_pending = data[0] == 0x35; + return; + } + read_pending = false; + const uint8_t address = data[0]; + uint32_t raw; + switch (address) { + case 0x5E: // temperature: (300 - 64) * 12.5 / 59 - 40 = 10 C + raw = 300; + break; + case 0x16: // voltage + raw = 0x2A1B3C; + break; + default: + return; + } + const uint8_t l = raw & 0xFF, m = (raw >> 8) & 0xFF, h = (raw >> 16) & 0xFF; + std::vector reply{l, m, h, static_cast((address + l + m + h) ^ 0xFF)}; + if (address == 0x5E && !stray_sent) { + stray_sent = true; + reply.push_back(0xAA); + } + id(mock_uart).inject_to_rx_buffer(reply); + +sensor: + - platform: bl0906 + uart_id: mock_uart + update_interval: 1s + temperature: + name: Temperature + voltage: + name: Voltage diff --git a/tests/integration/test_uart_mock_bl0906_stray_rx.py b/tests/integration/test_uart_mock_bl0906_stray_rx.py new file mode 100644 index 00000000000..5f8a635c7f5 --- /dev/null +++ b/tests/integration/test_uart_mock_bl0906_stray_rx.py @@ -0,0 +1,57 @@ +"""A BL0906 that leaves a stray byte in the receive buffer keeps reading.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import EntityState, SensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_bl0906_stray_rx( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stray received byte does not stop the read cycle.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + lines: list[str] = [] + states: dict[int, EntityState] = {} + + async with ( + run_compiled(yaml_config, line_callback=lines.append), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + temperature = require_entity(entities, "temperature", SensorInfo) + voltage = require_entity(entities, "voltage", SensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity: SensorInfo) -> float | None: + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + try: + async with asyncio.timeout(10.0): + while state_of(voltage) is None: + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"No voltage reading; log: {lines[-20:]}") + + assert any("RX inject 5 bytes" in x for x in lines), "stray byte not sent" + assert state_of(temperature) == pytest.approx(10.0, abs=0.01) + uref = 1.097 * 100000 / (13162 * 100 * 1000) + assert state_of(voltage) == pytest.approx(0x2A1B3C * uref, rel=1e-5) + assert not any("Junk on wire" in x for x in lines) From 0922df9bb3ee60c0c698dd8f81980d67087ed7a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:39:02 -1000 Subject: [PATCH 569/651] [uart] Add discard_available to drop stale receive bytes (#21029) --- esphome/components/uart/uart.cpp | 7 ++++ esphome/components/uart/uart.h | 3 ++ tests/components/uart/uart_read_available.cpp | 38 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 56b8e6401e8..1c545af8956 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -33,6 +33,13 @@ void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UART } } +void UARTDevice::discard_available() { + UARTComponent *parent = this->parent_; + uint8_t byte; + while (parent->available() && parent->read_byte(&byte)) { + } +} + const LogString *parity_to_str(UARTParityOptions parity) { return UARTParityStrings::get_log_str(static_cast(parity), UARTParityStrings::LAST_INDEX); } diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index a0840aa26a7..e8f98304bc9 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -90,6 +90,9 @@ class UARTDevice { count); } + /// Read and drop every byte waiting in the receive buffer. + void discard_available(); + UARTFlushResult flush() { return this->parent_->flush(); } // Compat APIs diff --git a/tests/components/uart/uart_read_available.cpp b/tests/components/uart/uart_read_available.cpp index 10c3e7fa0ef..6adc0b475df 100644 --- a/tests/components/uart/uart_read_available.cpp +++ b/tests/components/uart/uart_read_available.cpp @@ -141,4 +141,42 @@ TEST(UARTReadAvailableTest, ChunksWithCountStopEarly) { EXPECT_EQ(uart.remaining(), 14u); } +TEST(UARTReadAvailableTest, DiscardDropsEveryWaitingByte) { + ::testing::NiceMock uart(make_bytes(5)); + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_EQ(uart.remaining(), 0u); +} + +TEST(UARTReadAvailableTest, DiscardWithNothingAvailableDoesNotRead) { + ::testing::NiceMock uart(std::vector{}); + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_TRUE(uart.read_sizes.empty()); +} + +TEST(UARTReadAvailableTest, DiscardDropsBytesThatArriveWhileDraining) { + ::testing::NiceMock uart(std::vector{1, 2}); + bool appended = false; + ON_CALL(uart, available()).WillByDefault(Invoke([&]() -> size_t { + if (!appended && uart.read_sizes.size() == 1) { + uart.append({9, 9}); + appended = true; + } + return uart.remaining(); + })); + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_TRUE(appended); + EXPECT_EQ(uart.remaining(), 0u); +} + +TEST(UARTReadAvailableTest, DiscardStopsOnReadFailure) { + ::testing::NiceMock uart(make_bytes(5)); + uart.fail_after_reads = 2; + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_EQ(uart.read_sizes.size(), 3u); +} + } // namespace esphome::uart::testing From 3665bcc4e37d3fe43c34ebafa278d743f597449b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:40:35 -1000 Subject: [PATCH 570/651] [ble_device_base][ble_client][modbus] Add new_ble_device_component, new_ble_node_component and new_modbus_client_component (#21025) --- esphome/components/ble_client/__init__.py | 10 +++++++++ .../components/ble_device_base/__init__.py | 10 ++++++++- esphome/components/modbus/__init__.py | 9 +++++++- .../components/test_new_bus_component.py | 22 +++++++++++++++++-- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index b783ef1c153..64dd02c4dc0 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Coroutine +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -171,6 +174,13 @@ async def register_ble_node(var, config): cg.add(parent.register_ble_node(var)) +def new_ble_node_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Construct, register and attach to the BLE client; args go to the constructor.""" + return cg.new_bus_component(register_ble_node, config, *args) + + BLE_WRITE_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(BLEClient), diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index e8283457c6c..9f089034143 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -24,8 +24,9 @@ AES-CCM decryption for encrypted advertisements is provided portably in ble_aes_ccm.h. """ -from collections.abc import Callable +from collections.abc import Callable, Coroutine import re +from typing import Any import esphome.codegen as cg from esphome.components.const import CONF_WINDOW @@ -182,6 +183,13 @@ async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj return var +def new_ble_device_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Construct, register and attach to the BLE hub; args go to the constructor.""" + return cg.new_bus_component(register_ble_device, config, *args) + + # ---- shared validation / codegen helpers (platform-neutral) ---- diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 4e4d7fe1608..2c3aba950fe 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import logging from typing import Any, Literal, NamedTuple @@ -390,6 +390,13 @@ async def register_modbus_client_device(var: MockObj, config: ConfigType) -> Non cg.add(var.set_address(config[CONF_ADDRESS])) +def new_modbus_client_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, MockObj]: + """Construct, register and attach to the modbus bus; args go to the constructor.""" + return cg.new_bus_component(register_modbus_client_device, config, *args) + + async def register_modbus_server_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/tests/unit_tests/components/test_new_bus_component.py b/tests/unit_tests/components/test_new_bus_component.py index 54f947d6257..2fa5c67b24e 100644 --- a/tests/unit_tests/components/test_new_bus_component.py +++ b/tests/unit_tests/components/test_new_bus_component.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome import cpp_helpers -from esphome.components import i2c, spi, uart +from esphome.components import ble_client, ble_device_base, i2c, modbus, spi, uart from esphome.const import CONF_ID @@ -18,6 +18,17 @@ from esphome.const import CONF_ID (i2c.new_i2c_component, i2c, "register_i2c_device"), (uart.new_uart_component, uart, "register_uart_device"), (spi.new_spi_component, spi, "register_spi_device"), + ( + ble_device_base.new_ble_device_component, + ble_device_base, + "register_ble_device", + ), + (ble_client.new_ble_node_component, ble_client, "register_ble_node"), + ( + modbus.new_modbus_client_component, + modbus, + "register_modbus_client_device", + ), ], ) @pytest.mark.asyncio @@ -47,7 +58,14 @@ async def test_creates_registers_and_attaches_in_order( @pytest.mark.parametrize( "helper", - [i2c.new_i2c_component, uart.new_uart_component, spi.new_spi_component], + [ + i2c.new_i2c_component, + uart.new_uart_component, + spi.new_spi_component, + ble_device_base.new_ble_device_component, + ble_client.new_ble_node_component, + modbus.new_modbus_client_component, + ], ) def test_wrappers_are_plain_functions(helper: Callable[..., Any]) -> None: # An async wrapper would stay on the stack and become the component source. From 270d9894811b31957c0a7f689ba09393dca83290 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:41:33 -1000 Subject: [PATCH 571/651] [core] Add cg.add_pin and cg.add_variable for optional pins and ids (#21018) --- AGENTS.md | 4 +++ esphome/codegen.py | 2 ++ esphome/cpp_helpers.py | 22 ++++++++++++++- tests/unit_tests/test_cpp_helpers.py | 40 +++++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5581a255dba..b2d3b442bf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,6 +398,10 @@ file does, and it is the authority when they disagree. The most useful starting await buttons(CONF_RESTART, var.set_restart_button) ``` + - **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])` + and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass + `config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`. + * **Automations (Triggers, Actions, Conditions):** Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). diff --git a/esphome/codegen.py b/esphome/codegen.py index 64f2ca9f04d..cf569b7bff7 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -53,6 +53,8 @@ from esphome.cpp_generator import ( # noqa: F401 with_local_variable, ) from esphome.cpp_helpers import ( # noqa: F401 + add_pin, + add_variable, build_registry_entry, build_registry_list, extract_registry_entry_config, diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index c744b09728e..ba26d14bc98 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -24,7 +24,7 @@ from esphome.cpp_generator import ( ) from esphome.cpp_types import App from esphome.helpers import cpp_string_escape -from esphome.types import ConfigFragmentType, ConfigType +from esphome.types import ConfigFragmentType, ConfigType, Expression from esphome.util import Registry, RegistryEntry _LOGGER = logging.getLogger(__name__) @@ -231,6 +231,26 @@ async def gpio_pin_expression(conf): return await coroutine(pins.PIN_SCHEMA_REGISTRY[CORE.target_platform][0])(conf) +async def add_pin( + setter: Callable[[MockObj], Expression], pin_config: ConfigType | None +) -> MockObj | None: + """Pass the pin built from ``pin_config`` to ``setter`` and return it; skip None.""" + if (pin := await gpio_pin_expression(pin_config)) is not None: + add(setter(pin)) + return pin + + +async def add_variable( + setter: Callable[[MockObj], Expression], id_: ID | None +) -> MockObj | None: + """Pass the variable declared by ``id_`` to ``setter`` and return it; skip None.""" + if id_ is None: + return None + var = await get_variable(id_) + add(setter(var)) + return var + + def set_setup_priority(var, priority: float) -> None: """Emit a setup-priority override for the given component. diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 7f56faddfbc..4c3a425ae3e 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,9 +1,11 @@ +from collections.abc import Awaitable, Callable import importlib.util import logging from pathlib import Path import sys from types import ModuleType -from unittest.mock import Mock +from typing import Any +from unittest.mock import AsyncMock, Mock import pytest @@ -301,3 +303,39 @@ async def test_new_bus_component_source_is_the_caller( result = await dev.to_code(bus.new_component, {const.CONF_ID: "foo.bar"}) assert result is var source.assert_called_once_with(expected) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("helper", [ch.add_pin, ch.add_variable]) +async def test_add_helpers_skip_none( + monkeypatch: pytest.MonkeyPatch, helper: Callable[..., Awaitable[Any]] +) -> None: + add_mock = Mock() + monkeypatch.setattr(ch, "add", add_mock) + setter = Mock() + + assert await helper(setter, None) is None + setter.assert_not_called() + add_mock.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("helper", "lookup"), + [("add_pin", "gpio_pin_expression"), ("add_variable", "get_variable")], +) +async def test_add_helpers_pass_value_to_setter( + monkeypatch: pytest.MonkeyPatch, helper: str, lookup: str +) -> None: + value = Mock() + lookup_mock = AsyncMock(return_value=value) + monkeypatch.setattr(ch, lookup, lookup_mock) + add_mock = Mock() + monkeypatch.setattr(ch, "add", add_mock) + setter = Mock() + arg = Mock() + + assert await getattr(ch, helper)(setter, arg) is value + lookup_mock.assert_awaited_once_with(arg) + setter.assert_called_once_with(value) + add_mock.assert_called_once_with(setter.return_value) From 2a416fbce6b674f855fe99bc98d9ffe6b0a9dec0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:45:21 -1000 Subject: [PATCH 572/651] [core] Document that hub entities go in platforms in AGENTS.md (#21003) --- AGENTS.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b2d3b442bf6..c165ddc350f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,7 +287,8 @@ file does, and it is the authority when they disagree. The most useful starting `from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other metadata constants. CI (`lint_component_domain`) fails without it. - `DEPENDENCIES`: List of required components - - `AUTO_LOAD`: Components to automatically load + - `AUTO_LOAD`: Components to automatically load. Not a way to put entities under the component's + own key; see "Entities of a hub" below. - `CONFLICTS_WITH`: Incompatible components - `CODEOWNERS`: GitHub usernames responsible for maintenance - `MULTI_CONF`: Whether multiple instances are allowed @@ -379,6 +380,26 @@ file does, and it is the authority when they disagree. The most useful starting var = await switch.new_switch(config) ``` + - **Entities of a hub:** a new component's entities go in platform files (`sensor.py`, + `binary_sensor.py`, ...) that take the hub's id, never under the hub's own key with their platforms + added to `AUTO_LOAD` to make that work. Auto loading a platform because the C++ uses its type is + fine. See https://developers.esphome.io/architecture/components/#exposing-multiple-entities. + ```yaml + # Bad - entities under the hub's key + my_hub: + connected: + name: Connected + + # Good - the hub, then one platform entry that holds its entities + my_hub: + id: hub1 + binary_sensor: + - platform: my_hub + my_hub_id: hub1 # optional with a single hub + connected: + name: Connected + ``` + - **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or `sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, `sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented`, @@ -389,13 +410,19 @@ file does, and it is the authority when they disagree. The most useful starting itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are. ```python + # my_hub/sensor.py async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_MY_HUB_ID]) sensors = sensor.sub_sensors(config) - await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) - await sensors(CONF_HUMIDITY, var.set_humidity_sensor) - buttons = button.sub_buttons(config, parent=var) - await buttons(CONF_RESTART, var.set_restart_button) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_HUMIDITY, hub.set_humidity_sensor) + + + # my_hub/button.py + async def to_code(config): + hub = await cg.get_variable(config[CONF_MY_HUB_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_RESTART, hub.set_restart_button) ``` - **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])` From 0cd1ae01f120572f4a26a90e9b89ac8c82a89fee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:52:14 -1000 Subject: [PATCH 573/651] [components] Use cv.uint8_t and cv.uint16_t for full byte ranges (#21015) --- esphome/components/am43/cover/__init__.py | 2 +- esphome/components/as7341/sensor.py | 2 +- esphome/components/as734x/__init__.py | 2 +- esphome/components/grove_tb6612fng/__init__.py | 2 +- esphome/components/hub75/display.py | 4 ++-- esphome/components/lc709203f/sensor.py | 2 +- esphome/components/ltr501/sensor.py | 8 ++------ esphome/components/ltr_als_ps/sensor.py | 8 ++------ esphome/components/lvgl/lv_validation.py | 4 ++-- esphome/components/lvgl/widgets/animimg.py | 2 +- esphome/components/micronova/sensor/__init__.py | 4 +--- esphome/components/mipi/__init__.py | 2 +- esphome/components/mixer/speaker/__init__.py | 4 +--- esphome/components/nextion/base_component.py | 4 ++-- esphome/components/opentherm/schema.py | 8 ++++---- esphome/components/sen6x/sensor.py | 4 +--- esphome/components/sx127x/__init__.py | 2 +- esphome/components/template/text/__init__.py | 4 ++-- esphome/components/tmc22xx/__init__.py | 2 +- .../components/waveshare_io_ch32v003/output/__init__.py | 8 ++------ esphome/components/wled/__init__.py | 2 +- 21 files changed, 31 insertions(+), 49 deletions(-) diff --git a/esphome/components/am43/cover/__init__.py b/esphome/components/am43/cover/__init__.py index d1783b77df9..5257866a885 100644 --- a/esphome/components/am43/cover/__init__.py +++ b/esphome/components/am43/cover/__init__.py @@ -19,7 +19,7 @@ CONFIG_SCHEMA = ( cover.cover_schema(Am43Component) .extend( { - cv.Optional(CONF_PIN, default=8888): cv.int_range(min=0, max=0xFFFF), + cv.Optional(CONF_PIN, default=8888): cv.uint16_t, cv.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, } ) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index 65307def3d0..9f5b4be89e3 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -73,7 +73,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_CLEAR): SENSOR_SCHEMA, cv.Optional(CONF_NIR): SENSOR_SCHEMA, cv.Optional(CONF_GAIN, default="X8"): cv.enum(GAIN_OPTIONS), - cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), + cv.Optional(CONF_ATIME, default=29): cv.uint8_t, cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), } ) diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index dc45ddd1308..e82be0ea66f 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -127,7 +127,7 @@ def _schema( { cv.GenerateID(): cv.declare_id(AS734XComponent), cv.Optional(CONF_GAIN, default="X8"): cv.enum(gains), - cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), + cv.Optional(CONF_ATIME, default=29): cv.uint8_t, cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), cv.Optional(CONF_CALIBRATION, default={}): _calibration_schema(bands), cv.Optional(CONF_NORMALIZE_BASIC_COUNTS, default="NONE"): cv.enum( diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 501071f42b9..3bb4095ba17 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -47,7 +47,7 @@ automation.register_apply_action( { cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), - cv.Required(CONF_SPEED): cv.templatable(cv.int_range(min=0, max=255)), + cv.Required(CONF_SPEED): cv.templatable(cv.uint8_t), cv.Required(CONF_DIRECTION): cv.enum(DIRECTION_TYPE, upper=True), } ), diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index b6aa35b8a7b..e63b94d3833 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -397,7 +397,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SHIFT_DRIVER): cv.enum(SHIFT_DRIVERS, upper=True), # Display configuration cv.Optional(CONF_DOUBLE_BUFFER): cv.boolean, - cv.Optional(CONF_BRIGHTNESS): cv.int_range(min=0, max=255), + cv.Optional(CONF_BRIGHTNESS): cv.uint8_t, cv.Optional(CONF_BIT_DEPTH): cv.int_range(min=4, max=12), cv.Optional(CONF_GAMMA_CORRECT): cv.enum( {"LINEAR": 0, "CIE1931": 1, "GAMMA_2_2": 2}, upper=True @@ -610,7 +610,7 @@ automation.register_apply_action( cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HUB75Display), - cv.Required(CONF_BRIGHTNESS): cv.templatable(cv.int_range(min=0, max=255)), + cv.Required(CONF_BRIGHTNESS): cv.templatable(cv.uint8_t), }, key=CONF_BRIGHTNESS, ), diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 8ebb14d2ca7..a5b02f22242 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -60,7 +60,7 @@ CONFIG_SCHEMA = ( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend( { - cv.Required(CONF_B_CONSTANT): cv.int_range(0, 0xFFFF), + cv.Required(CONF_B_CONSTANT): cv.uint16_t, } ), } diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index ca9416f924f..a46b312bd07 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -138,12 +138,8 @@ CONFIG_SCHEMA = cv.All( CONF_PS_COOLDOWN, default="5s" ): cv.positive_time_period_seconds, cv.Optional(CONF_PS_GAIN, default="1X"): cv.enum(PS_GAINS, upper=True), - cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.int_range( - min=0, max=65535 - ), - cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.int_range( - min=0, max=65535 - ), + cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.uint16_t, + cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.uint16_t, cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_AMBIENT_LIGHT): cv.maybe_simple_value( diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 5ebaed1f249..0deb01c2830 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -133,12 +133,8 @@ CONFIG_SCHEMA = cv.All( CONF_PS_COOLDOWN, default="5s" ): cv.positive_time_period_seconds, cv.Optional(CONF_PS_GAIN, default="16X"): cv.enum(PS_GAINS, upper=True), - cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.int_range( - min=0, max=65535 - ), - cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.int_range( - min=0, max=65535 - ), + cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.uint16_t, + cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.uint16_t, cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_AMBIENT_LIGHT): cv.maybe_simple_value( diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index d8a96de639a..646e472e7fe 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -236,7 +236,7 @@ def id_name(value): def stop_value(value): - return cv.int_range(0, 255)(value) + return cv.uint8_t(value) def _image_validator(value): @@ -398,7 +398,7 @@ lv_brightness = LValidator( def _percentage_validator(value): - value = cv.Any(cv.percentage, cv.float_range(0.0, 1.0), cv.int_range(0, 255))(value) + value = cv.Any(cv.percentage, cv.float_range(0.0, 1.0), cv.uint8_t)(value) if isinstance(value, int): return value / 255.0 return value diff --git a/esphome/components/lvgl/widgets/animimg.py b/esphome/components/lvgl/widgets/animimg.py index b6d59df7f25..8540b3f5529 100644 --- a/esphome/components/lvgl/widgets/animimg.py +++ b/esphome/components/lvgl/widgets/animimg.py @@ -17,7 +17,7 @@ CONF_ANIMIMG = "animimg" def lv_repeat_count(value): if isinstance(value, str) and value.lower() in ("forever", "infinite"): value = 0xFFFF - return cv.int_range(min=0, max=0xFFFF)(value) + return cv.uint16_t(value) ANIMIMG_BASE_SCHEMA = cv.Schema( diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index 6091718d658..7062e436ce4 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -86,9 +86,7 @@ CONFIG_SCHEMA = cv.Schema( is_polling_component=True, ) ) - .extend( - {cv.Optional(CONF_FAN_RPM_OFFSET, default=0): cv.int_range(min=0, max=255)} - ), + .extend({cv.Optional(CONF_FAN_RPM_OFFSET, default=0): cv.uint8_t}), cv.Optional(CONF_WATER_TEMPERATURE): sensor.sensor_schema( MicroNovaSensor, unit_of_measurement=UNIT_CELSIUS, diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index b2351e23a44..4db1ebdc5ed 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -235,7 +235,7 @@ def map_sequence(value): cv.Range(TimePeriod(milliseconds=1), TimePeriod(milliseconds=255)), )(value) return DELAY_FLAG, delay_value.total_milliseconds - value = cv.All(cv.ensure_list(cv.int_range(0, 255)), cv.Length(1, 254))(value) + value = cv.All(cv.ensure_list(cv.uint8_t), cv.Length(1, 254))(value) return tuple(value) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 44473b12c32..9d0a55ebb97 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -147,9 +147,7 @@ automation.register_apply_action( cv.Schema( { cv.GenerateID(): cv.use_id(SourceSpeaker), - cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable( - cv.int_range(min=0, max=255) - ), + cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable(cv.uint8_t), cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable( cv.positive_time_period_milliseconds ), diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 74a50a95d4c..17c1b263ed8 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -72,7 +72,7 @@ CONFIG_TEXT_COMPONENT_SCHEMA = CONFIG_BASE_COMPONENT_SCHEMA.extend( cv.Schema( { cv.Required(CONF_COMPONENT_NAME): NextionName, - cv.Optional(CONF_FONT_ID): cv.int_range(min=0, max=255), + cv.Optional(CONF_FONT_ID): cv.uint8_t, } ) ) @@ -89,7 +89,7 @@ CONFIG_BINARY_SENSOR_SCHEMA = CONFIG_BASE_COMPONENT_SCHEMA.extend( CONFIG_SENSOR_COMPONENT_SCHEMA = CONFIG_BINARY_SENSOR_SCHEMA.extend( cv.Schema( { - cv.Optional(CONF_FONT_ID): cv.int_range(min=0, max=255), + cv.Optional(CONF_FONT_ID): cv.uint8_t, } ) ) diff --git a/esphome/components/opentherm/schema.py b/esphome/components/opentherm/schema.py index 7f3ea2df36d..5c735c4aa81 100644 --- a/esphome/components/opentherm/schema.py +++ b/esphome/components/opentherm/schema.py @@ -844,7 +844,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_hb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=1, ), @@ -854,7 +854,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_lb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=1, ), @@ -874,7 +874,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_hb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=5, ), @@ -884,7 +884,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_lb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=5, ), diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 953b6529ef6..b928f97774e 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -233,9 +233,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_NORMALIZED_OFFSET_SLOPE, default=0 ): cv.float_range(min=-3.2768, max=3.2767), - cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_range( - min=0, max=65535 - ), + cv.Optional(CONF_TIME_CONSTANT, default=0): cv.uint16_t, } ), cv.Optional(CONF_TEMPERATURE_ACCELERATION): cv.Schema( diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 08660415a62..3bd3b35a2ed 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -205,7 +205,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_PREAMBLE_POLARITY, default=0xAA): cv.All( cv.hex_int, cv.one_of(0xAA, 0x55) ), - cv.Optional(CONF_PREAMBLE_SIZE, default=0): cv.int_range(min=0, max=65535), + cv.Optional(CONF_PREAMBLE_SIZE, default=0): cv.uint16_t, cv.Required(CONF_RST_PIN): pins.internal_gpio_output_pin_schema, cv.Optional(CONF_RX_FLOOR, default=-94): cv.float_range(min=-128, max=-1), cv.Optional(CONF_RX_START, default=True): cv.boolean, diff --git a/esphome/components/template/text/__init__.py b/esphome/components/template/text/__init__.py index 1266370cb21..68e6eadd7b8 100644 --- a/esphome/components/template/text/__init__.py +++ b/esphome/components/template/text/__init__.py @@ -51,8 +51,8 @@ CONFIG_SCHEMA = cv.All( text.text_schema(TemplateText) .extend( { - cv.Optional(CONF_MIN_LENGTH, default=0): cv.int_range(min=0, max=255), - cv.Optional(CONF_MAX_LENGTH, default=255): cv.int_range(min=0, max=255), + cv.Optional(CONF_MIN_LENGTH, default=0): cv.uint8_t, + cv.Optional(CONF_MAX_LENGTH, default=255): cv.uint8_t, cv.Optional(CONF_PATTERN): cv.string, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index a3ac518d8eb..1018443e002 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -242,7 +242,7 @@ def register_actions(domain: str, cls: MockObjClass) -> None: ), 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_TPOWERDOWN): cv.templatable(cv.uint8_t), cv.Optional(CONF_STANDSTILL_MODE): cv.templatable( cv.enum(STANDSTILL_MODES, lower=True) ), diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py index 7438769928d..5463ddcc652 100644 --- a/esphome/components/waveshare_io_ch32v003/output/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -39,12 +39,8 @@ def validate_pwm_limits(config: ConfigType) -> ConfigType: CONF_SAFE_PWM_LEVELS_SCHEMA = cv.Schema( { - cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.int_range( - min=0, max=255 - ), - cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.int_range( - min=0, max=255 - ), + cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.uint8_t, + cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.uint8_t, } ) diff --git a/esphome/components/wled/__init__.py b/esphome/components/wled/__init__.py index 6b3263e6a3b..59f09c2bf6d 100644 --- a/esphome/components/wled/__init__.py +++ b/esphome/components/wled/__init__.py @@ -22,7 +22,7 @@ CONF_BLANK_ON_START = "blank_on_start" "WLED", { cv.Optional(CONF_PORT, default=21324): cv.port, - cv.Optional(CONF_SYNC_GROUP_MASK, default=0): cv.int_range(min=0, max=255), + cv.Optional(CONF_SYNC_GROUP_MASK, default=0): cv.uint8_t, cv.Optional(CONF_BLANK_ON_START, default=True): cv.boolean, }, ) From ed9616d512c3fe136ea58d2e2398162559e39c2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:10:22 -1000 Subject: [PATCH 574/651] [components] Set pins with cg.add_pin (#21019) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/a4988/stepper.py | 11 +-- esphome/components/ac_dimmer/output.py | 6 +- esphome/components/adc/sensor.py | 3 +- esphome/components/ade7880/sensor.py | 13 +--- esphome/components/ade7953_base/__init__.py | 4 +- esphome/components/apc1/sensor.py | 9 +-- esphome/components/as3935/__init__.py | 3 +- esphome/components/as5600/__init__.py | 4 +- .../axs15231/touchscreen/__init__.py | 6 +- esphome/components/bp1658cj/__init__.py | 6 +- esphome/components/bp5758d/__init__.py | 6 +- esphome/components/cap1188/__init__.py | 4 +- esphome/components/cc1101/__init__.py | 4 +- esphome/components/cd74hc4067/__init__.py | 12 +-- .../cdc_acm_uart/bridge/__init__.py | 8 +- esphome/components/chsc6x/touchscreen.py | 3 +- .../components/cst226/touchscreen/__init__.py | 6 +- .../components/cst328/touchscreen/__init__.py | 6 +- .../components/cst816/touchscreen/__init__.py | 6 +- .../cst9220/touchscreen/__init__.py | 6 +- esphome/components/deep_sleep/__init__.py | 3 +- esphome/components/dht/sensor.py | 4 +- esphome/components/ds248x/__init__.py | 4 +- esphome/components/duty_cycle/sensor.py | 3 +- .../ektf2232/touchscreen/__init__.py | 6 +- esphome/components/epaper_spi/display.py | 11 +-- esphome/components/esp32_dac/output.py | 3 +- esphome/components/esp8266_pwm/output.py | 3 +- .../components/fingerprint_grow/__init__.py | 9 +-- .../components/ft5x06/touchscreen/__init__.py | 4 +- esphome/components/ft63x6/touchscreen.py | 8 +- .../components/gpio/binary_sensor/__init__.py | 3 +- esphome/components/gpio/one_wire/__init__.py | 3 +- esphome/components/gpio/output/__init__.py | 3 +- esphome/components/gpio/switch/__init__.py | 3 +- esphome/components/gsl3670/touchscreen.py | 9 +-- .../components/gt911/touchscreen/__init__.py | 6 +- esphome/components/hbridge/switch/__init__.py | 6 +- esphome/components/hlw8012/sensor.py | 9 +-- esphome/components/hx711/sensor.py | 6 +- esphome/components/icnt86/touchscreen.py | 9 +-- esphome/components/inkplate/display.py | 75 +++++-------------- esphome/components/it8951/display.py | 6 +- esphome/components/lcd_gpio/display.py | 12 +-- .../lilygo_t5_47/touchscreen/__init__.py | 3 +- esphome/components/mics_5524/sensor.py | 3 +- esphome/components/mipi_dsi/display.py | 4 +- esphome/components/mipi_rgb/display.py | 24 ++---- esphome/components/mipi_spi/display.py | 9 +-- esphome/components/mlx90393/sensor.py | 4 +- esphome/components/modbus/__init__.py | 5 +- esphome/components/my9231/__init__.py | 6 +- esphome/components/nrf52/__init__.py | 4 +- esphome/components/opentherm/__init__.py | 7 +- esphome/components/pcd8544/display.py | 6 +- esphome/components/pn7160/__init__.py | 9 +-- esphome/components/pn71xx/__init__.py | 7 +- esphome/components/power_supply/__init__.py | 3 +- esphome/components/pulse_counter/sensor.py | 3 +- esphome/components/pulse_meter/sensor.py | 3 +- esphome/components/pulse_width/sensor.py | 3 +- esphome/components/qmc5883l/sensor.py | 4 +- esphome/components/rc522/__init__.py | 4 +- esphome/components/rotary_encoder/sensor.py | 10 +-- esphome/components/rp2040_pwm/output.py | 3 +- esphome/components/serial_proxy/__init__.py | 9 +-- esphome/components/sh1122_base/__init__.py | 4 +- esphome/components/shelly_dimmer/light.py | 6 +- .../components/sigma_delta_output/output.py | 4 +- esphome/components/slow_pwm/output.py | 4 +- esphome/components/sm10bit_base/__init__.py | 6 +- esphome/components/sm16716/__init__.py | 6 +- esphome/components/sm2135/__init__.py | 6 +- esphome/components/sn74hc165/__init__.py | 13 +--- esphome/components/sn74hc595/__init__.py | 13 +--- esphome/components/spd2010/touchscreen.py | 6 +- esphome/components/spi/__init__.py | 13 +--- esphome/components/ssd1306_base/__init__.py | 4 +- esphome/components/ssd1306_spi/display.py | 3 +- esphome/components/ssd1322_base/__init__.py | 4 +- esphome/components/ssd1322_spi/display.py | 3 +- esphome/components/ssd1325_base/__init__.py | 4 +- esphome/components/ssd1325_spi/display.py | 3 +- esphome/components/ssd1327_base/__init__.py | 4 +- esphome/components/ssd1327_spi/display.py | 3 +- esphome/components/ssd1331_base/__init__.py | 4 +- esphome/components/ssd1331_spi/display.py | 3 +- esphome/components/ssd1351_base/__init__.py | 4 +- esphome/components/ssd1351_spi/display.py | 3 +- .../components/st7123/touchscreen/__init__.py | 6 +- esphome/components/st7567_base/__init__.py | 4 +- esphome/components/st7567_spi/display.py | 3 +- esphome/components/st7701s/display.py | 21 ++---- .../components/status_led/light/__init__.py | 8 +- esphome/components/sx126x/__init__.py | 10 +-- esphome/components/sx127x/__init__.py | 7 +- esphome/components/tas58xx/audio_dac.py | 4 +- esphome/components/tca8418/__init__.py | 3 +- esphome/components/tlc5947/__init__.py | 13 +--- esphome/components/tlc5971/__init__.py | 6 +- esphome/components/tm1621/display.py | 12 +-- esphome/components/tm1637/display.py | 6 +- esphome/components/tm1638/display.py | 11 +-- esphome/components/tm1651/__init__.py | 6 +- esphome/components/tmc22xx/__init__.py | 3 +- .../tt21100/touchscreen/__init__.py | 9 +-- esphome/components/ttp229_bsf/__init__.py | 6 +- esphome/components/tuya/__init__.py | 4 +- esphome/components/tuya/climate/__init__.py | 8 +- esphome/components/tx20/sensor.py | 3 +- esphome/components/uln2003/stepper.py | 12 +-- esphome/components/ultrasonic/sensor.py | 6 +- esphome/components/vl53l0x/sensor.py | 4 +- esphome/components/wiegand/__init__.py | 6 +- esphome/components/x9c/output.py | 9 +-- .../xpt2046/touchscreen/__init__.py | 4 +- esphome/components/zyaura/sensor.py | 7 +- 117 files changed, 227 insertions(+), 564 deletions(-) diff --git a/esphome/components/a4988/stepper.py b/esphome/components/a4988/stepper.py index 7a19bd550da..f97f30903a4 100644 --- a/esphome/components/a4988/stepper.py +++ b/esphome/components/a4988/stepper.py @@ -23,11 +23,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await stepper.register_stepper(var, config) - step_pin = await cg.gpio_pin_expression(config[CONF_STEP_PIN]) - cg.add(var.set_step_pin(step_pin)) - dir_pin = await cg.gpio_pin_expression(config[CONF_DIR_PIN]) - cg.add(var.set_dir_pin(dir_pin)) - - if sleep_pin_config := config.get(CONF_SLEEP_PIN): - sleep_pin = await cg.gpio_pin_expression(sleep_pin_config) - cg.add(var.set_sleep_pin(sleep_pin)) + await cg.add_pin(var.set_step_pin, config[CONF_STEP_PIN]) + await cg.add_pin(var.set_dir_pin, config[CONF_DIR_PIN]) + await cg.add_pin(var.set_sleep_pin, config.get(CONF_SLEEP_PIN)) diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 7d1b66a6905..90d88acc2a3 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -68,10 +68,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_MIN_POWER] = 0.1 await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_GATE_PIN]) - cg.add(var.set_gate_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN]) - cg.add(var.set_zero_cross_pin(pin)) + await cg.add_pin(var.set_gate_pin, config[CONF_GATE_PIN]) + await cg.add_pin(var.set_zero_cross_pin, config[CONF_ZERO_CROSS_PIN]) cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE])) cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE])) cg.add(var.set_method(config[CONF_METHOD])) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index b44d88d56e3..1c10aed4a15 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -164,8 +164,7 @@ async def to_code(config: ConfigType) -> None: # Remove before 2027.2.0 cg.add(var.set_is_temperature()) elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) # Skip the setters when the config matches the C++ initializers. if config[CONF_RAW]: diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 541d37603e1..7e01b35b73b 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -284,16 +284,9 @@ async def power_channel(config: ConfigType) -> MockObj: async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if irq0_pin := config.get(CONF_IRQ0_PIN): - pin = await cg.gpio_pin_expression(irq0_pin) - cg.add(var.set_irq0_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_IRQ1_PIN]) - cg.add(var.set_irq1_pin(pin)) - - if reset_pin := config.get(CONF_RESET_PIN): - pin = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_irq0_pin, config.get(CONF_IRQ0_PIN)) + await cg.add_pin(var.set_irq1_pin, config[CONF_IRQ1_PIN]) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if frequency := config.get(CONF_FREQUENCY): cg.add(var.set_frequency(frequency)) diff --git a/esphome/components/ade7953_base/__init__.py b/esphome/components/ade7953_base/__init__.py index 808de99984c..bd86c4d6d9d 100644 --- a/esphome/components/ade7953_base/__init__.py +++ b/esphome/components/ade7953_base/__init__.py @@ -169,9 +169,7 @@ ADE7953_CONFIG_SCHEMA = cv.Schema( async def register_ade7953(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - if irq_pin_config := config.get(CONF_IRQ_PIN): - irq_pin = await cg.gpio_pin_expression(irq_pin_config) - cg.add(var.set_irq_pin(irq_pin)) + await cg.add_pin(var.set_irq_pin, config.get(CONF_IRQ_PIN)) cg.add(var.set_pga_v(PGA_GAINS[config.get(CONF_VOLTAGE_PGA_GAIN)])) cg.add(var.set_pga_ia(PGA_GAINS[config.get(CONF_CURRENT_PGA_GAIN_A)])) diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py index f3f7e0203f9..61cfdc591b0 100644 --- a/esphome/components/apc1/sensor.py +++ b/esphome/components/apc1/sensor.py @@ -191,13 +191,8 @@ async def to_code(config: ConfigType) -> None: 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)) + await cg.add_pin(var.set_set_pin, config.get(CONF_SET_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) APC1_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index b7b5b09c626..fe36d73b590 100644 --- a/esphome/components/as3935/__init__.py +++ b/esphome/components/as3935/__init__.py @@ -48,8 +48,7 @@ AS3935_SCHEMA = cv.Schema( async def setup_as3935(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - irq_pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) - cg.add(var.set_irq_pin(irq_pin)) + await cg.add_pin(var.set_irq_pin, config[CONF_IRQ_PIN]) cg.add(var.set_indoor(config[CONF_INDOOR])) cg.add(var.set_noise_level(config[CONF_NOISE_LEVEL])) cg.add(var.set_watchdog_threshold(config[CONF_WATCHDOG_THRESHOLD])) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index d695e34235f..a118334f02c 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -218,9 +218,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fast_filter(config[CONF_FAST_FILTER])) cg.add(var.set_start_position(config[CONF_START_POSITION])) - if dir_pin_config := config.get(CONF_DIR_PIN): - pin = await cg.gpio_pin_expression(dir_pin_config) - cg.add(var.set_dir_pin(pin)) + await cg.add_pin(var.set_dir_pin, config.get(CONF_DIR_PIN)) if (end_position_config := config.get(CONF_END_POSITION, None)) is not None: cg.add(var.set_end_position(end_position_config)) diff --git a/esphome/components/axs15231/touchscreen/__init__.py b/esphome/components/axs15231/touchscreen/__init__.py index 2616cb281bb..27f2348b6d8 100644 --- a/esphome/components/axs15231/touchscreen/__init__.py +++ b/esphome/components/axs15231/touchscreen/__init__.py @@ -31,7 +31,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/bp1658cj/__init__.py b/esphome/components/bp1658cj/__init__.py index 76b3d427a45..c2c560c7dbb 100644 --- a/esphome/components/bp1658cj/__init__.py +++ b/esphome/components/bp1658cj/__init__.py @@ -34,9 +34,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_max_power_color_channels(config[CONF_MAX_POWER_COLOR_CHANNELS])) cg.add(var.set_max_power_white_channels(config[CONF_MAX_POWER_WHITE_CHANNELS])) diff --git a/esphome/components/bp5758d/__init__.py b/esphome/components/bp5758d/__init__.py index bd6b6ca1106..b3a05268cb3 100644 --- a/esphome/components/bp5758d/__init__.py +++ b/esphome/components/bp5758d/__init__.py @@ -25,7 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) diff --git a/esphome/components/cap1188/__init__.py b/esphome/components/cap1188/__init__.py index 24f6e627d2c..ae17b0477c4 100644 --- a/esphome/components/cap1188/__init__.py +++ b/esphome/components/cap1188/__init__.py @@ -39,9 +39,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_touch_threshold(config[CONF_TOUCH_THRESHOLD])) cg.add(var.set_allow_multiple_touches(config[CONF_ALLOW_MULTIPLE_TOUCHES])) - if reset_pin_config := config.get(CONF_RESET_PIN): - pin = await cg.gpio_pin_expression(reset_pin_config) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 5375d6bf396..4f2ca2ad266 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -281,9 +281,7 @@ async def to_code(config): if key in config: cg.add(getattr(var, f"set_{key}")(config[key])) - if CONF_GDO0_PIN in config: - gdo0_pin = await cg.gpio_pin_expression(config[CONF_GDO0_PIN]) - cg.add(var.set_gdo0_pin(gdo0_pin)) + await cg.add_pin(var.set_gdo0_pin, config.get(CONF_GDO0_PIN)) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), diff --git a/esphome/components/cd74hc4067/__init__.py b/esphome/components/cd74hc4067/__init__.py index 655b8db819d..6eb4fe8248e 100644 --- a/esphome/components/cd74hc4067/__init__.py +++ b/esphome/components/cd74hc4067/__init__.py @@ -39,13 +39,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin_s0 = await cg.gpio_pin_expression(config[CONF_PIN_S0]) - cg.add(var.set_pin_s0(pin_s0)) - pin_s1 = await cg.gpio_pin_expression(config[CONF_PIN_S1]) - cg.add(var.set_pin_s1(pin_s1)) - pin_s2 = await cg.gpio_pin_expression(config[CONF_PIN_S2]) - cg.add(var.set_pin_s2(pin_s2)) - pin_s3 = await cg.gpio_pin_expression(config[CONF_PIN_S3]) - cg.add(var.set_pin_s3(pin_s3)) + await cg.add_pin(var.set_pin_s0, config[CONF_PIN_S0]) + await cg.add_pin(var.set_pin_s1, config[CONF_PIN_S1]) + await cg.add_pin(var.set_pin_s2, config[CONF_PIN_S2]) + await cg.add_pin(var.set_pin_s3, config[CONF_PIN_S3]) cg.add(var.set_switch_delay(config[CONF_DELAY])) diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py index 133ea92a483..40d44747430 100644 --- a/esphome/components/cdc_acm_uart/bridge/__init__.py +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -93,9 +93,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc) await cg.register_component(var, config) - if dtr_pin_config := config.get(CONF_DTR_PIN): - dtr_pin = await cg.gpio_pin_expression(dtr_pin_config) - cg.add(var.set_dtr_pin(dtr_pin)) - if rts_pin_config := config.get(CONF_RTS_PIN): - rts_pin = await cg.gpio_pin_expression(rts_pin_config) - cg.add(var.set_rts_pin(rts_pin)) + await cg.add_pin(var.set_dtr_pin, config.get(CONF_DTR_PIN)) + await cg.add_pin(var.set_rts_pin, config.get(CONF_RTS_PIN)) diff --git a/esphome/components/chsc6x/touchscreen.py b/esphome/components/chsc6x/touchscreen.py index de974d2a79d..f97feba3c51 100644 --- a/esphome/components/chsc6x/touchscreen.py +++ b/esphome/components/chsc6x/touchscreen.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) diff --git a/esphome/components/cst226/touchscreen/__init__.py b/esphome/components/cst226/touchscreen/__init__.py index 459cba61cd6..ddffd8720c2 100644 --- a/esphome/components/cst226/touchscreen/__init__.py +++ b/esphome/components/cst226/touchscreen/__init__.py @@ -32,7 +32,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/cst328/touchscreen/__init__.py b/esphome/components/cst328/touchscreen/__init__.py index 9bc7744b7c3..9569a7875bd 100644 --- a/esphome/components/cst328/touchscreen/__init__.py +++ b/esphome/components/cst328/touchscreen/__init__.py @@ -33,7 +33,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/cst816/touchscreen/__init__.py b/esphome/components/cst816/touchscreen/__init__.py index 029a544a911..88d5de6ad1a 100644 --- a/esphome/components/cst816/touchscreen/__init__.py +++ b/esphome/components/cst816/touchscreen/__init__.py @@ -32,7 +32,5 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_skip_probe(config[CONF_SKIP_PROBE])) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/cst9220/touchscreen/__init__.py b/esphome/components/cst9220/touchscreen/__init__.py index 393685e67b3..15bfa64c70f 100644 --- a/esphome/components/cst9220/touchscreen/__init__.py +++ b/esphome/components/cst9220/touchscreen/__init__.py @@ -31,7 +31,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index c9a0ec2196f..7230b1e8e9c 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -379,8 +379,7 @@ async def to_code(config: ConfigType) -> None: ) ) else: - pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN]) - cg.add(var.set_wakeup_pin(pin)) + await cg.add_pin(var.set_wakeup_pin, wakeup_pins[0][CONF_PIN]) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) if CONF_RUN_DURATION in config: diff --git a/esphome/components/dht/sensor.py b/esphome/components/dht/sensor.py index da8a5775b15..d000add3013 100644 --- a/esphome/components/dht/sensor.py +++ b/esphome/components/dht/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) -from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType dht_ns = cg.esphome_ns.namespace("dht") @@ -59,8 +58,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 02faa806f7a..623a7547d40 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -107,6 +107,4 @@ async def to_code(config: ConfigType) -> None: if CONF_ACTIVE_PULLUP_RESISTANCE in config: cg.add(var.set_val_rwpu(config[CONF_ACTIVE_PULLUP_RESISTANCE])) - if CONF_SLEEP_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_SLEEP_PIN]) - cg.add(var.set_sleep_pin(pin)) + await cg.add_pin(var.set_sleep_pin, config.get(CONF_SLEEP_PIN)) diff --git a/esphome/components/duty_cycle/sensor.py b/esphome/components/duty_cycle/sensor.py index b7aa1777c83..deb3d02d7a7 100644 --- a/esphome/components/duty_cycle/sensor.py +++ b/esphome/components/duty_cycle/sensor.py @@ -27,5 +27,4 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/ektf2232/touchscreen/__init__.py b/esphome/components/ektf2232/touchscreen/__init__.py index 7636b6993a1..8707f3b4e36 100644 --- a/esphome/components/ektf2232/touchscreen/__init__.py +++ b/esphome/components/ektf2232/touchscreen/__init__.py @@ -35,7 +35,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(interrupt_pin)) - reset_pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset_pin)) + await cg.add_pin(var.set_interrupt_pin, config[CONF_INTERRUPT_PIN]) + await cg.add_pin(var.set_reset_pin, config[CONF_RESET_PIN]) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 6a391811112..75a3426f162 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -243,20 +243,15 @@ async def to_code(config): config = await model.to_code(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) if CONF_LAMBDA in config: lambda_ = await cg.process_lambda( config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void ) cg.add(var.set_writer(lambda_)) - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) - if busy_pin := config.get(CONF_BUSY_PIN): - busy = await cg.gpio_pin_expression(busy_pin) - cg.add(var.set_busy_pin(busy)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await cg.add_pin(var.set_busy_pin, config.get(CONF_BUSY_PIN)) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) diff --git a/esphome/components/esp32_dac/output.py b/esphome/components/esp32_dac/output.py index c87a9e2a1d0..7a27121a96b 100644 --- a/esphome/components/esp32_dac/output.py +++ b/esphome/components/esp32_dac/output.py @@ -49,5 +49,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index 62ec1fe003b..00b4dc563e4 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -46,8 +46,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 2ef654afc55..2007ca60471 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -146,13 +146,8 @@ async def to_code(config): new_password = config[CONF_NEW_PASSWORD] cg.add(var.set_new_password(new_password)) - if CONF_SENSING_PIN in config: - sensing_pin = await cg.gpio_pin_expression(config[CONF_SENSING_PIN]) - cg.add(var.set_sensing_pin(sensing_pin)) - - if CONF_SENSOR_POWER_PIN in config: - sensor_power_pin = await cg.gpio_pin_expression(config[CONF_SENSOR_POWER_PIN]) - cg.add(var.set_sensor_power_pin(sensor_power_pin)) + await cg.add_pin(var.set_sensing_pin, config.get(CONF_SENSING_PIN)) + await cg.add_pin(var.set_sensor_power_pin, config.get(CONF_SENSOR_POWER_PIN)) if CONF_IDLE_PERIOD_TO_SLEEP in config: idle_period_to_sleep_ms = config[CONF_IDLE_PERIOD_TO_SLEEP] diff --git a/esphome/components/ft5x06/touchscreen/__init__.py b/esphome/components/ft5x06/touchscreen/__init__.py index 3ebff693c00..b56043ec4d7 100644 --- a/esphome/components/ft5x06/touchscreen/__init__.py +++ b/esphome/components/ft5x06/touchscreen/__init__.py @@ -28,6 +28,4 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) await touchscreen.register_touchscreen(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - pin = await cg.gpio_pin_expression(interrupt_pin) - cg.add(var.set_interrupt_pin(pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) diff --git a/esphome/components/ft63x6/touchscreen.py b/esphome/components/ft63x6/touchscreen.py index 0d8537bde90..7ac7a3f3ce4 100644 --- a/esphome/components/ft63x6/touchscreen.py +++ b/esphome/components/ft63x6/touchscreen.py @@ -37,9 +37,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): - interrupt_pin = await cg.gpio_pin_expression(interrupt_pin_config) - cg.add(var.set_interrupt_pin(interrupt_pin)) - if reset_pin_config := config.get(CONF_RESET_PIN): - reset_pin = await cg.gpio_pin_expression(reset_pin_config) - cg.add(var.set_reset_pin(reset_pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 8a40e4e7325..22b7cdb520b 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -129,8 +129,7 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) if config[CONF_USE_INTERRUPT]: cg.add_define("USE_GPIO_BINARY_SENSOR_INTERRUPT") diff --git a/esphome/components/gpio/one_wire/__init__.py b/esphome/components/gpio/one_wire/__init__.py index feb8b53dff6..f2b0ca0be68 100644 --- a/esphome/components/gpio/one_wire/__init__.py +++ b/esphome/components/gpio/one_wire/__init__.py @@ -23,5 +23,4 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/gpio/output/__init__.py b/esphome/components/gpio/output/__init__.py index 645dd61f802..fc4041fdc6d 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -45,5 +45,4 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 481b8d01dad..ec826697eb1 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -33,8 +33,7 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) if (interlock := config.get(CONF_INTERLOCK)) is not None: cg.add_define("USE_GPIO_SWITCH_INTERLOCK") diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index 703887864bd..5f0edd3d41b 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -226,13 +226,8 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if CONF_INTERRUPT_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(pin)) - - if CONF_RESET_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) # Firmware table data = _read_firmware(config) diff --git a/esphome/components/gt911/touchscreen/__init__.py b/esphome/components/gt911/touchscreen/__init__.py index fa929d4ba0d..c2fb09fb59e 100644 --- a/esphome/components/gt911/touchscreen/__init__.py +++ b/esphome/components/gt911/touchscreen/__init__.py @@ -28,7 +28,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/hbridge/switch/__init__.py b/esphome/components/hbridge/switch/__init__.py index 294be6ed5fe..5dcd8dd3d24 100644 --- a/esphome/components/hbridge/switch/__init__.py +++ b/esphome/components/hbridge/switch/__init__.py @@ -35,10 +35,8 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - on_pin = await cg.gpio_pin_expression(config[CONF_ON_PIN]) - cg.add(var.set_on_pin(on_pin)) - off_pin = await cg.gpio_pin_expression(config[CONF_OFF_PIN]) - cg.add(var.set_off_pin(off_pin)) + await cg.add_pin(var.set_on_pin, config[CONF_ON_PIN]) + await cg.add_pin(var.set_off_pin, config[CONF_OFF_PIN]) cg.add(var.set_pulse_length(config[CONF_PULSE_LENGTH])) cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if wait_time := config.get(CONF_WAIT_TIME): diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 007f7c53030..04357d564da 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -98,12 +98,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - sel = await cg.gpio_pin_expression(config[CONF_SEL_PIN]) - cg.add(var.set_sel_pin(sel)) - cf = await cg.gpio_pin_expression(config[CONF_CF_PIN]) - cg.add(var.set_cf_pin(cf)) - cf1 = await cg.gpio_pin_expression(config[CONF_CF1_PIN]) - cg.add(var.set_cf1_pin(cf1)) + await cg.add_pin(var.set_sel_pin, config[CONF_SEL_PIN]) + await cg.add_pin(var.set_cf_pin, config[CONF_CF_PIN]) + await cg.add_pin(var.set_cf1_pin, config[CONF_CF1_PIN]) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/hx711/sensor.py b/esphome/components/hx711/sensor.py index 2739589c663..4f4c106f4ef 100644 --- a/esphome/components/hx711/sensor.py +++ b/esphome/components/hx711/sensor.py @@ -39,8 +39,6 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - dout_pin = await cg.gpio_pin_expression(config[CONF_DOUT_PIN]) - cg.add(var.set_dout_pin(dout_pin)) - sck_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_sck_pin(sck_pin)) + await cg.add_pin(var.set_dout_pin, config[CONF_DOUT_PIN]) + await cg.add_pin(var.set_sck_pin, config[CONF_CLK_PIN]) cg.add(var.set_gain(config[CONF_GAIN])) diff --git a/esphome/components/icnt86/touchscreen.py b/esphome/components/icnt86/touchscreen.py index 5d7a7386120..9a45ac4ac32 100644 --- a/esphome/components/icnt86/touchscreen.py +++ b/esphome/components/icnt86/touchscreen.py @@ -31,10 +31,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): - cg.add( - var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config)) - ) - - if reset_pin_config := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index c7600d39bbc..5e90b1ff490 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -184,59 +184,22 @@ async def to_code(config: ConfigType) -> None: waveform = [element for tupl in waveform for element in tupl] cg.add(var.set_waveform(waveform, False)) - ckv = await cg.gpio_pin_expression(config[CONF_CKV_PIN]) - cg.add(var.set_ckv_pin(ckv)) - - gmod = await cg.gpio_pin_expression(config[CONF_GMOD_PIN]) - cg.add(var.set_gmod_pin(gmod)) - - gpio0_enable = await cg.gpio_pin_expression(config[CONF_GPIO0_ENABLE_PIN]) - cg.add(var.set_gpio0_enable_pin(gpio0_enable)) - - oe = await cg.gpio_pin_expression(config[CONF_OE_PIN]) - cg.add(var.set_oe_pin(oe)) - - powerup = await cg.gpio_pin_expression(config[CONF_POWERUP_PIN]) - cg.add(var.set_powerup_pin(powerup)) - - sph = await cg.gpio_pin_expression(config[CONF_SPH_PIN]) - cg.add(var.set_sph_pin(sph)) - - spv = await cg.gpio_pin_expression(config[CONF_SPV_PIN]) - cg.add(var.set_spv_pin(spv)) - - vcom = await cg.gpio_pin_expression(config[CONF_VCOM_PIN]) - cg.add(var.set_vcom_pin(vcom)) - - wakeup = await cg.gpio_pin_expression(config[CONF_WAKEUP_PIN]) - cg.add(var.set_wakeup_pin(wakeup)) - - cl = await cg.gpio_pin_expression(config[CONF_CL_PIN]) - cg.add(var.set_cl_pin(cl)) - - le = await cg.gpio_pin_expression(config[CONF_LE_PIN]) - cg.add(var.set_le_pin(le)) - - display_data_0 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_0_PIN]) - cg.add(var.set_display_data_0_pin(display_data_0)) - - display_data_1 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_1_PIN]) - cg.add(var.set_display_data_1_pin(display_data_1)) - - display_data_2 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_2_PIN]) - cg.add(var.set_display_data_2_pin(display_data_2)) - - display_data_3 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_3_PIN]) - cg.add(var.set_display_data_3_pin(display_data_3)) - - display_data_4 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_4_PIN]) - cg.add(var.set_display_data_4_pin(display_data_4)) - - display_data_5 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_5_PIN]) - cg.add(var.set_display_data_5_pin(display_data_5)) - - display_data_6 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_6_PIN]) - cg.add(var.set_display_data_6_pin(display_data_6)) - - display_data_7 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_7_PIN]) - cg.add(var.set_display_data_7_pin(display_data_7)) + await cg.add_pin(var.set_ckv_pin, config[CONF_CKV_PIN]) + await cg.add_pin(var.set_gmod_pin, config[CONF_GMOD_PIN]) + await cg.add_pin(var.set_gpio0_enable_pin, config[CONF_GPIO0_ENABLE_PIN]) + await cg.add_pin(var.set_oe_pin, config[CONF_OE_PIN]) + await cg.add_pin(var.set_powerup_pin, config[CONF_POWERUP_PIN]) + await cg.add_pin(var.set_sph_pin, config[CONF_SPH_PIN]) + await cg.add_pin(var.set_spv_pin, config[CONF_SPV_PIN]) + await cg.add_pin(var.set_vcom_pin, config[CONF_VCOM_PIN]) + await cg.add_pin(var.set_wakeup_pin, config[CONF_WAKEUP_PIN]) + await cg.add_pin(var.set_cl_pin, config[CONF_CL_PIN]) + await cg.add_pin(var.set_le_pin, config[CONF_LE_PIN]) + await cg.add_pin(var.set_display_data_0_pin, config[CONF_DISPLAY_DATA_0_PIN]) + await cg.add_pin(var.set_display_data_1_pin, config[CONF_DISPLAY_DATA_1_PIN]) + await cg.add_pin(var.set_display_data_2_pin, config[CONF_DISPLAY_DATA_2_PIN]) + await cg.add_pin(var.set_display_data_3_pin, config[CONF_DISPLAY_DATA_3_PIN]) + await cg.add_pin(var.set_display_data_4_pin, config[CONF_DISPLAY_DATA_4_PIN]) + await cg.add_pin(var.set_display_data_5_pin, config[CONF_DISPLAY_DATA_5_PIN]) + await cg.add_pin(var.set_display_data_6_pin, config[CONF_DISPLAY_DATA_6_PIN]) + await cg.add_pin(var.set_display_data_7_pin, config[CONF_DISPLAY_DATA_7_PIN]) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 28636e5e26e..bb0c1f10fbb 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -367,10 +367,8 @@ async def to_code(config: ConfigType) -> None: await spi.register_spi_device(var, config, write_only=False) await display.add_writer(var, config) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) - if busy_pin := config.get(CONF_BUSY_PIN): - cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin))) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await cg.add_pin(var.set_busy_pin, config.get(CONF_BUSY_PIN)) if enable_pins := config.get(CONF_ENABLE_PIN): cg.add( var.set_enable_pins( diff --git a/esphome/components/lcd_gpio/display.py b/esphome/components/lcd_gpio/display.py index 0e99b471b3c..a312187c7f6 100644 --- a/esphome/components/lcd_gpio/display.py +++ b/esphome/components/lcd_gpio/display.py @@ -43,14 +43,8 @@ async def to_code(config: ConfigType) -> None: await lcd_base.setup_lcd_display(var, config) pins_ = [await cg.gpio_pin_expression(conf) for conf in config[CONF_DATA_PINS]] cg.add(var.set_data_pins(*pins_)) - enable = await cg.gpio_pin_expression(config[CONF_ENABLE_PIN]) - cg.add(var.set_enable_pin(enable)) - - rs = await cg.gpio_pin_expression(config[CONF_RS_PIN]) - cg.add(var.set_rs_pin(rs)) - - if CONF_RW_PIN in config: - rw = await cg.gpio_pin_expression(config[CONF_RW_PIN]) - cg.add(var.set_rw_pin(rw)) + await cg.add_pin(var.set_enable_pin, config[CONF_ENABLE_PIN]) + await cg.add_pin(var.set_rs_pin, config[CONF_RS_PIN]) + await cg.add_pin(var.set_rw_pin, config.get(CONF_RW_PIN)) await display.add_writer(var, config, GPIOLCDDisplay.operator("ref")) diff --git a/esphome/components/lilygo_t5_47/touchscreen/__init__.py b/esphome/components/lilygo_t5_47/touchscreen/__init__.py index 1e70f379a1a..5c68ddf6eed 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/__init__.py +++ b/esphome/components/lilygo_t5_47/touchscreen/__init__.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(interrupt_pin)) + await cg.add_pin(var.set_interrupt_pin, config[CONF_INTERRUPT_PIN]) diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py index 7b80572a115..0440a4829d9 100644 --- a/esphome/components/mics_5524/sensor.py +++ b/esphome/components/mics_5524/sensor.py @@ -434,8 +434,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) cg.add(var.set_preference_key(_preference_key(config))) - if (enable := config.get(CONF_ENABLE_PIN)) is not None: - cg.add(var.set_enable_pin(await cg.gpio_pin_expression(enable))) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) if (reference := config.get(CONF_AIR_REFERENCE, config.get(CONF_R0))) is not None: cg.add(var.set_reference(reference)) # A request on a hub without `calibration:` uses the schema defaults, never persisted. diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 6dd96d21bcb..67aad41c00c 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -213,9 +213,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY] / 1.0e6)) cg.add(var.set_lanes(int(config[CONF_LANES]))) cg.add(var.set_lane_bit_rate(config[CONF_LANE_BIT_RATE] / 1.0e6)) - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 4c87bf19987..a7cca81d8d5 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -315,28 +315,16 @@ async def to_code(config: ConfigType) -> None: data_pin = await cg.gpio_pin_expression(pin) cg.add(var.add_data_pin(data_pin, index)) - if dc_pin := config.get(CONF_DC_PIN): - dc = await cg.gpio_pin_expression(dc_pin) - cg.add(var.set_dc_pin(dc)) - - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_dc_pin, config.get(CONF_DC_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if model.rotation_as_transform(config): config[CONF_ROTATION] = 0 - if de_pin := config.get(CONF_DE_PIN): - pin = await cg.gpio_pin_expression(de_pin) - cg.add(var.set_de_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_PCLK_PIN]) - cg.add(var.set_pclk_pin(pin)) - if hsync_pin := config.get(CONF_HSYNC_PIN): - pin = await cg.gpio_pin_expression(hsync_pin) - cg.add(var.set_hsync_pin(pin)) - if vsync_pin := config.get(CONF_VSYNC_PIN): - pin = await cg.gpio_pin_expression(vsync_pin) - cg.add(var.set_vsync_pin(pin)) + await cg.add_pin(var.set_de_pin, config.get(CONF_DE_PIN)) + await cg.add_pin(var.set_pclk_pin, config[CONF_PCLK_PIN]) + await cg.add_pin(var.set_hsync_pin, config.get(CONF_HSYNC_PIN)) + await cg.add_pin(var.set_vsync_pin, config.get(CONF_VSYNC_PIN)) await display.register_display(var, config) await display.add_writer(var, config) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index ff49979a244..380c3a4dfd6 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -408,13 +408,8 @@ async def to_code(config: ConfigType) -> None: enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) - - if dc_pin := config.get(CONF_DC_PIN): - dc_pin = await cg.gpio_pin_expression(dc_pin) - cg.add(var.set_dc_pin(dc_pin)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await cg.add_pin(var.set_dc_pin, config.get(CONF_DC_PIN)) if config.get(CONF_INVERT_COLORS): cg.add(var.set_invert_colors(True)) diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index d6a777e7d88..5f793efd918 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -145,8 +145,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_resolution(2, RESOLUTION[config[CONF_Z_AXIS][CONF_RESOLUTION]])) if await sensors(CONF_TEMPERATURE, var.set_t_sensor): cg.add(var.set_t_oversampling(config[CONF_TEMPERATURE][CONF_OVERSAMPLING])) - if CONF_DRDY_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) - cg.add(var.set_drdy_gpio(pin)) + await cg.add_pin(var.set_drdy_gpio, config.get(CONF_DRDY_PIN)) cg.add_library("functionpointer/arduino-MLX90393", "1.0.2") diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 2c3aba950fe..8c079afbac3 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -11,7 +11,6 @@ from esphome.components.const import CONF_ROLE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj -from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv from esphome.types import ConfigType, TemplateArgsType @@ -307,9 +306,7 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) - if CONF_FLOW_CONTROL_PIN in config: - pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) - cg.add(var.set_flow_control_pin(pin)) + await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) if config[CONF_ROLE] == "client": cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) diff --git a/esphome/components/my9231/__init__.py b/esphome/components/my9231/__init__.py index 6fcb09a3478..5c4fe6af458 100644 --- a/esphome/components/my9231/__init__.py +++ b/esphome/components/my9231/__init__.py @@ -33,10 +33,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - di = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_pin_di(di)) - dcki = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_pin_dcki(dcki)) + await cg.add_pin(var.set_pin_di, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_pin_dcki, config[CONF_CLOCK_PIN]) cg.add(var.set_num_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index b0c375eb53d..0c6ae8eb470 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -529,9 +529,7 @@ async def to_code(config: ConfigType) -> None: async def _dfu_to_code(dfu_config): cg.add_define("USE_NRF52_DFU") var = cg.new_Pvariable(dfu_config[CONF_ID]) - if CONF_RESET_PIN in dfu_config: - pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_reset_pin, dfu_config.get(CONF_RESET_PIN)) # DFU uses cdc rate callback to enter bootloader which was disabled explicitly to save power. zephyr_add_prj_conf("USB_DEVICE_STACK", True) diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 026951be93d..3772034ab63 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -87,11 +87,8 @@ async def to_code(config: dict[str, Any]) -> None: await cg.register_component(var, config) # Set pins - in_pin = await cg.gpio_pin_expression(config[CONF_IN_PIN]) - cg.add(var.set_in_pin(in_pin)) - - out_pin = await cg.gpio_pin_expression(config[CONF_OUT_PIN]) - cg.add(var.set_out_pin(out_pin)) + await cg.add_pin(var.set_in_pin, config[CONF_IN_PIN]) + await cg.add_pin(var.set_out_pin, config[CONF_OUT_PIN]) non_sensors = { CONF_ID, diff --git a/esphome/components/pcd8544/display.py b/esphome/components/pcd8544/display.py index c63d18b3cc4..1143def11c3 100644 --- a/esphome/components/pcd8544/display.py +++ b/esphome/components/pcd8544/display.py @@ -47,10 +47,8 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) + await cg.add_pin(var.set_reset_pin, config[CONF_RESET_PIN]) cg.add(var.set_contrast(config[CONF_CONTRAST])) diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index f1bd6122ff1..058b70ffb65 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -29,10 +29,5 @@ pn71xx.register_is_writing_condition("pn7160.is_writing", PN7160) async def setup_pn7160(var: MockObj, config: ConfigType) -> None: await pn71xx.setup_pn71xx(var, config) - if dwl_req_pin_config := config.get(CONF_DWL_REQ_PIN): - pin = await cg.gpio_pin_expression(dwl_req_pin_config) - cg.add(var.set_dwl_req_pin(pin)) - - if wkup_req_pin_config := config.get(CONF_WKUP_REQ_PIN): - pin = await cg.gpio_pin_expression(wkup_req_pin_config) - cg.add(var.set_wkup_req_pin(pin)) + await cg.add_pin(var.set_dwl_req_pin, config.get(CONF_DWL_REQ_PIN)) + await cg.add_pin(var.set_wkup_req_pin, config.get(CONF_WKUP_REQ_PIN)) diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py index 3b40eb11419..10d806ca0c6 100644 --- a/esphome/components/pn71xx/__init__.py +++ b/esphome/components/pn71xx/__init__.py @@ -127,11 +127,8 @@ _request_ontagremoved_trigger_slot = cg.slot_counter( async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) - cg.add(var.set_irq_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_VEN_PIN]) - cg.add(var.set_ven_pin(pin)) + await cg.add_pin(var.set_irq_pin, config[CONF_IRQ_PIN]) + await cg.add_pin(var.set_ven_pin, config[CONF_VEN_PIN]) if emulation_message_config := config.get(CONF_EMULATION_MESSAGE): cg.add(var.set_tag_emulation_message(emulation_message_config)) diff --git a/esphome/components/power_supply/__init__.py b/esphome/components/power_supply/__init__.py index 4d90642b5f5..5862a945a66 100644 --- a/esphome/components/power_supply/__init__.py +++ b/esphome/components/power_supply/__init__.py @@ -35,8 +35,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) cg.add(var.set_enable_time(config[CONF_ENABLE_TIME])) cg.add(var.set_keep_on_time(config[CONF_KEEP_ON_TIME])) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 46aa87a8e11..33beb96dcf6 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -131,8 +131,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config, use_pcnt) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) count = config[CONF_COUNT_MODE] cg.add(var.set_rising_edge_mode(count[CONF_RISING_EDGE])) cg.add(var.set_falling_edge_mode(count[CONF_FALLING_EDGE])) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index 05708c65c13..1239d011a2b 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -83,8 +83,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) cg.add(var.set_filter_us(config[CONF_INTERNAL_FILTER])) cg.add(var.set_timeout_us(config[CONF_TIMEOUT])) cg.add(var.set_filter_mode(config[CONF_INTERNAL_FILTER_MODE])) diff --git a/esphome/components/pulse_width/sensor.py b/esphome/components/pulse_width/sensor.py index dd7554c4ebd..01f7988f584 100644 --- a/esphome/components/pulse_width/sensor.py +++ b/esphome/components/pulse_width/sensor.py @@ -32,5 +32,4 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index deba7eab7e0..c1b8d3c7af8 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -153,6 +153,4 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) await sensors(CONF_HEADING, var.set_heading_sensor) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) - if CONF_DRDY_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) - cg.add(var.set_drdy_pin(pin)) + await cg.add_pin(var.set_drdy_pin, config.get(CONF_DRDY_PIN)) diff --git a/esphome/components/rc522/__init__.py b/esphome/components/rc522/__init__.py index 69cc713813b..8b2aabfa237 100644 --- a/esphome/components/rc522/__init__.py +++ b/esphome/components/rc522/__init__.py @@ -44,9 +44,7 @@ RC522_SCHEMA = cv.Schema( async def setup_rc522(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 31391d9c6ac..15629c446ab 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -93,16 +93,12 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin_a = await cg.gpio_pin_expression(config[CONF_PIN_A]) - cg.add(var.set_pin_a(pin_a)) - pin_b = await cg.gpio_pin_expression(config[CONF_PIN_B]) - cg.add(var.set_pin_b(pin_b)) + await cg.add_pin(var.set_pin_a, config[CONF_PIN_A]) + await cg.add_pin(var.set_pin_b, config[CONF_PIN_B]) cg.add(var.set_publish_initial_value(config[CONF_PUBLISH_INITIAL_VALUE])) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) - if CONF_PIN_RESET in config: - pin_i = await cg.gpio_pin_expression(config[CONF_PIN_RESET]) - cg.add(var.set_reset_pin(pin_i)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_PIN_RESET)) cg.add(var.set_resolution(config[CONF_RESOLUTION])) if CONF_MIN_VALUE in config: cg.add(var.set_min_value(config[CONF_MIN_VALUE])) diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index 49b10f7d201..2f1394d121c 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -27,8 +27,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) cg.add(var.set_frequency(config[CONF_FREQUENCY])) diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 35b65f6bae3..27d69c97346 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -117,10 +117,5 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(_add_serial_proxy_count_define) data.count += 1 - if CONF_RTS_PIN in config: - rts_pin = await cg.gpio_pin_expression(config[CONF_RTS_PIN]) - cg.add(var.set_rts_pin(rts_pin)) - - if CONF_DTR_PIN in config: - dtr_pin = await cg.gpio_pin_expression(config[CONF_DTR_PIN]) - cg.add(var.set_dtr_pin(dtr_pin)) + await cg.add_pin(var.set_rts_pin, config.get(CONF_RTS_PIN)) + await cg.add_pin(var.set_dtr_pin, config.get(CONF_DTR_PIN)) diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py index e8828685368..4e412f5ebf7 100644 --- a/esphome/components/sh1122_base/__init__.py +++ b/esphome/components/sh1122_base/__init__.py @@ -32,8 +32,6 @@ 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)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index c166076e0f8..701d29ffa22 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -250,10 +250,8 @@ async def to_code(config: ConfigType) -> None: await light.register_light(var, config) await uart.register_uart_device(var, config) - nrst_pin = await cg.gpio_pin_expression(config[CONF_NRST_PIN]) - cg.add(var.set_nrst_pin(nrst_pin)) - boot0_pin = await cg.gpio_pin_expression(config[CONF_BOOT0_PIN]) - cg.add(var.set_boot0_pin(boot0_pin)) + await cg.add_pin(var.set_nrst_pin, config[CONF_NRST_PIN]) + await cg.add_pin(var.set_boot0_pin, config[CONF_BOOT0_PIN]) cg.add(var.set_leading_edge(config[CONF_LEADING_EDGE])) cg.add(var.set_warmup_brightness(config[CONF_WARMUP_BRIGHTNESS])) diff --git a/esphome/components/sigma_delta_output/output.py b/esphome/components/sigma_delta_output/output.py index aca5f264572..af6f5948eaf 100644 --- a/esphome/components/sigma_delta_output/output.py +++ b/esphome/components/sigma_delta_output/output.py @@ -43,9 +43,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - if CONF_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config.get(CONF_PIN)) if CONF_STATE_CHANGE_ACTION in config: await automation.build_automation( diff --git a/esphome/components/slow_pwm/output.py b/esphome/components/slow_pwm/output.py index f3f82d7ce5a..488932f1b42 100644 --- a/esphome/components/slow_pwm/output.py +++ b/esphome/components/slow_pwm/output.py @@ -47,9 +47,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) - if CONF_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config.get(CONF_PIN)) if CONF_STATE_CHANGE_ACTION in config: await automation.build_automation( var.get_state_change_trigger(), diff --git a/esphome/components/sm10bit_base/__init__.py b/esphome/components/sm10bit_base/__init__.py index 05ba98562d7..5417f9ea6ce 100644 --- a/esphome/components/sm10bit_base/__init__.py +++ b/esphome/components/sm10bit_base/__init__.py @@ -33,10 +33,8 @@ async def register_sm10bit_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_max_power_color_channels(config[CONF_MAX_POWER_COLOR_CHANNELS])) cg.add(var.set_max_power_white_channels(config[CONF_MAX_POWER_WHITE_CHANNELS])) diff --git a/esphome/components/sm16716/__init__.py b/esphome/components/sm16716/__init__.py index ae3c54ab7c7..8359b53671b 100644 --- a/esphome/components/sm16716/__init__.py +++ b/esphome/components/sm16716/__init__.py @@ -31,10 +31,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_num_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/sm2135/__init__.py b/esphome/components/sm2135/__init__.py index 7c5622c041b..934cf68186f 100644 --- a/esphome/components/sm2135/__init__.py +++ b/esphome/components/sm2135/__init__.py @@ -61,10 +61,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_rgb_current(config[CONF_RGB_CURRENT])) cg.add(var.set_cw_current(config[CONF_CW_CURRENT])) diff --git a/esphome/components/sn74hc165/__init__.py b/esphome/components/sn74hc165/__init__.py index af566cebda9..f7d03a897d9 100644 --- a/esphome/components/sn74hc165/__init__.py +++ b/esphome/components/sn74hc165/__init__.py @@ -44,15 +44,10 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data_pin)) - clock_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock_pin)) - load_pin = await cg.gpio_pin_expression(config[CONF_LOAD_PIN]) - cg.add(var.set_load_pin(load_pin)) - if CONF_CLOCK_INHIBIT_PIN in config: - clock_inhibit_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_INHIBIT_PIN]) - cg.add(var.set_clock_inhibit_pin(clock_inhibit_pin)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) + await cg.add_pin(var.set_load_pin, config[CONF_LOAD_PIN]) + await cg.add_pin(var.set_clock_inhibit_pin, config.get(CONF_CLOCK_INHIBIT_PIN)) cg.add(var.set_sr_count(config[CONF_SR_COUNT])) diff --git a/esphome/components/sn74hc595/__init__.py b/esphome/components/sn74hc595/__init__.py index 78ed703ed9e..8d540011798 100644 --- a/esphome/components/sn74hc595/__init__.py +++ b/esphome/components/sn74hc595/__init__.py @@ -72,18 +72,13 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if config[CONF_TYPE] == TYPE_GPIO: - data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data_pin)) - clock_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock_pin)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) else: await spi.register_spi_device(var, config) - latch_pin = await cg.gpio_pin_expression(config[CONF_LATCH_PIN]) - cg.add(var.set_latch_pin(latch_pin)) - if oe_pin := config.get(CONF_OE_PIN): - oe_pin = await cg.gpio_pin_expression(oe_pin) - cg.add(var.set_oe_pin(oe_pin)) + await cg.add_pin(var.set_latch_pin, config[CONF_LATCH_PIN]) + await cg.add_pin(var.set_oe_pin, config.get(CONF_OE_PIN)) cg.add(var.set_sr_count(config[CONF_SR_COUNT])) diff --git a/esphome/components/spd2010/touchscreen.py b/esphome/components/spd2010/touchscreen.py index 7ead6a34184..0f3a2e24bf6 100644 --- a/esphome/components/spd2010/touchscreen.py +++ b/esphome/components/spd2010/touchscreen.py @@ -27,7 +27,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 92d5baccf76..151f228651e 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -436,12 +436,9 @@ async def to_code(configs: list[ConfigType]) -> None: for spi in configs: var = cg.new_Pvariable(spi[CONF_ID]) await cg.register_component(var, spi) - clk = await cg.gpio_pin_expression(spi[CONF_CLK_PIN]) - cg.add(var.set_clk(clk)) - if miso := spi.get(CONF_MISO_PIN): - cg.add(var.set_miso(await cg.gpio_pin_expression(miso))) - if mosi := spi.get(CONF_MOSI_PIN): - cg.add(var.set_mosi(await cg.gpio_pin_expression(mosi))) + await cg.add_pin(var.set_clk, spi[CONF_CLK_PIN]) + await cg.add_pin(var.set_miso, spi.get(CONF_MISO_PIN)) + await cg.add_pin(var.set_mosi, spi.get(CONF_MOSI_PIN)) if data_pins := spi.get(CONF_DATA_PINS): cg.add(var.set_data_pins(data_pins)) if (index := spi.get(CONF_INTERFACE_INDEX)) is not None: @@ -491,9 +488,7 @@ async def register_spi_device( cg.add(var.set_spi_parent(parent)) if write_only: cg.add(var.set_write_only(True)) - if cs_pin := config.get(CONF_CS_PIN): - pin = await cg.gpio_pin_expression(cs_pin) - cg.add(var.set_cs_pin(pin)) + await cg.add_pin(var.set_cs_pin, config.get(CONF_CS_PIN)) if data_rate := config.get(CONF_DATA_RATE): cg.add(var.set_data_rate(data_rate)) if spi_mode := config.get(CONF_SPI_MODE): diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index b51fb6f8f22..4f9dda09dd7 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -80,9 +80,7 @@ async def setup_ssd1306(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_CONTRAST in config: diff --git a/esphome/components/ssd1306_spi/display.py b/esphome/components/ssd1306_spi/display.py index 9a415ccfdbd..e7d067eca46 100644 --- a/esphome/components/ssd1306_spi/display.py +++ b/esphome/components/ssd1306_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1306_base.setup_ssd1306(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1322_base/__init__.py b/esphome/components/ssd1322_base/__init__.py index 72f8bd27235..e3a6ac5656e 100644 --- a/esphome/components/ssd1322_base/__init__.py +++ b/esphome/components/ssd1322_base/__init__.py @@ -33,9 +33,7 @@ async def setup_ssd1322(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: diff --git a/esphome/components/ssd1322_spi/display.py b/esphome/components/ssd1322_spi/display.py index 63e0816ae72..e9b036eb7f8 100644 --- a/esphome/components/ssd1322_spi/display.py +++ b/esphome/components/ssd1322_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1322_base.setup_ssd1322(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1325_base/__init__.py b/esphome/components/ssd1325_base/__init__.py index e148808d15f..ee75f2b0b0b 100644 --- a/esphome/components/ssd1325_base/__init__.py +++ b/esphome/components/ssd1325_base/__init__.py @@ -37,9 +37,7 @@ async def setup_ssd1325(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: diff --git a/esphome/components/ssd1325_spi/display.py b/esphome/components/ssd1325_spi/display.py index cca9a3cf8e8..601d874ed3d 100644 --- a/esphome/components/ssd1325_spi/display.py +++ b/esphome/components/ssd1325_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1325_base.setup_ssd1325(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1327_base/__init__.py b/esphome/components/ssd1327_base/__init__.py index 93e4c75d3b1..bd317cf17f4 100644 --- a/esphome/components/ssd1327_base/__init__.py +++ b/esphome/components/ssd1327_base/__init__.py @@ -32,9 +32,7 @@ async def setup_ssd1327(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/ssd1327_spi/display.py b/esphome/components/ssd1327_spi/display.py index 8a5ea40efa2..60a96c5764c 100644 --- a/esphome/components/ssd1327_spi/display.py +++ b/esphome/components/ssd1327_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1327_base.setup_ssd1327(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1331_base/__init__.py b/esphome/components/ssd1331_base/__init__.py index 5f71570b6ae..acde9a1ea46 100644 --- a/esphome/components/ssd1331_base/__init__.py +++ b/esphome/components/ssd1331_base/__init__.py @@ -23,9 +23,7 @@ SSD1331_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( async def setup_ssd1331(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/ssd1331_spi/display.py b/esphome/components/ssd1331_spi/display.py index eeebb48ac6b..f52f14a2273 100644 --- a/esphome/components/ssd1331_spi/display.py +++ b/esphome/components/ssd1331_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1331_base.setup_ssd1331(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1351_base/__init__.py b/esphome/components/ssd1351_base/__init__.py index 2c44cd18403..7078f7d2421 100644 --- a/esphome/components/ssd1351_base/__init__.py +++ b/esphome/components/ssd1351_base/__init__.py @@ -33,9 +33,7 @@ async def setup_ssd1351(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/ssd1351_spi/display.py b/esphome/components/ssd1351_spi/display.py index 10991b8338f..1f70dd14b95 100644 --- a/esphome/components/ssd1351_spi/display.py +++ b/esphome/components/ssd1351_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1351_base.setup_ssd1351(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/st7123/touchscreen/__init__.py b/esphome/components/st7123/touchscreen/__init__.py index 55ea43ca6ab..a1b19c49247 100644 --- a/esphome/components/st7123/touchscreen/__init__.py +++ b/esphome/components/st7123/touchscreen/__init__.py @@ -27,7 +27,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/st7567_base/__init__.py b/esphome/components/st7567_base/__init__.py index a045cf34ccd..67f05157d97 100644 --- a/esphome/components/st7567_base/__init__.py +++ b/esphome/components/st7567_base/__init__.py @@ -39,9 +39,7 @@ ST7567_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( async def setup_st7567(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) cg.add(var.init_invert_colors(config[CONF_INVERT_COLORS])) diff --git a/esphome/components/st7567_spi/display.py b/esphome/components/st7567_spi/display.py index ad04b9f2125..ba4a08eaf45 100644 --- a/esphome/components/st7567_spi/display.py +++ b/esphome/components/st7567_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await st7567_base.setup_st7567(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 79430f14176..915d32b5380 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -209,13 +209,8 @@ async def to_code(config: ConfigType) -> None: data_pin = await cg.gpio_pin_expression(pin) cg.add(var.add_data_pin(data_pin, index)) - if dc_pin := config.get(CONF_DC_PIN): - dc = await cg.gpio_pin_expression(dc_pin) - cg.add(var.set_dc_pin(dc)) - - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_dc_pin, config.get(CONF_DC_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if transform := config.get(CONF_TRANSFORM): cg.add(var.set_mirror_x(transform[CONF_MIRROR_X])) @@ -236,11 +231,7 @@ async def to_code(config: ConfigType) -> None: await display.add_writer(var, config) - pin = await cg.gpio_pin_expression(config[CONF_DE_PIN]) - cg.add(var.set_de_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_PCLK_PIN]) - cg.add(var.set_pclk_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_HSYNC_PIN]) - cg.add(var.set_hsync_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_VSYNC_PIN]) - cg.add(var.set_vsync_pin(pin)) + await cg.add_pin(var.set_de_pin, config[CONF_DE_PIN]) + await cg.add_pin(var.set_pclk_pin, config[CONF_PCLK_PIN]) + await cg.add_pin(var.set_hsync_pin, config[CONF_HSYNC_PIN]) + await cg.add_pin(var.set_vsync_pin, config[CONF_VSYNC_PIN]) diff --git a/esphome/components/status_led/light/__init__.py b/esphome/components/status_led/light/__init__.py index f63cf14178b..a36de55f198 100644 --- a/esphome/components/status_led/light/__init__.py +++ b/esphome/components/status_led/light/__init__.py @@ -27,11 +27,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) - if CONF_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) - if CONF_OUTPUT in config: - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_pin(var.set_pin, config.get(CONF_PIN)) + await cg.add_variable(var.set_output, config.get(CONF_OUTPUT)) await cg.register_component(var, config) await light.register_light(var, config) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 3af042ea72b..2c14ea0bd56 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -249,13 +249,9 @@ async def to_code(config: ConfigType) -> None: ], config[CONF_ON_PACKET], ) - if CONF_DIO1_PIN in config: - dio1_pin = await cg.gpio_pin_expression(config[CONF_DIO1_PIN]) - cg.add(var.set_dio1_pin(dio1_pin)) - rst_pin = await cg.gpio_pin_expression(config[CONF_RST_PIN]) - cg.add(var.set_rst_pin(rst_pin)) - busy_pin = await cg.gpio_pin_expression(config[CONF_BUSY_PIN]) - cg.add(var.set_busy_pin(busy_pin)) + await cg.add_pin(var.set_dio1_pin, config.get(CONF_DIO1_PIN)) + await cg.add_pin(var.set_rst_pin, config[CONF_RST_PIN]) + await cg.add_pin(var.set_busy_pin, config[CONF_BUSY_PIN]) cg.add(var.set_bandwidth(config[CONF_BANDWIDTH])) cg.add(var.set_frequency(config[CONF_FREQUENCY])) cg.add(var.set_hw_version(config[CONF_HW_VERSION])) diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 3bd3b35a2ed..49e68c61844 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -232,11 +232,8 @@ async def to_code(config: ConfigType) -> None: ], config[CONF_ON_PACKET], ) - if CONF_DIO0_PIN in config: - dio0_pin = await cg.gpio_pin_expression(config[CONF_DIO0_PIN]) - cg.add(var.set_dio0_pin(dio0_pin)) - rst_pin = await cg.gpio_pin_expression(config[CONF_RST_PIN]) - cg.add(var.set_rst_pin(rst_pin)) + await cg.add_pin(var.set_dio0_pin, config.get(CONF_DIO0_PIN)) + await cg.add_pin(var.set_rst_pin, config[CONF_RST_PIN]) cg.add(var.set_auto_cal(config[CONF_AUTO_CAL])) cg.add(var.set_bandwidth(config[CONF_BANDWIDTH])) cg.add(var.set_frequency(config[CONF_FREQUENCY])) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 332d994d5e9..4a23e085ea0 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -175,6 +175,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_mixer_mode(config[CONF_MIXER_MODE])) cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) - if enable_pin_config := config.get(CONF_ENABLE_PIN): - enable_pin = await cg.gpio_pin_expression(enable_pin_config) - cg.add(var.set_enable_pin(enable_pin)) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py index ee9decd6680..2c33df3db91 100644 --- a/esphome/components/tca8418/__init__.py +++ b/esphome/components/tca8418/__init__.py @@ -145,8 +145,7 @@ async def to_code(config: ConfigType) -> None: 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))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) for conf in config.get(CONF_ON_KEY, []): await automation.build_callback_automation( diff --git a/esphome/components/tlc5947/__init__.py b/esphome/components/tlc5947/__init__.py index 71247dd13ef..1f1f4ea9c29 100644 --- a/esphome/components/tlc5947/__init__.py +++ b/esphome/components/tlc5947/__init__.py @@ -38,14 +38,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) - lat = await cg.gpio_pin_expression(config[CONF_LAT_PIN]) - cg.add(var.set_lat_pin(lat)) - if CONF_OE_PIN in config: - outenable = await cg.gpio_pin_expression(config[CONF_OE_PIN]) - cg.add(var.set_outenable_pin(outenable)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) + await cg.add_pin(var.set_lat_pin, config[CONF_LAT_PIN]) + await cg.add_pin(var.set_outenable_pin, config.get(CONF_OE_PIN)) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/tlc5971/__init__.py b/esphome/components/tlc5971/__init__.py index 26beec280e1..8e22b080f42 100644 --- a/esphome/components/tlc5971/__init__.py +++ b/esphome/components/tlc5971/__init__.py @@ -28,9 +28,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/tm1621/display.py b/esphome/components/tm1621/display.py index 4a83d363fe4..4765d950cbe 100644 --- a/esphome/components/tm1621/display.py +++ b/esphome/components/tm1621/display.py @@ -30,13 +30,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - cs = await cg.gpio_pin_expression(config[CONF_CS_PIN]) - cg.add(var.set_cs_pin(cs)) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - read = await cg.gpio_pin_expression(config[CONF_READ_PIN]) - cg.add(var.set_read_pin(read)) - write = await cg.gpio_pin_expression(config[CONF_WRITE_PIN]) - cg.add(var.set_write_pin(write)) + await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN]) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_read_pin, config[CONF_READ_PIN]) + await cg.add_pin(var.set_write_pin, config[CONF_WRITE_PIN]) await display.add_writer(var, config, TM1621DisplayRef) diff --git a/esphome/components/tm1637/display.py b/esphome/components/tm1637/display.py index ebdd9449d65..27289dea774 100644 --- a/esphome/components/tm1637/display.py +++ b/esphome/components/tm1637/display.py @@ -36,10 +36,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - clk = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_clk_pin(clk)) - dio = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) - cg.add(var.set_dio_pin(dio)) + await cg.add_pin(var.set_clk_pin, config[CONF_CLK_PIN]) + await cg.add_pin(var.set_dio_pin, config[CONF_DIO_PIN]) cg.add(var.set_intensity(config[CONF_INTENSITY])) cg.add(var.set_inverted(config[CONF_INVERTED])) diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index 74ee8b3293d..d7e375ac885 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -35,14 +35,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - clk = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_clk_pin(clk)) - - dio = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) - cg.add(var.set_dio_pin(dio)) - - stb = await cg.gpio_pin_expression(config[CONF_STB_PIN]) - cg.add(var.set_stb_pin(stb)) + await cg.add_pin(var.set_clk_pin, config[CONF_CLK_PIN]) + await cg.add_pin(var.set_dio_pin, config[CONF_DIO_PIN]) + await cg.add_pin(var.set_stb_pin, config[CONF_STB_PIN]) cg.add(var.set_intensity(config[CONF_INTENSITY])) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 14aa1c8842b..60cf1e542a1 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -43,10 +43,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - clk_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_clk_pin(clk_pin)) - dio_pin = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) - cg.add(var.set_dio_pin(dio_pin)) + await cg.add_pin(var.set_clk_pin, config[CONF_CLK_PIN]) + await cg.add_pin(var.set_dio_pin, config[CONF_DIO_PIN]) validate_brightness = cv.enum(TM1651_BRIGHTNESS_OPTIONS, int=True) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index 1018443e002..427b3fe83a0 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -176,8 +176,7 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: (CONF_INDEX_PIN, var.set_index_pin), (CONF_DIAG_PIN, var.set_diag_pin), ): - if (pin := config.get(key)) is not None: - cg.add(setter(await cg.gpio_pin_expression(pin))) + await cg.add_pin(setter, config.get(key)) for key, setter in ( (CONF_RSENSE, var.set_rsense), (CONF_VSENSE, var.set_vsense), diff --git a/esphome/components/tt21100/touchscreen/__init__.py b/esphome/components/tt21100/touchscreen/__init__.py index 22489df379e..7ef15f810d4 100644 --- a/esphome/components/tt21100/touchscreen/__init__.py +++ b/esphome/components/tt21100/touchscreen/__init__.py @@ -32,10 +32,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if CONF_INTERRUPT_PIN in config: - interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(interrupt_pin)) - - if CONF_RESET_PIN in config: - rts_pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(rts_pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/ttp229_bsf/__init__.py b/esphome/components/ttp229_bsf/__init__.py index 1b10940570b..274ea25fd7c 100644 --- a/esphome/components/ttp229_bsf/__init__.py +++ b/esphome/components/ttp229_bsf/__init__.py @@ -26,7 +26,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - sdo = await cg.gpio_pin_expression(config[CONF_SDO_PIN]) - cg.add(var.set_sdo_pin(sdo)) - scl = await cg.gpio_pin_expression(config[CONF_SCL_PIN]) - cg.add(var.set_scl_pin(scl)) + await cg.add_pin(var.set_sdo_pin, config[CONF_SDO_PIN]) + await cg.add_pin(var.set_scl_pin, config[CONF_SCL_PIN]) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 51db405512c..4a334e3e518 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -89,9 +89,7 @@ async def to_code(config): 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_)) + await cg.add_pin(var.set_status_pin, config.get(CONF_STATUS_PIN)) if CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS in config: for dp in config[CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS]: cg.add(var.add_ignore_mcu_update_on_datapoints(dp)) diff --git a/esphome/components/tuya/climate/__init__.py b/esphome/components/tuya/climate/__init__.py index cefe5d5a4b4..91ef9a6a68a 100644 --- a/esphome/components/tuya/climate/__init__.py +++ b/esphome/components/tuya/climate/__init__.py @@ -204,12 +204,8 @@ async def to_code(config): if switch_datapoint := config.get(CONF_SWITCH_DATAPOINT): cg.add(var.set_switch_id(switch_datapoint)) - if heating_state_pin_config := config.get(CONF_HEATING_STATE_PIN): - heating_state_pin = await cg.gpio_pin_expression(heating_state_pin_config) - cg.add(var.set_heating_state_pin(heating_state_pin)) - if cooling_state_pin_config := config.get(CONF_COOLING_STATE_PIN): - cooling_state_pin = await cg.gpio_pin_expression(cooling_state_pin_config) - cg.add(var.set_cooling_state_pin(cooling_state_pin)) + await cg.add_pin(var.set_heating_state_pin, config.get(CONF_HEATING_STATE_PIN)) + await cg.add_pin(var.set_cooling_state_pin, config.get(CONF_COOLING_STATE_PIN)) if active_state_config := config.get(CONF_ACTIVE_STATE): cg.add(var.set_active_state_id(active_state_config.get(CONF_DATAPOINT))) if (heating_value := active_state_config.get(CONF_HEATING_VALUE)) is not None: diff --git a/esphome/components/tx20/sensor.py b/esphome/components/tx20/sensor.py index 77418e5284f..fe2d1ba99f6 100644 --- a/esphome/components/tx20/sensor.py +++ b/esphome/components/tx20/sensor.py @@ -48,5 +48,4 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_WIND_SPEED, var.set_wind_speed_sensor) await sensors(CONF_WIND_DIRECTION_DEGREES, var.set_wind_direction_degrees_sensor) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/uln2003/stepper.py b/esphome/components/uln2003/stepper.py index f09b2702c71..bc656a5ff36 100644 --- a/esphome/components/uln2003/stepper.py +++ b/esphome/components/uln2003/stepper.py @@ -42,14 +42,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await stepper.register_stepper(var, config) - pin_a = await cg.gpio_pin_expression(config[CONF_PIN_A]) - cg.add(var.set_pin_a(pin_a)) - pin_b = await cg.gpio_pin_expression(config[CONF_PIN_B]) - cg.add(var.set_pin_b(pin_b)) - pin_c = await cg.gpio_pin_expression(config[CONF_PIN_C]) - cg.add(var.set_pin_c(pin_c)) - pin_d = await cg.gpio_pin_expression(config[CONF_PIN_D]) - cg.add(var.set_pin_d(pin_d)) + await cg.add_pin(var.set_pin_a, config[CONF_PIN_A]) + await cg.add_pin(var.set_pin_b, config[CONF_PIN_B]) + await cg.add_pin(var.set_pin_c, config[CONF_PIN_C]) + await cg.add_pin(var.set_pin_d, config[CONF_PIN_D]) cg.add(var.set_sleep_when_done(config[CONF_SLEEP_WHEN_DONE])) cg.add(var.set_step_mode(config[CONF_STEP_MODE])) diff --git a/esphome/components/ultrasonic/sensor.py b/esphome/components/ultrasonic/sensor.py index 25973447f6f..a978f36b90b 100644 --- a/esphome/components/ultrasonic/sensor.py +++ b/esphome/components/ultrasonic/sensor.py @@ -49,9 +49,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - trigger = await cg.gpio_pin_expression(config[CONF_TRIGGER_PIN]) - cg.add(var.set_trigger_pin(trigger)) - echo = await cg.gpio_pin_expression(config[CONF_ECHO_PIN]) - cg.add(var.set_echo_pin(echo)) + await cg.add_pin(var.set_trigger_pin, config[CONF_TRIGGER_PIN]) + await cg.add_pin(var.set_echo_pin, config[CONF_ECHO_PIN]) cg.add(var.set_timeout_us(config[CONF_TIMEOUT] / (0.000343 / 2))) cg.add(var.set_pulse_time_us(config[CONF_PULSE_TIME])) diff --git a/esphome/components/vl53l0x/sensor.py b/esphome/components/vl53l0x/sensor.py index 3029e0f77b1..8f6a97c384a 100644 --- a/esphome/components/vl53l0x/sensor.py +++ b/esphome/components/vl53l0x/sensor.py @@ -81,9 +81,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_long_range(config[CONF_LONG_RANGE])) cg.add(var.set_timeout_us(config[CONF_TIMEOUT])) - if CONF_ENABLE_PIN in config: - enable = await cg.gpio_pin_expression(config[CONF_ENABLE_PIN]) - cg.add(var.set_enable_pin(enable)) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) if timing_budget := config.get(CONF_TIMING_BUDGET): cg.add(var.set_timing_budget(timing_budget)) diff --git a/esphome/components/wiegand/__init__.py b/esphome/components/wiegand/__init__.py index 93d5e4f0f06..c59bf1c0785 100644 --- a/esphome/components/wiegand/__init__.py +++ b/esphome/components/wiegand/__init__.py @@ -56,10 +56,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_D0]) - cg.add(var.set_d0_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_D1]) - cg.add(var.set_d1_pin(pin)) + await cg.add_pin(var.set_d0_pin, config[CONF_D0]) + await cg.add_pin(var.set_d1_pin, config[CONF_D1]) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/x9c/output.py b/esphome/components/x9c/output.py index ff3f26e2abf..b927cfa906f 100644 --- a/esphome/components/x9c/output.py +++ b/esphome/components/x9c/output.py @@ -45,12 +45,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - cs_pin = await cg.gpio_pin_expression(config[CONF_CS_PIN]) - cg.add(var.set_cs_pin(cs_pin)) - inc_pin = await cg.gpio_pin_expression(config[CONF_INC_PIN]) - cg.add(var.set_inc_pin(inc_pin)) - ud_pin = await cg.gpio_pin_expression(config[CONF_UD_PIN]) - cg.add(var.set_ud_pin(ud_pin)) + await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN]) + await cg.add_pin(var.set_inc_pin, config[CONF_INC_PIN]) + await cg.add_pin(var.set_ud_pin, config[CONF_UD_PIN]) cg.add(var.set_initial_value(config[CONF_INITIAL_VALUE])) cg.add(var.set_step_delay(config[CONF_STEP_DELAY])) diff --git a/esphome/components/xpt2046/touchscreen/__init__.py b/esphome/components/xpt2046/touchscreen/__init__.py index e38bc23da37..a8bbe9d88fd 100644 --- a/esphome/components/xpt2046/touchscreen/__init__.py +++ b/esphome/components/xpt2046/touchscreen/__init__.py @@ -39,6 +39,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_threshold(config[CONF_THRESHOLD])) - if CONF_INTERRUPT_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_irq_pin(pin)) + await cg.add_pin(var.set_irq_pin, config.get(CONF_INTERRUPT_PIN)) diff --git a/esphome/components/zyaura/sensor.py b/esphome/components/zyaura/sensor.py index cfe980f037c..23fd3bc5bfb 100644 --- a/esphome/components/zyaura/sensor.py +++ b/esphome/components/zyaura/sensor.py @@ -18,7 +18,6 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) -from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType zyaura_ns = cg.esphome_ns.namespace("zyaura") @@ -56,10 +55,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin_clock = await gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_pin_clock(pin_clock)) - pin_data = await gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_pin_data(pin_data)) + await cg.add_pin(var.set_pin_clock, config[CONF_CLOCK_PIN]) + await cg.add_pin(var.set_pin_data, config[CONF_DATA_PIN]) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) From eca95348acb87f129f4fc650aa9078cfe3ba9cb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:16:50 -1000 Subject: [PATCH 575/651] [components] Assemble 24-bit readings with encode_uint24 (#21016) --- esphome/components/am2315c/am2315c.cpp | 2 +- esphome/components/bl0906/bl0906.cpp | 3 ++- esphome/components/bmp085/bmp085.cpp | 3 ++- esphome/components/bmp3xx_base/bmp3xx_base.cpp | 7 ++++--- esphome/components/bmp581_base/bmp581_base.cpp | 3 ++- esphome/components/dps310/dps310.cpp | 10 ++++------ esphome/components/ens210/ens210.cpp | 5 +++-- esphome/components/it8951/it8951.cpp | 2 +- esphome/components/ms5611/ms5611.cpp | 5 +++-- esphome/components/pzem004t/pzem004t.cpp | 3 ++- 10 files changed, 24 insertions(+), 19 deletions(-) diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp index 9b350560ee9..cd754411577 100644 --- a/esphome/components/am2315c/am2315c.cpp +++ b/esphome/components/am2315c/am2315c.cpp @@ -64,7 +64,7 @@ bool AM2315C::convert_(uint8_t *data, float &humidity, float &temperature) { uint32_t raw; raw = (data[1] << 12) | (data[2] << 4) | (data[3] >> 4); humidity = raw * 9.5367431640625e-5; - raw = ((data[3] & 0x0F) << 16) | (data[4] << 8) | data[5]; + raw = encode_uint24(data[3] & 0x0F, data[4], data[5]); temperature = raw * 1.9073486328125e-4 - 50; return crc8(data, 6, 0xFF, 0x31, true) == data[6]; } diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 35f663ffa0c..6f59da77d34 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -1,13 +1,14 @@ #include "bl0906.h" #include "constants.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bl0906 { ESPHOME_LOG_TAG(TAG, "bl0906"); -constexpr uint32_t to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } +constexpr uint32_t to_uint32_t(ube24_t input) { return encode_uint24(input.h, input.m, input.l); } constexpr int32_t to_int32_t(sbe24_t input) { return static_cast(encode_uint32((uint8_t) input.h, input.m, input.l, 0)) >> 8; diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index f9ec710a9b6..a7607d4dbd3 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -1,4 +1,5 @@ #include "bmp085.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bmp085 { @@ -93,7 +94,7 @@ void BMP085Component::read_pressure_() { return; } - uint32_t value = (uint32_t(buffer[0]) << 16) | (uint32_t(buffer[1]) << 8) | uint32_t(buffer[2]); + uint32_t value = encode_uint24(buffer[0], buffer[1], buffer[2]); if ((value >> 5) == 0) { ESP_LOGW(TAG, "Invalid pressure!"); this->status_set_warning(); diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index f59abc61ec3..f985ebed870 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -7,6 +7,7 @@ #include "bmp3xx_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" #include @@ -222,7 +223,7 @@ bool BMP3XXComponent::get_temperature(float &temperature) { return false; } // Copy the temperature data into the adc variables - int32_t adc_temp = (int32_t) data[2] << 16 | (int32_t) data[1] << 8 | (int32_t) data[0]; + int32_t adc_temp = encode_uint24(data[2], data[1], data[0]); // Temperature compensation (function from BMP388 datasheet) temperature = bmp388_compensate_temperature_((float) adc_temp); return true; @@ -249,8 +250,8 @@ bool BMP3XXComponent::get_measurements(float &temperature, float &pressure) { return false; } // Copy the temperature and pressure data into the adc variables - int32_t adc_pres = (int32_t) data[2] << 16 | (int32_t) data[1] << 8 | (int32_t) data[0]; - int32_t adc_temp = (int32_t) data[5] << 16 | (int32_t) data[4] << 8 | (int32_t) data[3]; + int32_t adc_pres = encode_uint24(data[2], data[1], data[0]); + int32_t adc_temp = encode_uint24(data[5], data[4], data[3]); // Temperature compensation (function from BMP388 datasheet) temperature = bmp388_compensate_temperature_((float) adc_temp); diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 7bc743977b2..9f67f734e6e 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -12,6 +12,7 @@ #include "bmp581_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -410,7 +411,7 @@ bool BMP581Component::read_temperature_and_pressure_(float &temperature, float & temperature = (float) (raw_temp / 65536.0); // convert measurement to degrees Celsius (page 22 of datasheet) // pressure MSB is in data[5], LSB is in data[4], XLSB in data[3] - int32_t raw_press = (int32_t) data[5] << 16 | (int32_t) data[4] << 8 | (int32_t) data[3]; + int32_t raw_press = encode_uint24(data[5], data[4], data[3]); pressure = (float) (raw_press / 64.0); // Divide by 2^6=64 for Pa (page 22 of datasheet) return true; diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 6ab786e7c71..5b3272b1d5e 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -1,4 +1,5 @@ #include "dps310.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -69,8 +70,7 @@ bool DPS310Component::setup_registers_() { (((uint32_t) coef_data_raw[5] >> 4) & 0x0F); this->c00_ = DPS310Component::twos_complement(c00_, 20); - this->c10_ = - (((uint32_t) coef_data_raw[5] & 0x0F) << 16) | ((uint32_t) coef_data_raw[6] << 8) | (uint32_t) coef_data_raw[7]; + this->c10_ = encode_uint24(coef_data_raw[5] & 0x0F, coef_data_raw[6], coef_data_raw[7]); this->c10_ = DPS310Component::twos_complement(c10_, 20); this->c01_ = int16_t(((uint16_t) coef_data_raw[8] << 8) | (uint16_t) coef_data_raw[9]); @@ -133,8 +133,7 @@ void DPS310Component::read_pressure_() { return; } this->got_pres_ = true; - this->raw_pressure_ = DPS310Component::twos_complement( - int32_t((uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2]))), 24); + this->raw_pressure_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); } void DPS310Component::read_temperature_() { @@ -144,8 +143,7 @@ void DPS310Component::read_temperature_() { return; } this->got_temp_ = true; - this->raw_temperature_ = DPS310Component::twos_complement( - int32_t((uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2]))), 24); + this->raw_temperature_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); } // Calculations are taken from the datasheet which can be found here: diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index 2251e0a574b..0d31a398177 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -7,6 +7,7 @@ // https://github.com/sciosense/ENS210_driver #include "ens210.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -160,7 +161,7 @@ void ENS210Component::update() { return; } // Pack bytes for humidity - h_val_data = (uint32_t) ((uint32_t) data[5] << 16 | (uint32_t) data[4] << 8 | (uint32_t) data[3]); + h_val_data = encode_uint24(data[5], data[4], data[3]); // Extract humidity data and update the status extract_measurement_(h_val_data, &humidity_data, &humidity_status); @@ -175,7 +176,7 @@ void ENS210Component::update() { return; } // Pack bytes for temperature - t_val_data = (uint32_t) ((uint32_t) data[2] << 16 | (uint32_t) data[1] << 8 | (uint32_t) data[0]); + t_val_data = encode_uint24(data[2], data[1], data[0]); // Extract temperature data and update the status extract_measurement_(t_val_data, &temperature_data, &temperature_status); diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 6b0a4b56094..e0d6f683f0d 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -988,7 +988,7 @@ void HOT IT8951Display::draw_pixels_internal(int x_start, int y_start, int w, in } case COLOR_BITNESS_888: { const size_t i = source_idx * 3; - color_value = ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + color_value = encode_uint24(ptr[i + 2], ptr[i + 1], ptr[i]); break; } default: diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index e3e5194c415..29d483dd228 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -1,4 +1,5 @@ #include "ms5611.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -52,7 +53,7 @@ void MS5611Component::read_temperature_() { this->status_set_warning(); return; } - const uint32_t raw_temperature = (uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2])); + const uint32_t raw_temperature = encode_uint24(bytes[0], bytes[1], bytes[2]); // request pressure reading if (!this->write_bytes(MS5611_CMD_CONV_D1 + 0x08, nullptr, 0)) { @@ -68,7 +69,7 @@ void MS5611Component::read_pressure_(uint32_t raw_temperature) { this->status_set_warning(); return; } - const uint32_t raw_pressure = (uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2])); + const uint32_t raw_pressure = encode_uint24(bytes[0], bytes[1], bytes[2]); this->calculate_values_(raw_temperature, raw_pressure); } diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 606a03e0c23..2c5e349bd31 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -1,4 +1,5 @@ #include "pzem004t.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/application.h" #include @@ -74,7 +75,7 @@ void PZEM004T::loop() { } case 0xA3: { // Energy Response - uint32_t energy = (uint32_t(resp[1]) << 16) | (uint32_t(resp[2]) << 8) | (uint32_t(resp[3])); + uint32_t energy = encode_uint24(resp[1], resp[2], resp[3]); if (this->energy_sensor_ != nullptr) this->energy_sensor_->publish_state(energy); ESP_LOGD(TAG, "Got Energy %" PRIu32 " Wh", energy); From 22a36de4aed7ef86840ed0a9e707a4d5c01f0308 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:20:58 -1000 Subject: [PATCH 576/651] [components] Decode little-endian I2C values with the new helpers (#20939) --- esphome/components/apds9930/apds9930.cpp | 7 +++--- esphome/components/as7341/as7341.cpp | 10 ++++---- esphome/components/as7341/as7341.h | 1 - esphome/components/as734x/as734x.cpp | 4 ++-- esphome/components/bh1745/bh1745.cpp | 13 +++++----- .../components/bme280_base/bme280_base.cpp | 4 ++-- esphome/components/bme280_base/bme280_base.h | 2 +- esphome/components/bme280_i2c/bme280_i2c.cpp | 4 ++-- esphome/components/bme280_i2c/bme280_i2c.h | 2 +- esphome/components/bme280_spi/bme280_spi.cpp | 7 +++--- esphome/components/bme280_spi/bme280_spi.h | 2 +- esphome/components/bmi160/bmi160.cpp | 17 ++----------- esphome/components/bmi160/bmi160.h | 3 --- esphome/components/bmi270/bmm150_aux.h | 2 +- .../components/bmp280_base/bmp280_base.cpp | 4 ++-- esphome/components/bmp280_base/bmp280_base.h | 2 +- esphome/components/bmp280_i2c/bmp280_i2c.h | 2 +- esphome/components/bmp280_spi/bmp280_spi.cpp | 7 +++--- esphome/components/bmp280_spi/bmp280_spi.h | 2 +- esphome/components/bq27220/bq27220.cpp | 11 +-------- esphome/components/bq27220/bq27220.h | 3 +-- esphome/components/bq76972/bq76972.cpp | 5 ++-- .../touchscreen/cst9220_touchscreen.cpp | 8 +++---- esphome/components/fusb302b/fusb302b.cpp | 8 +++---- .../gt911/touchscreen/gt911_touchscreen.cpp | 4 ++-- esphome/components/hdc2010/hdc2010.cpp | 5 ++-- esphome/components/hdc2080/hdc2080.cpp | 5 ++-- esphome/components/icm20689/icm20689.cpp | 14 +++++------ esphome/components/lis2dw12/lis2dw12.cpp | 6 ++--- esphome/components/lis3dh/lis3dh.cpp | 16 ++++++------- esphome/components/lps22/lps22.cpp | 3 ++- esphome/components/lsm6ds/lsm6ds.cpp | 18 +++++++------- esphome/components/mlx90614/mlx90614.cpp | 2 +- esphome/components/mpr121/mpr121.cpp | 7 +----- esphome/components/qmi8658/qmi8658.cpp | 14 +++++------ esphome/components/qmp6988/qmp6988.cpp | 20 ++++++++-------- .../spd2010/spd2010_touchscreen.cpp | 6 ++--- esphome/components/stts22h/stts22h.cpp | 3 ++- esphome/components/tca9555/tca9555.cpp | 24 +++++++------------ esphome/components/tcs34725/tcs34725.cpp | 12 +++++----- esphome/components/tfluna/tfluna.cpp | 8 +++---- esphome/components/tsl2561/tsl2561.cpp | 8 +------ esphome/components/veml3235/veml3235.cpp | 6 ++--- esphome/components/veml3235/veml3235.h | 10 +++----- esphome/components/xdb401/xdb401.cpp | 2 +- 45 files changed, 136 insertions(+), 187 deletions(-) diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index a4b836beaa1..eec741d5938 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -1,5 +1,6 @@ #include "apds9930.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -111,8 +112,8 @@ void APDS9930Component::update() { 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]); + const uint16_t ch0 = encode_value_le(&raw[1]); + const uint16_t ch1 = encode_value_le(&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"); @@ -125,7 +126,7 @@ void APDS9930Component::update() { } if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) { - this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5])); + this->proximity_sensor_->publish_state(encode_value_le(&raw[5])); } } diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index cf02884c5e4..a40b8b507ce 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -95,16 +95,16 @@ uint8_t AS7341Component::get_atime() { } uint16_t AS7341Component::get_astep() { - uint16_t data; - this->read_byte_16(AS7341_ASTEP, &data); - return this->swap_bytes(data); + uint16_t data = 0; + this->read_byte_16_le(AS7341_ASTEP, &data); + return data; } bool AS7341Component::setup_gain(AS7341Gain gain) { return this->write_byte(AS7341_CFG1, gain); } bool AS7341Component::setup_atime(uint8_t atime) { return this->write_byte(AS7341_ATIME, atime); } -bool AS7341Component::setup_astep(uint16_t astep) { return this->write_byte_16(AS7341_ASTEP, swap_bytes(astep)); } +bool AS7341Component::setup_astep(uint16_t astep) { return this->write_byte_16_le(AS7341_ASTEP, astep); } bool AS7341Component::read_channels(uint16_t *data) { this->set_smux_low_channels(true); @@ -253,6 +253,4 @@ bool AS7341Component::clear_register_bit(uint8_t address, uint8_t bit_position) return this->write_byte(address, data); } -uint16_t AS7341Component::swap_bytes(uint16_t data) { return (data >> 8) | (data << 8); } - } // namespace esphome::as7341 diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h index 2d72987f1cd..0ecc5dca419 100644 --- a/esphome/components/as7341/as7341.h +++ b/esphome/components/as7341/as7341.h @@ -118,7 +118,6 @@ class AS7341Component final : public PollingComponent, public i2c::I2CDevice { bool write_register_bit(uint8_t address, bool value, uint8_t bit_position); bool set_register_bit(uint8_t address, uint8_t bit_position); bool clear_register_bit(uint8_t address, uint8_t bit_position); - uint16_t swap_bytes(uint16_t data); protected: sensor::Sensor *f1_{nullptr}; diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 30412be70f5..5be4f4808ac 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -277,7 +277,7 @@ bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) { bool AS734XComponent::write_exposure_() { const ChipInfo *chip = this->chip_; if (!this->select_bank_(REG_ATIME) || !this->write_byte(REG_ATIME, this->atime_) || - !this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) || + !this->write_byte_16_le(pgm(chip->astep_reg), this->astep_) || !this->write_byte(pgm(chip->cfg1_reg), this->gain_)) { return false; } @@ -459,7 +459,7 @@ void AS734XComponent::read_step_() { if (frame[0] & ASTATUS_ASAT) { ESP_LOGV(TAG, "Step %u saturated", this->step_); } - auto slot_value = [&frame](uint8_t slot) { return encode_uint16(frame[2 + 2 * slot], frame[1 + 2 * slot]); }; + auto slot_value = [&frame](uint8_t slot) { return encode_value_le(&frame[1 + 2 * slot]); }; const uint8_t *readout = pgm(this->chip_->readout) + this->step_ * NUM_BANDS; const uint8_t extra_clear = pgm(this->chip_->extra_clear_slot); for (uint8_t band = 0; band < NUM_BANDS; band++) { diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index ee7b9935f4c..c34c2493310 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -133,18 +133,17 @@ void BH1745Component::update() { 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))) { + uint16_t buffer[BH1745_CHANNELS]; + if (!this->read_bytes_16_le(REG_RED_DATA_LSB, buffer, BH1745_CHANNELS)) { 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]); + const uint16_t red = buffer[0]; + const uint16_t green = buffer[1]; + const uint16_t blue = buffer[2]; + const uint16_t clear = buffer[3]; ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear); if (this->red_counts_sensor_ != nullptr) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index cf61160f77e..f922b1942b4 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -335,8 +335,8 @@ uint8_t BME280Component::read_u8_(uint8_t a_register) { } uint16_t BME280Component::read_u16_le_(uint8_t a_register) { uint16_t data = 0; - this->read_byte_16(a_register, &data); - return (data >> 8) | (data << 8); + this->read_byte_16_le(a_register, &data); + return data; } int16_t BME280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index d1a441ccebb..5ddb3cfd456 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -91,7 +91,7 @@ class BME280Component : public PollingComponent { virtual bool read_byte(uint8_t a_register, uint8_t *data) = 0; 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; - virtual bool read_byte_16(uint8_t a_register, uint16_t *data) = 0; + virtual bool read_byte_16_le(uint8_t a_register, uint16_t *data) = 0; BME280CalibrationData calibration_; BME280Oversampling temperature_oversampling_{BME280_OVERSAMPLING_16X}; diff --git a/esphome/components/bme280_i2c/bme280_i2c.cpp b/esphome/components/bme280_i2c/bme280_i2c.cpp index f4380b0d343..40f2384852b 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.cpp +++ b/esphome/components/bme280_i2c/bme280_i2c.cpp @@ -16,8 +16,8 @@ bool BME280I2CComponent::write_byte(uint8_t a_register, uint8_t data) { bool BME280I2CComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { return I2CDevice::read_bytes(a_register, data, len); }; -bool BME280I2CComponent::read_byte_16(uint8_t a_register, uint16_t *data) { - return I2CDevice::read_byte_16(a_register, data); +bool BME280I2CComponent::read_byte_16_le(uint8_t a_register, uint16_t *data) { + return I2CDevice::read_byte_16_le(a_register, data); }; void BME280I2CComponent::dump_config() { diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index 45c3e5b260f..a4817faece0 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -12,7 +12,7 @@ class BME280I2CComponent final : public esphome::bme280_base::BME280Component, p bool read_byte(uint8_t a_register, uint8_t *data) 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; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool read_byte_16_le(uint8_t a_register, uint16_t *data) override; void dump_config() override; }; diff --git a/esphome/components/bme280_spi/bme280_spi.cpp b/esphome/components/bme280_spi/bme280_spi.cpp index a4e7b7d95c3..b9473fb6985 100644 --- a/esphome/components/bme280_spi/bme280_spi.cpp +++ b/esphome/components/bme280_spi/bme280_spi.cpp @@ -3,6 +3,7 @@ #include "bme280_spi.h" #include +#include "esphome/core/helpers.h" namespace esphome::bme280_spi { @@ -51,11 +52,11 @@ bool BME280SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t le return true; } -bool BME280SPIComponent::read_byte_16(uint8_t a_register, uint16_t *data) { +bool BME280SPIComponent::read_byte_16_le(uint8_t a_register, uint16_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); - ((uint8_t *) data)[1] = this->transfer_byte(0); - ((uint8_t *) data)[0] = this->transfer_byte(0); + const uint8_t lsb = this->transfer_byte(0); + *data = encode_uint16(this->transfer_byte(0), lsb); this->disable(); return true; } diff --git a/esphome/components/bme280_spi/bme280_spi.h b/esphome/components/bme280_spi/bme280_spi.h index 3879151ea13..98f39f44b38 100644 --- a/esphome/components/bme280_spi/bme280_spi.h +++ b/esphome/components/bme280_spi/bme280_spi.h @@ -12,7 +12,7 @@ class BME280SPIComponent final : public esphome::bme280_base::BME280Component, bool read_byte(uint8_t a_register, uint8_t *data) 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; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool read_byte_16_le(uint8_t a_register, uint16_t *data) override; }; } // namespace esphome::bme280_spi diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index b97baae06ae..2a522c7a8ce 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -196,19 +196,6 @@ void BMI160Component::dump_config() { LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); } -i2c::ErrorCode BMI160Component::read_le_int16_(uint8_t reg, int16_t *value, uint8_t len) { - uint8_t raw_data[len * 2]; - // read using read_register because we have little-endian data, and read_bytes_16 will swap it - i2c::ErrorCode err = this->read_register(reg, raw_data, len * 2); - if (err != i2c::ERROR_OK) { - return err; - } - for (int i = 0; i < len; i++) { - value[i] = (int16_t) ((uint16_t) raw_data[i * 2] | ((uint16_t) raw_data[i * 2 + 1] << 8)); - } - return err; -} - void BMI160Component::update() { if (!this->setup_complete_) { return; @@ -216,7 +203,7 @@ void BMI160Component::update() { ESP_LOGV(TAG, " Updating BMI160"); int16_t data[6]; - if (this->read_le_int16_(BMI160_REGISTER_DATA_GYRO_X_LSB, data, 6) != i2c::ERROR_OK) { + if (!this->read_bytes_16_le(BMI160_REGISTER_DATA_GYRO_X_LSB, data, 6)) { this->status_set_warning(); return; } @@ -229,7 +216,7 @@ void BMI160Component::update() { float accel_z = (float) data[5] / (float) INT16_MAX * 16 * GRAVITY_EARTH; int16_t raw_temperature; - if (this->read_le_int16_(BMI160_REGISTER_DATA_TEMP_LSB, &raw_temperature, 1) != i2c::ERROR_OK) { + if (!this->read_bytes_16_le(BMI160_REGISTER_DATA_TEMP_LSB, &raw_temperature, 1)) { this->status_set_warning(); return; } diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index 53704965cd9..eb0b553cc24 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -34,9 +34,6 @@ class BMI160Component final : public PollingComponent, public i2c::I2CDevice { /// Run one setup stage; false on an I2C failure. bool setup_stage_(int stage); bool setup_complete_{false}; - - /** reads `len` 16-bit little-endian integers from the given i2c register */ - i2c::ErrorCode read_le_int16_(uint8_t reg, int16_t *value, uint8_t len); }; } // namespace esphome::bmi160 diff --git a/esphome/components/bmi270/bmm150_aux.h b/esphome/components/bmi270/bmm150_aux.h index 747a0898fa8..3e1c288b77a 100644 --- a/esphome/components/bmi270/bmm150_aux.h +++ b/esphome/components/bmi270/bmm150_aux.h @@ -61,7 +61,7 @@ struct BMM150Trim { // 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]); + return encode_value_le(®s[offset]); } // Decodes the trim registers; `regs` holds the bytes of registers 0x5D onwards (at least 21). diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index d82f80ccd43..10be0585210 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -247,8 +247,8 @@ uint8_t BMP280Component::read_u8_(uint8_t a_register) { } uint16_t BMP280Component::read_u16_le_(uint8_t a_register) { uint16_t data = 0; - this->bmp_read_byte_16(a_register, &data); - return (data >> 8) | (data << 8); + this->bmp_read_byte_16_le(a_register, &data); + return data; } int16_t BMP280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 1c3fc9cf6cf..cee8f43c7c2 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -69,7 +69,7 @@ class BMP280Component : public PollingComponent { virtual bool bmp_read_byte(uint8_t a_register, uint8_t *data) = 0; virtual bool bmp_write_byte(uint8_t a_register, uint8_t data) = 0; virtual bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; - virtual bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) = 0; + virtual bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) = 0; /// Read the temperature value and store the calculated ambient temperature in t_fine. float read_temperature_(int32_t *t_fine); diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index f2ec93176ef..441d1a4565f 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -16,7 +16,7 @@ class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, p bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { return read_bytes(a_register, data, len); } - bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override { return read_byte_16(a_register, data); } + bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) override { return read_byte_16_le(a_register, data); } void dump_config() override; }; diff --git a/esphome/components/bmp280_spi/bmp280_spi.cpp b/esphome/components/bmp280_spi/bmp280_spi.cpp index 04f92f9b899..5008d191392 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.cpp +++ b/esphome/components/bmp280_spi/bmp280_spi.cpp @@ -3,6 +3,7 @@ #include "bmp280_spi.h" #include +#include "esphome/core/helpers.h" namespace esphome::bmp280_spi { @@ -51,11 +52,11 @@ bool BMP280SPIComponent::bmp_read_bytes(uint8_t a_register, uint8_t *data, size_ return true; } -bool BMP280SPIComponent::bmp_read_byte_16(uint8_t a_register, uint16_t *data) { +bool BMP280SPIComponent::bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); - ((uint8_t *) data)[1] = this->transfer_byte(0); - ((uint8_t *) data)[0] = this->transfer_byte(0); + const uint8_t lsb = this->transfer_byte(0); + *data = encode_uint16(this->transfer_byte(0), lsb); this->disable(); return true; } diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index 449167811d3..fafc722c6cc 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -12,7 +12,7 @@ class BMP280SPIComponent final : public esphome::bmp280_base::BMP280Component, bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; bool bmp_write_byte(uint8_t a_register, uint8_t data) override; bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override; + bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) override; }; } // namespace esphome::bmp280_spi diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp index b7912b930e6..3c78893437d 100644 --- a/esphome/components/bq27220/bq27220.cpp +++ b/esphome/components/bq27220/bq27220.cpp @@ -25,15 +25,6 @@ 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) { @@ -42,7 +33,7 @@ void BQ27220Component::setup() { } 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)) { + if (!this->read_byte_16_le(REG_MAC_DATA, &device_number)) { this->mark_failed(LOG_STR("Failed to read device number from BQ27220")); return; } diff --git a/esphome/components/bq27220/bq27220.h b/esphome/components/bq27220/bq27220.h index d88b76a20f0..62cc4b659d0 100644 --- a/esphome/components/bq27220/bq27220.h +++ b/esphome/components/bq27220/bq27220.h @@ -25,14 +25,13 @@ class BQ27220Component final : public PollingComponent, public i2c::I2CDevice { 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)) { + if (!this->read_byte_16_le(reg, &raw)) { ok = false; sensor->publish_state(NAN); return; diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index 9426fbb2526..59d18878370 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -80,11 +80,10 @@ bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, } 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)) + if (!this->read_block_(start_register, reinterpret_cast(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]); + data[i] = convert_little_endian(data[i]); return true; } diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp index 366b1846d73..72803804335 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -51,9 +51,9 @@ void CST9220Touchscreen::continue_setup_() { // Read the panel resolution unless the user supplied calibration values. if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) { if (this->x_raw_max_ == this->x_raw_min_) - this->x_raw_max_ = encode_uint16(buffer[1], buffer[0]); + this->x_raw_max_ = encode_value_le(buffer); if (this->y_raw_max_ == this->y_raw_min_) - this->y_raw_max_ = encode_uint16(buffer[3], buffer[2]); + this->y_raw_max_ = encode_value_le(&buffer[2]); } // Read the chip type and project id and validate the controller. @@ -62,8 +62,8 @@ void CST9220Touchscreen::continue_setup_() { this->mark_failed(); return; } - this->chip_id_ = encode_uint16(buffer[3], buffer[2]); - this->project_id_ = encode_uint16(buffer[1], buffer[0]); + this->chip_id_ = encode_value_le(&buffer[2]); + this->project_id_ = encode_value_le(buffer); if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) { ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_); this->status_set_error(LOG_STR("Unknown chip ID")); diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp index 479981f8cac..608b356bbac 100644 --- a/esphome/components/fusb302b/fusb302b.cpp +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -343,16 +343,14 @@ bool FUSB302B::read_message_(PdMsg &msg, bool &is_sop) { uint8_t header[2]; if (!this->read_byte(FUSB_FIFOS, &token) || this->read_register(FUSB_FIFOS, header, 2) != i2c::ERROR_OK) return false; - msg.set_header(encode_uint16(header[1], header[0])); + msg.set_header(encode_value_le(header)); // Data objects, followed by the CRC32 that the chip already checked uint8_t buf[PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; if (this->read_register(FUSB_FIFOS, buf, msg.num_of_obj * 4 + 4) != i2c::ERROR_OK) return false; - for (uint8_t i = 0; i < msg.num_of_obj; i++) { - const uint8_t *obj = &buf[i * 4]; - msg.data_objects[i] = encode_uint32(obj[3], obj[2], obj[1], obj[0]); - } + for (uint8_t i = 0; i < msg.num_of_obj; i++) + msg.data_objects[i] = encode_value_le(&buf[i * 4]); is_sop = (token & FUSB_FIFO_RX_TOKEN_BITS) == FUSB_FIFO_RX_SOP; return true; } diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 388b8165fd7..fe251508e27 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -90,8 +90,8 @@ void GT911Touchscreen::setup_internal_() { if (err == i2c::ERROR_OK) { err = this->read(data, sizeof(data)); if (err == i2c::ERROR_OK) { - this->x_raw_max_ = encode_uint16(data[1], data[0]); - this->y_raw_max_ = encode_uint16(data[3], data[2]); + this->x_raw_max_ = encode_value_le(data); + this->y_raw_max_ = encode_value_le(&data[2]); if (this->swap_x_y_) std::swap(this->x_raw_max_, this->y_raw_max_); } diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index 471afe515a1..ecae9909390 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -1,4 +1,5 @@ #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "hdc2010.h" // https://github.com/vigsterkr/homebridge-hdc2010/blob/main/src/hdc2010.js // https://github.com/lime-labs/HDC2080-Arduino/blob/master/src/HDC2080.cpp @@ -76,7 +77,7 @@ float HDC2010Component::read_temp() { this->read_register(REG_TEMPERATURE_LOW, &byte[0], 1); this->read_register(REG_TEMPERATURE_HIGH, &byte[1], 1); - uint16_t temp = encode_uint16(byte[1], byte[0]); + uint16_t temp = encode_value_le(byte); return (float) temp * 0.0025177f - 40.0f; } @@ -86,7 +87,7 @@ float HDC2010Component::read_humidity() { this->read_register(REG_HUMIDITY_LOW, &byte[0], 1); this->read_register(REG_HUMIDITY_HIGH, &byte[1], 1); - uint16_t humidity = encode_uint16(byte[1], byte[0]); + uint16_t humidity = encode_value_le(byte); return (float) humidity * 0.001525879f; } diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index beb84f30746..8672dc497f5 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -1,5 +1,6 @@ #include "hdc2080.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::hdc2080 { @@ -55,11 +56,11 @@ void HDC2080Component::update() { } this->status_clear_warning(); if (this->temperature_sensor_ != nullptr) { - float temp = encode_uint16(raw_data[1], raw_data[0]) * (165.0f / 65536.0f) - 40.5f; + float temp = encode_value_le(raw_data) * (165.0f / 65536.0f) - 40.5f; this->temperature_sensor_->publish_state(temp); } if (this->humidity_sensor_ != nullptr) { - float humidity = encode_uint16(raw_data[3], raw_data[2]) * (100.0f / 65536.0f); + float humidity = encode_value_le(&raw_data[2]) * (100.0f / 65536.0f); this->humidity_sensor_->publish_state(humidity); } }); diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp index 3bca35dc254..69cb69c1f25 100644 --- a/esphome/components/icm20689/icm20689.cpp +++ b/esphome/components/icm20689/icm20689.cpp @@ -101,19 +101,19 @@ bool ICM20689Component::update_data(motion::MotionData &data) { const float accel_factor = accel_scale(accel_range); - data.acceleration[motion::X_AXIS] = static_cast(encode_uint16(raw_data[0], raw_data[1])) * accel_factor; - data.acceleration[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[2], raw_data[3])) * accel_factor; - data.acceleration[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[4], raw_data[5])) * accel_factor; + data.acceleration[motion::X_AXIS] = encode_value(raw_data) * accel_factor; + data.acceleration[motion::Y_AXIS] = encode_value(&raw_data[2]) * accel_factor; + data.acceleration[motion::Z_AXIS] = encode_value(&raw_data[4]) * accel_factor; const float gyro_factor = gyro_scale(gyro_range); - data.angular_rate[motion::X_AXIS] = static_cast(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor; - data.angular_rate[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor; - data.angular_rate[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor; + data.angular_rate[motion::X_AXIS] = encode_value(&raw_data[8]) * gyro_factor; + data.angular_rate[motion::Y_AXIS] = encode_value(&raw_data[10]) * gyro_factor; + data.angular_rate[motion::Z_AXIS] = encode_value(&raw_data[12]) * gyro_factor; if (!this->temperature_callback_.empty()) { // Datasheet formula: temperature (°C) = raw / 326.8 + 25. - const float temperature = static_cast(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f; + const float temperature = encode_value(&raw_data[6]) / 326.8f + 25.0f; this->temperature_callback_.call(temperature); } diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp index 21e1a07676f..7af60be87a4 100644 --- a/esphome/components/lis2dw12/lis2dw12.cpp +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -214,9 +214,9 @@ bool LIS2DW12Component::read_acceleration_data_(float &x, float &y, float &z) { 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_; + int16_t raw_x = encode_value_le(accel_data) >> this->shift_; + int16_t raw_y = encode_value_le(&accel_data[2]) >> this->shift_; + int16_t raw_z = encode_value_le(&accel_data[4]) >> this->shift_; if (this->swap_xy_) { std::swap(raw_x, raw_y); diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp index 64e6da4ef95..f5ade6ab0b3 100644 --- a/esphome/components/lis3dh/lis3dh.cpp +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -144,8 +144,8 @@ 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)) { + int16_t raw[3]; + if (!this->read_bytes_16_le(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 3)) { this->status_set_warning(LOG_STR("Failed to read acceleration data")); return false; } @@ -154,9 +154,9 @@ bool LIS3DHComponent::update_data(motion::MotionData &data) { 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]); + int16_t raw_x = raw[0]; + int16_t raw_y = raw[1]; + int16_t raw_z = raw[2]; 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; @@ -168,14 +168,14 @@ bool LIS3DHComponent::update_data(motion::MotionData &data) { } bool LIS3DHComponent::publish_temperature_() { - uint8_t raw[2]; - if (!this->read_bytes(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, raw, 2)) { + int16_t raw; + if (!this->read_bytes_16_le(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, &raw, 1)) { 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; + float temperature = raw / 256.0f + LIS3DH_TEMP_REFERENCE_C; this->temperature_callback_.call(temperature); return true; } diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 57f801c332b..47b00057c69 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -1,4 +1,5 @@ #include "lps22.h" +#include "esphome/core/helpers.h" namespace esphome::lps22 { @@ -65,7 +66,7 @@ void LPS22Component::try_read_() { if (this->temperature_sensor_ != nullptr) { uint8_t t_buf[2]{0}; this->read_register(TEMP_L, t_buf, 2); - int16_t encoded = static_cast(encode_uint16(t_buf[1], t_buf[0])); + int16_t encoded = encode_value_le(t_buf); float temp = TEMPERATURE_SCALE * static_cast(encoded); this->temperature_sensor_->publish_state(temp); } diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index e34735a4160..1d284d54ef0 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -1,6 +1,7 @@ #include "lsm6ds.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/progmem.h" namespace esphome::lsm6ds { @@ -166,9 +167,9 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { }; float gyro_scale = GYRO_SCALE[this->gyro_range_]; - data.angular_rate[motion::X_AXIS] = (int16_t) ((raw[1] << 8) | raw[0]) * gyro_scale; - data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw[3] << 8) | raw[2]) * gyro_scale; - data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw[5] << 8) | raw[4]) * gyro_scale; + data.angular_rate[motion::X_AXIS] = encode_value_le(raw) * gyro_scale; + data.angular_rate[motion::Y_AXIS] = encode_value_le(&raw[2]) * gyro_scale; + data.angular_rate[motion::Z_AXIS] = encode_value_le(&raw[4]) * gyro_scale; // Accelerometer // Sensitivity (mg/LSB) from datasheet Table 3. @@ -182,12 +183,9 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { }; float accel_scale = ACCEL_SCALE[this->accel_range_]; - data.acceleration[motion::X_AXIS] = - (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 1] << 8) | raw[LSM6DS_ACCEL_OFFSET + 0]) * accel_scale; - data.acceleration[motion::Y_AXIS] = - (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 3] << 8) | raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; - data.acceleration[motion::Z_AXIS] = - (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 5] << 8) | raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; + data.acceleration[motion::X_AXIS] = encode_value_le(&raw[LSM6DS_ACCEL_OFFSET]) * accel_scale; + data.acceleration[motion::Y_AXIS] = encode_value_le(&raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; + data.acceleration[motion::Z_AXIS] = encode_value_le(&raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; // Temperature (lazy — only read if a listener is registered) // Kept as a separate 2-byte read to avoid extending the burst to 14 bytes when @@ -196,7 +194,7 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { if (!this->temperature_callback_.empty()) { uint8_t raw_t[2]; if (this->read_bytes(LSM6DS_REG_OUT_TEMP_L, raw_t, 2)) { - int16_t temp_raw = (int16_t) ((raw_t[1] << 8) | raw_t[0]); + int16_t temp_raw = encode_value_le(raw_t); float temperature = temp_raw / static_cast(this->temperature_sensitivity_) + 25.0f; this->temperature_callback_.call(temperature); } diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index a67d770cc3d..353057dc34d 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -140,7 +140,7 @@ i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) { return i2c::ERROR_CRC; } - data = encode_uint16(buf[4], buf[3]); + data = encode_value_le(&buf[3]); return i2c::ERROR_OK; } diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 3d9cded59ff..bbcee223e8c 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -72,12 +72,7 @@ void MPR121Component::dump_config() { } void MPR121Component::loop() { uint16_t val = 0; - this->read_byte_16(MPR121_TOUCHSTATUS_L, &val); - - // Flip order - uint8_t lsb = val >> 8; - uint8_t msb = val; - val = (uint16_t(msb) << 8) | lsb; + this->read_byte_16_le(MPR121_TOUCHSTATUS_L, &val); for (auto *channel : this->channels_) channel->process(val); diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index f03aac9a80c..f6690e0af6b 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -99,18 +99,18 @@ bool QMI8658Component::update_data(motion::MotionData &data) { // Data is little-endian (low byte first). float scale = ACCEL_SCALE[this->accel_range_ >> 4]; - int16_t raw_x = encode_uint16(raw_data[ACC_OFFS + 1], raw_data[ACC_OFFS + 0]); - int16_t raw_y = encode_uint16(raw_data[ACC_OFFS + 3], raw_data[ACC_OFFS + 2]); - int16_t raw_z = encode_uint16(raw_data[ACC_OFFS + 5], raw_data[ACC_OFFS + 4]); + int16_t raw_x = encode_value_le(&raw_data[ACC_OFFS]); + int16_t raw_y = encode_value_le(&raw_data[ACC_OFFS + 2]); + int16_t raw_z = encode_value_le(&raw_data[ACC_OFFS + 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; scale = GYRO_SCALE[this->gyro_range_ >> 4]; - raw_x = encode_uint16(raw_data[GYR_OFFS + 1], raw_data[GYR_OFFS + 0]); - raw_y = encode_uint16(raw_data[GYR_OFFS + 3], raw_data[GYR_OFFS + 2]); - raw_z = encode_uint16(raw_data[GYR_OFFS + 5], raw_data[GYR_OFFS + 4]); + raw_x = encode_value_le(&raw_data[GYR_OFFS]); + raw_y = encode_value_le(&raw_data[GYR_OFFS + 2]); + raw_z = encode_value_le(&raw_data[GYR_OFFS + 4]); ESP_LOGV(TAG, "Read raw gyro data: %d, %d, %d", raw_x, raw_y, raw_z); data.angular_rate[motion::X_AXIS] = raw_x * scale; data.angular_rate[motion::Y_AXIS] = raw_y * scale; @@ -119,7 +119,7 @@ bool QMI8658Component::update_data(motion::MotionData &data) { if (this->temperature_callback_.empty()) return true; // Temperature: signed 16-bit, °C = raw / 256 - int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + int16_t raw_t = encode_value_le(&raw_data[TEMP_OFFS]); this->temperature_callback_.call(raw_t / 256.0f); return true; } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index ea3bf19eb4c..9dce402fe02 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -112,21 +112,21 @@ bool QMP6988Component::get_calibration_data_() { (int32_t) encode_uint32(a_data_uint8_tr[18], a_data_uint8_tr[19], (a_data_uint8_tr[24] & 0x0f) << 4, 0); qmp6988_data_.qmp6988_cali.COE_a0 = qmp6988_data_.qmp6988_cali.COE_a0 >> 12; - qmp6988_data_.qmp6988_cali.COE_a1 = (int16_t) encode_uint16(a_data_uint8_tr[20], a_data_uint8_tr[21]); - qmp6988_data_.qmp6988_cali.COE_a2 = (int16_t) encode_uint16(a_data_uint8_tr[22], a_data_uint8_tr[23]); + qmp6988_data_.qmp6988_cali.COE_a1 = encode_value(&a_data_uint8_tr[20]); + qmp6988_data_.qmp6988_cali.COE_a2 = encode_value(&a_data_uint8_tr[22]); qmp6988_data_.qmp6988_cali.COE_b00 = (int32_t) encode_uint32(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[24] & 0xf0, 0); qmp6988_data_.qmp6988_cali.COE_b00 = qmp6988_data_.qmp6988_cali.COE_b00 >> 12; - qmp6988_data_.qmp6988_cali.COE_bt1 = (int16_t) encode_uint16(a_data_uint8_tr[2], a_data_uint8_tr[3]); - qmp6988_data_.qmp6988_cali.COE_bt2 = (int16_t) encode_uint16(a_data_uint8_tr[4], a_data_uint8_tr[5]); - qmp6988_data_.qmp6988_cali.COE_bp1 = (int16_t) encode_uint16(a_data_uint8_tr[6], a_data_uint8_tr[7]); - qmp6988_data_.qmp6988_cali.COE_b11 = (int16_t) encode_uint16(a_data_uint8_tr[8], a_data_uint8_tr[9]); - qmp6988_data_.qmp6988_cali.COE_bp2 = (int16_t) encode_uint16(a_data_uint8_tr[10], a_data_uint8_tr[11]); - qmp6988_data_.qmp6988_cali.COE_b12 = (int16_t) encode_uint16(a_data_uint8_tr[12], a_data_uint8_tr[13]); - qmp6988_data_.qmp6988_cali.COE_b21 = (int16_t) encode_uint16(a_data_uint8_tr[14], a_data_uint8_tr[15]); - qmp6988_data_.qmp6988_cali.COE_bp3 = (int16_t) encode_uint16(a_data_uint8_tr[16], a_data_uint8_tr[17]); + qmp6988_data_.qmp6988_cali.COE_bt1 = encode_value(&a_data_uint8_tr[2]); + qmp6988_data_.qmp6988_cali.COE_bt2 = encode_value(&a_data_uint8_tr[4]); + qmp6988_data_.qmp6988_cali.COE_bp1 = encode_value(&a_data_uint8_tr[6]); + qmp6988_data_.qmp6988_cali.COE_b11 = encode_value(&a_data_uint8_tr[8]); + qmp6988_data_.qmp6988_cali.COE_bp2 = encode_value(&a_data_uint8_tr[10]); + qmp6988_data_.qmp6988_cali.COE_b12 = encode_value(&a_data_uint8_tr[12]); + qmp6988_data_.qmp6988_cali.COE_b21 = encode_value(&a_data_uint8_tr[14]); + qmp6988_data_.qmp6988_cali.COE_bp3 = encode_value(&a_data_uint8_tr[16]); ESP_LOGV(TAG, "Calibration data:\n" diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp index 94fad87d7d4..55fa177d18e 100644 --- a/esphome/components/spd2010/spd2010_touchscreen.cpp +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -88,7 +88,7 @@ void SPD2010Touchscreen::initialize_() { this->mark_failed(LOG_STR("Failed to read firmware version")); return; } - ESP_LOGD(TAG, "Firmware version: 0x%04X", encode_uint16(version[5], version[4])); + ESP_LOGD(TAG, "Firmware version: 0x%04X", encode_value_le(&version[4])); if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); @@ -104,7 +104,7 @@ bool SPD2010Touchscreen::finish_report_() { return false; if (status[5] == HDP_STATUS_DONE) return this->clear_interrupt_(); - const uint16_t length = encode_uint16(status[3], status[2]); + const uint16_t length = encode_value_le(&status[2]); if (status[5] != HDP_STATUS_MORE || length == 0 || length > MAX_REPORT_LENGTH) break; uint8_t remaining[MAX_REPORT_LENGTH]; @@ -125,7 +125,7 @@ bool SPD2010Touchscreen::read_data_() { } const uint8_t status_low = status[0]; const uint8_t status_high = status[1]; - const uint16_t length = encode_uint16(status[3], status[2]); + const uint16_t length = encode_value_le(&status[2]); if (status_high & STATUS_BIOS) { this->status_set_warning(LOG_STR("BIOS status; start CPU")); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index fa96c6a0227..aee4fc9c204 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "stts22h.h" @@ -48,7 +49,7 @@ float STTS22HComponent::read_temperature_() { // Combine the two bytes into a single 16-bit signed integer // The STTS22H temperature data is in two's complement format - int16_t temp_raw_value = static_cast(encode_uint16(temp_reg_value[1], temp_reg_value[0])); + int16_t temp_raw_value = encode_value_le(temp_reg_value); return temp_raw_value * SENSOR_SCALE; // Apply sensor resolution } diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 2b234983a20..15272e614fb 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -64,12 +64,12 @@ void TCA9555Component::loop() { bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) return false; - uint8_t data[2]; - if (!this->read_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { + uint16_t value; + if (!this->read_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, &value)) { this->status_set_warning(LOG_STR("Failed to read output register")); return false; } - this->output_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); + this->output_mask_ = value; this->status_clear_warning(); return true; } @@ -77,13 +77,12 @@ bool TCA9555Component::read_gpio_outputs_() { bool TCA9555Component::read_gpio_modes_() { if (this->is_failed()) return false; - uint8_t data[2]; - bool success = this->read_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2); - if (!success) { + uint16_t value; + if (!this->read_byte_16_le(TCA9555_CONFIGURATION_PORT_0, &value)) { this->status_set_warning(LOG_STR("Failed to read mode register")); return false; } - this->mode_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); + this->mode_mask_ = value; this->status_clear_warning(); return true; @@ -120,10 +119,7 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { this->output_mask_ &= ~(1 << pin); } - uint8_t data[2]; - data[0] = this->output_mask_; - data[1] = this->output_mask_ >> 8; - if (!this->write_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { + if (!this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_)) { this->status_set_warning(LOG_STR("Failed to write output register")); return; } @@ -134,11 +130,7 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { bool TCA9555Component::write_gpio_modes_() { if (this->is_failed()) return false; - uint8_t data[2]; - - data[0] = this->mode_mask_; - data[1] = this->mode_mask_ >> 8; - if (!this->write_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2)) { + if (!this->write_byte_16_le(TCA9555_CONFIGURATION_PORT_0, this->mode_mask_)) { this->status_set_warning(LOG_STR("Failed to write mode register")); return false; } diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 979137a9729..a8497e7a446 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -177,20 +177,20 @@ void TCS34725Component::calculate_temperature_and_lux_(uint16_t r, uint16_t g, u } void TCS34725Component::update() { - uint8_t data[8]; // Buffer to hold the 8 bytes (2 bytes for each of the 4 channels) + uint16_t data[4]; // Clear, red, green and blue channels // Perform burst - if (this->read_register(TCS34725_REGISTER_CRGBDATAL, data, 8) != i2c::ERROR_OK) { + if (!this->read_bytes_16_le(TCS34725_REGISTER_CRGBDATAL, data, 4)) { this->status_set_warning(); ESP_LOGW(TAG, "Error reading TCS34725 sensor data"); return; } // Extract the data - uint16_t raw_c = encode_uint16(data[1], data[0]); // Clear channel - uint16_t raw_r = encode_uint16(data[3], data[2]); // Red channel - uint16_t raw_g = encode_uint16(data[5], data[4]); // Green channel - uint16_t raw_b = encode_uint16(data[7], data[6]); // Blue channel + uint16_t raw_c = data[0]; // Clear channel + uint16_t raw_r = data[1]; // Red channel + uint16_t raw_g = data[2]; // Green channel + uint16_t raw_b = data[3]; // Blue channel ESP_LOGV(TAG, "Raw values clear=%d red=%d green=%d blue=%d", raw_c, raw_r, raw_g, raw_b); diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index 706213024d6..ac168327e09 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -94,10 +94,10 @@ bool TFLuna::read_data_() { // Layout: // buf[0..1] = distance (LE), buf[2..3] = signal (LE), // buf[4..5] = temperature (LE), buf[6..7] = timestamp (LE) - uint16_t distance = encode_uint16(buf[1], buf[0]); - uint16_t signal_strength = encode_uint16(buf[3], buf[2]); - int16_t temperature_raw = encode_uint16(buf[5], buf[4]); - uint16_t timestamp = encode_uint16(buf[7], buf[6]); + uint16_t distance = encode_value_le(buf); + uint16_t signal_strength = encode_value_le(&buf[2]); + int16_t temperature_raw = encode_value_le(&buf[4]); + uint16_t timestamp = encode_value_le(&buf[6]); if (timestamp == this->previous_timestamp_) { return false; diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index f887b7d32ca..0ae96c9fd1c 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -148,13 +148,7 @@ bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); } bool TSL2561Sensor::tsl2561_read_uint(uint8_t a_register, uint16_t *value) { - uint8_t data[2]; - if (!this->read_bytes(a_register | TSL2561_COMMAND_BIT, data, 2)) - return false; - const uint16_t hi = data[1]; - const uint16_t lo = data[0]; - *value = (hi << 8) | lo; - return true; + return this->read_byte_16_le(a_register | TSL2561_COMMAND_BIT, value); } bool TSL2561Sensor::tsl2561_read_byte(uint8_t a_register, uint8_t *value) { return this->read_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 09340f3bdee..ffaaf9eb2a3 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -33,14 +33,14 @@ void VEML3235Sensor::setup() { } bool VEML3235Sensor::refresh_config_reg() { - uint16_t data = 0x1; // mandatory 1 per RM; shutdown bits cleared (device powered on) + uint16_t data = 0x100; // mandatory 1 per RM; shutdown bits cleared (device powered on) data |= (uint16_t(this->integration_time_) << CONFIG_REG_IT_BIT); data |= (uint16_t(this->digital_gain_) << CONFIG_REG_DG_BIT); data |= (uint16_t(this->gain_) << CONFIG_REG_G_BIT); ESP_LOGVV(TAG, "Writing 0x%.4x to register 0x%.2x", data, CONFIG_REG); - return this->write_byte_16(CONFIG_REG, data); + return this->write_byte_16_le(CONFIG_REG, data); } void VEML3235Sensor::update() { @@ -63,7 +63,7 @@ void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { this->status_clear_warning(); - uint16_t als_counts = encode_uint16(als_regs[1], als_regs[0]); + uint16_t als_counts = encode_value_le(als_regs); if (this->auto_gain_ && adjustments_left > 0) { // A sample integrated with the previous settings may still be in the data register after the diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index c19fc17b65f..fb00a30326d 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -15,13 +15,9 @@ static const uint8_t ID_REG = 0x09; // Bit offsets within CONFIG_REG // -// The device expects the low data byte first while write_byte_16() sends the high byte first, so the 16-bit -// configuration word used here is byte-swapped relative to the datasheet: datasheet low-byte bits are word -// bits 15:8 here and datasheet high-byte bits are word bits 7:0. -// -static const uint8_t CONFIG_REG_IT_BIT = 12; -static const uint8_t CONFIG_REG_DG_BIT = 5; -static const uint8_t CONFIG_REG_G_BIT = 3; +static const uint8_t CONFIG_REG_IT_BIT = 4; +static const uint8_t CONFIG_REG_DG_BIT = 13; +static const uint8_t CONFIG_REG_G_BIT = 11; // Other important constants // diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp index 11a1a569f3f..721300bae74 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -149,7 +149,7 @@ i2c::ErrorCode XDB401Component::read_temperature_(float &temperature) { ESP_LOGV(TAG, "Got temperature data: %s", temperature_buf); // Temperature is a signed 16-bit big-endian value in 1/256 °C (Q8.8 fixed point). - int16_t raw_temperature = static_cast(encode_uint16(t_data[0], t_data[1])); + int16_t raw_temperature = encode_value(t_data); ESP_LOGD(TAG, "Temperature data raw %i", raw_temperature); temperature = static_cast(raw_temperature) / 256.0f; From a801e414a06e683195d9e0fbc2c8c4b899e25508 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:32:06 -1000 Subject: [PATCH 577/651] [components] Set referenced components with cg.add_variable and register_parented (m-z) (#21023) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/mitsubishi_cn105/__init__.py | 3 +-- esphome/components/modbus/__init__.py | 3 +-- esphome/components/monochromatic/light.py | 3 +-- .../mqtt_subscribe/sensor/__init__.py | 3 +-- .../mqtt_subscribe/text_sensor/__init__.py | 3 +-- esphome/components/my9231/output.py | 3 +-- esphome/components/nextion/base_component.py | 20 +++++++----------- esphome/components/ntc/sensor.py | 3 +-- esphome/components/one_wire/__init__.py | 3 +-- esphome/components/opentherm/__init__.py | 5 +++-- esphome/components/output/__init__.py | 4 +--- esphome/components/output/button/__init__.py | 3 +-- esphome/components/output/lock/__init__.py | 3 +-- esphome/components/output/switch/__init__.py | 3 +-- esphome/components/pid/climate.py | 17 ++++----------- .../pvvx_mithermometer/display/__init__.py | 4 +--- esphome/components/remote_base/__init__.py | 3 +-- .../components/resampler/speaker/__init__.py | 3 +-- esphome/components/resistance/sensor.py | 3 +-- esphome/components/rgb/light.py | 9 +++----- esphome/components/rgbct/light.py | 16 +++++--------- esphome/components/rgbw/light.py | 12 ++++------- esphome/components/rgbww/light.py | 16 +++++--------- esphome/components/router/speaker/__init__.py | 3 +-- .../components/rp2_ble_tracker/__init__.py | 3 +-- esphome/components/rtttl/__init__.py | 7 ++----- .../components/safe_mode/button/__init__.py | 3 +-- .../components/safe_mode/switch/__init__.py | 3 +-- esphome/components/scd4x/sensor.py | 7 ++++--- esphome/components/sen6x/sensor.py | 9 ++++---- esphome/components/sendspin/__init__.py | 3 +-- esphome/components/serial_proxy/__init__.py | 6 ++---- esphome/components/servo/__init__.py | 3 +-- esphome/components/sgp30/sensor.py | 10 +++++---- esphome/components/sgp4x/sensor.py | 10 +++++---- esphome/components/sm16716/output.py | 3 +-- esphome/components/sm2135/output.py | 3 +-- esphome/components/sm2235/output.py | 3 +-- esphome/components/sm2335/output.py | 3 +-- esphome/components/speaker/__init__.py | 4 +--- .../speaker/media_player/__init__.py | 10 +++++---- esphome/components/spi/__init__.py | 3 +-- esphome/components/sprinkler/__init__.py | 6 +----- esphome/components/stcc4/sensor.py | 21 +++++++------------ esphome/components/sun/__init__.py | 3 +-- esphome/components/sun/sensor/__init__.py | 3 +-- .../components/sun/text_sensor/__init__.py | 3 +-- .../switch/binary_sensor/__init__.py | 3 +-- .../sx126x/packet_transport/__init__.py | 3 +-- .../sx127x/packet_transport/__init__.py | 3 +-- esphome/components/thermostat/climate.py | 4 +--- esphome/components/tm1638/output/__init__.py | 3 +-- esphome/components/tm1638/switch/__init__.py | 3 +-- .../components/total_daily_energy/sensor.py | 6 ++---- esphome/components/touchscreen/__init__.py | 3 +-- .../touchscreen/binary_sensor/__init__.py | 3 +-- esphome/components/tuya/__init__.py | 3 +-- .../components/tuya/binary_sensor/__init__.py | 3 +-- esphome/components/tuya/climate/__init__.py | 3 +-- esphome/components/tuya/cover/__init__.py | 3 +-- esphome/components/tuya/light/__init__.py | 3 +-- esphome/components/tuya/number/__init__.py | 3 +-- esphome/components/tuya/select/__init__.py | 3 +-- esphome/components/tuya/sensor/__init__.py | 3 +-- esphome/components/tuya/switch/__init__.py | 3 +-- .../components/tuya/text_sensor/__init__.py | 3 +-- .../components/tuya/water_heater/__init__.py | 3 +-- esphome/components/ufire_ec/sensor.py | 6 +++--- esphome/components/ufire_ise/sensor.py | 6 +++--- esphome/components/uponor_smatrix/__init__.py | 3 +-- esphome/components/uptime/sensor/__init__.py | 3 +-- .../components/voice_assistant/__init__.py | 14 +++---------- esphome/components/wireguard/__init__.py | 2 +- esphome/components/yashima/climate.py | 8 ++----- esphome/components/zigbee/time/__init__.py | 3 +-- 75 files changed, 138 insertions(+), 248 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index ba51bd3a806..4037dd14d1f 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -85,8 +85,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_MITSUBISHI_CN105_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MITSUBISHI_CN105_ID]) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 8c079afbac3..a1f15aab452 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -382,8 +382,7 @@ def final_validate_modbus_device( async def register_modbus_client_device(var: MockObj, config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_MODBUS_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MODBUS_ID]) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/monochromatic/light.py b/esphome/components/monochromatic/light.py index 04336bcde1a..40e793a6058 100644 --- a/esphome/components/monochromatic/light.py +++ b/esphome/components/monochromatic/light.py @@ -21,5 +21,4 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/mqtt_subscribe/sensor/__init__.py b/esphome/components/mqtt_subscribe/sensor/__init__.py index e451d2db8ad..38eb47654ad 100644 --- a/esphome/components/mqtt_subscribe/sensor/__init__.py +++ b/esphome/components/mqtt_subscribe/sensor/__init__.py @@ -33,7 +33,6 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_MQTT_PARENT_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MQTT_PARENT_ID]) cg.add(var.set_topic(config[CONF_TOPIC])) cg.add(var.set_qos(config[CONF_QOS])) diff --git a/esphome/components/mqtt_subscribe/text_sensor/__init__.py b/esphome/components/mqtt_subscribe/text_sensor/__init__.py index d432ef8a931..5b195778c55 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/__init__.py +++ b/esphome/components/mqtt_subscribe/text_sensor/__init__.py @@ -31,7 +31,6 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_MQTT_PARENT_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MQTT_PARENT_ID]) cg.add(var.set_topic(config[CONF_TOPIC])) cg.add(var.set_qos(config[CONF_QOS])) diff --git a/esphome/components/my9231/output.py b/esphome/components/my9231/output.py index c2ec15d411b..1516640e0e7 100644 --- a/esphome/components/my9231/output.py +++ b/esphome/components/my9231/output.py @@ -24,6 +24,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_MY9231_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MY9231_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 17c1b263ed8..442eb979158 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -116,21 +116,17 @@ async def setup_component_core_(var, config, arg): ) ) - if CONF_BACKGROUND_COLOR in config: - color_component = await cg.get_variable(config[CONF_BACKGROUND_COLOR]) - cg.add(var.set_background_color(color_component)) + await cg.add_variable(var.set_background_color, config.get(CONF_BACKGROUND_COLOR)) - if CONF_BACKGROUND_PRESSED_COLOR in config: - color_component = await cg.get_variable(config[CONF_BACKGROUND_PRESSED_COLOR]) - cg.add(var.set_background_pressed_color(color_component)) + await cg.add_variable( + var.set_background_pressed_color, config.get(CONF_BACKGROUND_PRESSED_COLOR) + ) - if CONF_FOREGROUND_COLOR in config: - color_component = await cg.get_variable(config[CONF_FOREGROUND_COLOR]) - cg.add(var.set_foreground_color(color_component)) + await cg.add_variable(var.set_foreground_color, config.get(CONF_FOREGROUND_COLOR)) - if CONF_FOREGROUND_PRESSED_COLOR in config: - color_component = await cg.get_variable(config[CONF_FOREGROUND_PRESSED_COLOR]) - cg.add(var.set_foreground_pressed_color(color_component)) + await cg.add_variable( + var.set_foreground_pressed_color, config.get(CONF_FOREGROUND_PRESSED_COLOR) + ) if CONF_FONT_ID in config: cg.add(var.set_font_id(config[CONF_FONT_ID])) diff --git a/esphome/components/ntc/sensor.py b/esphome/components/ntc/sensor.py index 6c2cb699901..9ac69b595f6 100644 --- a/esphome/components/ntc/sensor.py +++ b/esphome/components/ntc/sensor.py @@ -138,8 +138,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) calib = config[CONF_CALIBRATION] cg.add(var.set_a(calib[CONF_A])) cg.add(var.set_b(calib[CONF_B])) diff --git a/esphome/components/one_wire/__init__.py b/esphome/components/one_wire/__init__.py index 69368a990ad..4a8edc656e2 100644 --- a/esphome/components/one_wire/__init__.py +++ b/esphome/components/one_wire/__init__.py @@ -37,8 +37,7 @@ async def register_one_wire_device(var: MockObj, config: ConfigType) -> None: This is a coroutine, you need to await it with an 'await' expression! """ - parent = await cg.get_variable(config[CONF_ONE_WIRE_ID]) - cg.add(var.set_one_wire_bus(parent)) + await cg.add_variable(var.set_one_wire_bus, config[CONF_ONE_WIRE_ID]) if (address := config.get(CONF_ADDRESS)) is not None: cg.add(var.set_address(address)) if (index := config.get(CONF_INDEX)) is not None: diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 3772034ab63..309d35aac19 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -103,8 +103,9 @@ async def to_code(config: dict[str, Any]) -> None: if key in non_sensors: continue if key in schema.INPUTS: - input_sensor = await cg.get_variable(value) - cg.add(getattr(var, f"set_{key}_{const.INPUT_SENSOR}")(input_sensor)) + await cg.add_variable( + getattr(var, f"set_{key}_{const.INPUT_SENSOR}"), value + ) input_sensors.append(key) elif key in schema.SETTINGS: if value == schema.SETTINGS[key].default_value: diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 1b794037848..ff7f55586da 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -59,9 +59,7 @@ FloatOutputPtr = FloatOutput.operator("ptr") async def setup_output_platform_(obj, config): if CONF_INVERTED in config: cg.add(obj.set_inverted(config[CONF_INVERTED])) - if CONF_POWER_SUPPLY in config: - power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) - cg.add(obj.set_power_supply(power_supply_)) + await cg.add_variable(obj.set_power_supply, config.get(CONF_POWER_SUPPLY)) # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when # the config matches them. The define stays whenever the key is present because # platforms such as ac_dimmer read the scaling fields directly. diff --git a/esphome/components/output/button/__init__.py b/esphome/components/output/button/__init__.py index 1d14fa5755f..95ad18a56b9 100644 --- a/esphome/components/output/button/__init__.py +++ b/esphome/components/output/button/__init__.py @@ -23,8 +23,7 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_duration(config[CONF_DURATION])) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) await cg.register_component(var, config) await button.register_button(var, config) diff --git a/esphome/components/output/lock/__init__.py b/esphome/components/output/lock/__init__.py index 553114b689c..ffca1a5c47c 100644 --- a/esphome/components/output/lock/__init__.py +++ b/esphome/components/output/lock/__init__.py @@ -22,5 +22,4 @@ async def to_code(config): var = await lock.new_lock(config) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/output/switch/__init__.py b/esphome/components/output/switch/__init__.py index e896b183cad..6d46f8b2d5e 100644 --- a/esphome/components/output/switch/__init__.py +++ b/esphome/components/output/switch/__init__.py @@ -25,5 +25,4 @@ async def to_code(config): var = await switch.new_switch(config) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 1527fa6f354..a6b5641eb70 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -100,19 +100,10 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) - - if CONF_HUMIDITY_SENSOR in config: - sens = await cg.get_variable(config[CONF_HUMIDITY_SENSOR]) - cg.add(var.set_humidity_sensor(sens)) - - if CONF_COOL_OUTPUT in config: - out = await cg.get_variable(config[CONF_COOL_OUTPUT]) - cg.add(var.set_cool_output(out)) - if CONF_HEAT_OUTPUT in config: - out = await cg.get_variable(config[CONF_HEAT_OUTPUT]) - cg.add(var.set_heat_output(out)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) + await cg.add_variable(var.set_cool_output, config.get(CONF_COOL_OUTPUT)) + await cg.add_variable(var.set_heat_output, config.get(CONF_HEAT_OUTPUT)) params = config[CONF_CONTROL_PARAMETERS] cg.add(var.set_kp(params[CONF_KP])) cg.add(var.set_ki(params[CONF_KI])) diff --git a/esphome/components/pvvx_mithermometer/display/__init__.py b/esphome/components/pvvx_mithermometer/display/__init__.py index 1b8c0b5be30..dcbb749d400 100644 --- a/esphome/components/pvvx_mithermometer/display/__init__.py +++ b/esphome/components/pvvx_mithermometer/display/__init__.py @@ -44,8 +44,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auto_clear(config[CONF_AUTO_CLEAR_ENABLED])) cg.add(var.set_validity_period(config[CONF_VALIDITY_PERIOD].total_seconds)) - if CONF_TIME_ID in config: - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.add_variable(var.set_time, config.get(CONF_TIME_ID)) await display.add_writer(var, config, PVVXDisplayRef) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 990d2b87e4d..b01b0f3645e 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -154,8 +154,7 @@ async def attach_receiver( async def register_transmittable(var, config, key: str = CONF_TRANSMITTER_ID): - transmitter_ = await cg.get_variable(config[key]) - cg.add(var.set_transmitter(transmitter_)) + await cg.add_variable(var.set_transmitter, config[key]) # Registry names that share a protocol source file diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 8fa8aeb61c4..5b82f149799 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -86,8 +86,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await speaker.register_speaker(var, config) - output_spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER]) - cg.add(var.set_output_speaker(output_spkr)) + await cg.add_variable(var.set_output_speaker, config[CONF_OUTPUT_SPEAKER]) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) diff --git a/esphome/components/resistance/sensor.py b/esphome/components/resistance/sensor.py index cbf329713df..c76d910df53 100644 --- a/esphome/components/resistance/sensor.py +++ b/esphome/components/resistance/sensor.py @@ -43,8 +43,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) cg.add(var.set_configuration(config[CONF_CONFIGURATION])) cg.add(var.set_resistor(config[CONF_RESISTOR])) cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) diff --git a/esphome/components/rgb/light.py b/esphome/components/rgb/light.py index cd553292569..0a1ec2eddef 100644 --- a/esphome/components/rgb/light.py +++ b/esphome/components/rgb/light.py @@ -21,9 +21,6 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) diff --git a/esphome/components/rgbct/light.py b/esphome/components/rgbct/light.py index 00626ec6ba2..43e31a011bf 100644 --- a/esphome/components/rgbct/light.py +++ b/esphome/components/rgbct/light.py @@ -42,17 +42,11 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) - - color_temp = await cg.get_variable(config[CONF_COLOR_TEMPERATURE]) - cg.add(var.set_color_temperature(color_temp)) - white_brightness = await cg.get_variable(config[CONF_WHITE_BRIGHTNESS]) - cg.add(var.set_white_brightness(white_brightness)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) + await cg.add_variable(var.set_color_temperature, config[CONF_COLOR_TEMPERATURE]) + await cg.add_variable(var.set_white_brightness, config[CONF_WHITE_BRIGHTNESS]) cg.add(var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE])) cg.add(var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE])) diff --git a/esphome/components/rgbw/light.py b/esphome/components/rgbw/light.py index 7aedf4affc3..2875eba1978 100644 --- a/esphome/components/rgbw/light.py +++ b/esphome/components/rgbw/light.py @@ -30,12 +30,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) - white = await cg.get_variable(config[CONF_WHITE]) - cg.add(var.set_white(white)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) + await cg.add_variable(var.set_white, config[CONF_WHITE]) cg.add(var.set_color_interlock(config[CONF_COLOR_INTERLOCK])) diff --git a/esphome/components/rgbww/light.py b/esphome/components/rgbww/light.py index 3626a57b8c1..8881af6af45 100644 --- a/esphome/components/rgbww/light.py +++ b/esphome/components/rgbww/light.py @@ -45,22 +45,16 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) - - cwhite = await cg.get_variable(config[CONF_COLD_WHITE]) - cg.add(var.set_cold_white(cwhite)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) + await cg.add_variable(var.set_cold_white, config[CONF_COLD_WHITE]) if CONF_COLD_WHITE_COLOR_TEMPERATURE in config: cg.add( var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE]) ) - wwhite = await cg.get_variable(config[CONF_WARM_WHITE]) - cg.add(var.set_warm_white(wwhite)) + await cg.add_variable(var.set_warm_white, config[CONF_WARM_WHITE]) if CONF_WARM_WHITE_COLOR_TEMPERATURE in config: cg.add( var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE]) diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py index f568aedcbdf..90e9febed5c 100644 --- a/esphome/components/router/speaker/__init__.py +++ b/esphome/components/router/speaker/__init__.py @@ -86,8 +86,7 @@ async def to_code(config: ConfigType) -> None: speakers = config[CONF_OUTPUT_SPEAKERS] cg.add(var.set_output_count(len(speakers))) for spk_id in speakers: - spk = await cg.get_variable(spk_id) - cg.add(var.add_output(spk)) + await cg.add_variable(var.add_output, spk_id) automation.register_apply_action( diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 4a3d3f788bf..fdfc2a1f967 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -95,8 +95,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_RP2040_BLE_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_RP2040_BLE_ID]) # The tracker registers itself as a controller scan listener in setup(); # request the codegen-sized StaticVector slot for it. rp2040_ble.request_scan_listener_slot() diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 8bff8bb58dc..c987e0327c5 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -84,13 +84,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) if CONF_OUTPUT in config: - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) cg.add_define("USE_OUTPUT") - if CONF_SPEAKER in config: - out = await cg.get_variable(config[CONF_SPEAKER]) - cg.add(var.set_speaker(out)) + await cg.add_variable(var.set_speaker, config.get(CONF_SPEAKER)) cg.add(var.set_gain(config[CONF_GAIN])) diff --git a/esphome/components/safe_mode/button/__init__.py b/esphome/components/safe_mode/button/__init__.py index 89e24757996..d0ef080f10e 100644 --- a/esphome/components/safe_mode/button/__init__.py +++ b/esphome/components/safe_mode/button/__init__.py @@ -31,5 +31,4 @@ async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) - safe_mode_component = await cg.get_variable(config[CONF_SAFE_MODE]) - cg.add(var.set_safe_mode(safe_mode_component)) + await cg.add_variable(var.set_safe_mode, config[CONF_SAFE_MODE]) diff --git a/esphome/components/safe_mode/switch/__init__.py b/esphome/components/safe_mode/switch/__init__.py index 529b023d689..432d7a76154 100644 --- a/esphome/components/safe_mode/switch/__init__.py +++ b/esphome/components/safe_mode/switch/__init__.py @@ -26,5 +26,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - safe_mode_component = await cg.get_variable(config[CONF_SAFE_MODE]) - cg.add(var.set_safe_mode(safe_mode_component)) + await cg.add_variable(var.set_safe_mode, config[CONF_SAFE_MODE]) diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 12a07a3f63a..4b67319efb1 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -112,9 +112,10 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(config[key]) cg.add(getattr(var, funcName)(sens)) - if CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE in config: - sens = await cg.get_variable(config[CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE]) - cg.add(var.set_ambient_pressure_source(sens)) + await cg.add_variable( + var.set_ambient_pressure_source, + config.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), + ) cg.add(var.set_measurement_mode(config[CONF_MEASUREMENT_MODE])) diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index b928f97774e..d961d05bc85 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -326,11 +326,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_altitude_compensation(altitude)) if (pressure := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION)) is not None: cg.add(var.set_ambient_pressure_compensation(pressure)) - if ( - source := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE) - ) is not None: - sens = await cg.get_variable(source) - cg.add(var.set_ambient_pressure_source(sens)) + await cg.add_variable( + var.set_ambient_pressure_source, + co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), + ) SEN6X_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index c557a7960d2..3caad51f919 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -428,8 +428,7 @@ async def to_code(config: ConfigType) -> None: # Service starts disabled and the hub enables it; always advertised where unsupported if mdns.request_service_enable_disable(): - mdns_var = await cg.get_variable(CORE.config[MDNS_DOMAIN][CONF_ID]) - cg.add(var.set_mdns(mdns_var)) + await cg.add_variable(var.set_mdns, CORE.config[MDNS_DOMAIN][CONF_ID]) data = _get_data() diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 27d69c97346..d1b4e889066 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -101,12 +101,10 @@ async def to_code(config: ConfigType) -> None: # port_type names the electrical interface (a USB RS485 adapter is RS485), so every # usb_uart channel reports USB identity whatever port type it declares if is_usb_uart_channel(config[CONF_UART_ID], CORE.config): - channel = await cg.get_variable(config[CONF_UART_ID]) - cg.add(var.set_usb_channel(channel)) + await cg.add_variable(var.set_usb_channel, config[CONF_UART_ID]) cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY") if uart.is_host_uart(config[CONF_UART_ID], CORE.config): - host_uart = await cg.get_variable(config[CONF_UART_ID]) - cg.add(var.set_host_uart(host_uart)) + await cg.add_variable(var.set_host_uart, config[CONF_UART_ID]) cg.add_define("USE_SERIAL_PROXY_HOST_UART") cg.add_define("USE_SERIAL_PROXY") diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 44ec294765b..fdfb892d3e1 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -43,8 +43,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) cg.add(var.set_min_level(config[CONF_MIN_LEVEL])) cg.add(var.set_idle_level(config[CONF_IDLE_LEVEL])) cg.add(var.set_max_level(config[CONF_MAX_LEVEL])) diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index 5d4c43fccae..87b0d3aa2c0 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -98,7 +98,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_tvoc_baseline(baseline_config[CONF_TVOC_BASELINE])) if compensation_config := config.get(CONF_COMPENSATION): - sens = await cg.get_variable(compensation_config[CONF_HUMIDITY_SOURCE]) - cg.add(var.set_humidity_sensor(sens)) - sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE_SOURCE]) - cg.add(var.set_temperature_sensor(sens)) + await cg.add_variable( + var.set_humidity_sensor, compensation_config[CONF_HUMIDITY_SOURCE] + ) + await cg.add_variable( + var.set_temperature_sensor, compensation_config[CONF_TEMPERATURE_SOURCE] + ) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 866445f2464..78792c80102 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -103,10 +103,12 @@ async def to_code(config: ConfigType) -> None: if CONF_COMPENSATION in config: compensation_config = config[CONF_COMPENSATION] - sens = await cg.get_variable(compensation_config[CONF_HUMIDITY_SOURCE]) - cg.add(var.set_humidity_sensor(sens)) - sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE_SOURCE]) - cg.add(var.set_temperature_sensor(sens)) + await cg.add_variable( + var.set_humidity_sensor, compensation_config[CONF_HUMIDITY_SOURCE] + ) + await cg.add_variable( + var.set_temperature_sensor, compensation_config[CONF_TEMPERATURE_SOURCE] + ) cg.add(var.set_store_baseline(config[CONF_STORE_BASELINE])) diff --git a/esphome/components/sm16716/output.py b/esphome/components/sm16716/output.py index 08cec24fd64..c4f6981c04e 100644 --- a/esphome/components/sm16716/output.py +++ b/esphome/components/sm16716/output.py @@ -24,6 +24,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM16716_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM16716_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/sm2135/output.py b/esphome/components/sm2135/output.py index 80b4cb1a4f2..b7a49374045 100644 --- a/esphome/components/sm2135/output.py +++ b/esphome/components/sm2135/output.py @@ -25,6 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM2135_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM2135_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/sm2235/output.py b/esphome/components/sm2235/output.py index 29940a651e8..be590a3bd95 100644 --- a/esphome/components/sm2235/output.py +++ b/esphome/components/sm2235/output.py @@ -25,6 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM2235_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM2235_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/sm2335/output.py b/esphome/components/sm2335/output.py index 30f653b087a..ce047cbb96e 100644 --- a/esphome/components/sm2335/output.py +++ b/esphome/components/sm2335/output.py @@ -25,6 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM2335_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM2335_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index d34cfc32c0f..e1df66f6841 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -22,9 +22,7 @@ PlayAction = speaker_ns.class_( async def setup_speaker_core_(var, config): - if audio_dac_config := config.get(CONF_AUDIO_DAC): - aud_dac = await cg.get_variable(audio_dac_config) - cg.add(var.set_audio_dac(aud_dac)) + await cg.add_variable(var.set_audio_dac, config.get(CONF_AUDIO_DAC)) async def register_speaker(var, config): diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index b37a288d874..83f406e9e65 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -184,8 +184,9 @@ async def to_code(config): cg.add(var.set_volume_min(config[CONF_VOLUME_MIN])) announcement_pipeline_config = config[CONF_ANNOUNCEMENT_PIPELINE] - spkr = await cg.get_variable(announcement_pipeline_config[CONF_SPEAKER]) - cg.add(var.set_announcement_speaker(spkr)) + await cg.add_variable( + var.set_announcement_speaker, announcement_pipeline_config[CONF_SPEAKER] + ) if announcement_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_announcement_format( @@ -196,8 +197,9 @@ async def to_code(config): ) if media_pipeline_config := config.get(CONF_MEDIA_PIPELINE): - spkr = await cg.get_variable(media_pipeline_config[CONF_SPEAKER]) - cg.add(var.set_media_speaker(spkr)) + await cg.add_variable( + var.set_media_speaker, media_pipeline_config[CONF_SPEAKER] + ) if media_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_media_format( diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 151f228651e..8ff1842a7c1 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -484,8 +484,7 @@ def spi_device_schema( async def register_spi_device( var: cg.MockObj, config: ConfigType, write_only: bool = False ) -> None: - parent = await cg.get_variable(config[CONF_SPI_ID]) - cg.add(var.set_spi_parent(parent)) + await cg.add_variable(var.set_spi_parent, config[CONF_SPI_ID]) if write_only: cg.add(var.set_write_only(True)) await cg.add_pin(var.set_cs_pin, config.get(CONF_CS_PIN)) diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index fe096514d07..557b24505bd 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -733,8 +733,4 @@ async def to_code(config): var = await cg.get_variable(sprinkler_controller[CONF_ID]) for controller_to_add in config: if sprinkler_controller[CONF_ID] != controller_to_add[CONF_ID]: - cg.add( - var.add_controller( - await cg.get_variable(controller_to_add[CONF_ID]) - ) - ) + await cg.add_variable(var.add_controller, controller_to_add[CONF_ID]) diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index b103aa39534..4ea1bc6ed81 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -107,13 +107,11 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) await sensors(CONF_HUMIDITY, var.set_humidity_sensor) - if (temperature_source := config.get(CONF_TEMPERATURE_SOURCE)) is not None: - sens = await cg.get_variable(temperature_source) - cg.add(var.set_temperature_source(sens)) + await cg.add_variable( + var.set_temperature_source, config.get(CONF_TEMPERATURE_SOURCE) + ) - if (humidity_source := config.get(CONF_HUMIDITY_SOURCE)) is not None: - sens = await cg.get_variable(humidity_source) - cg.add(var.set_humidity_source(sens)) + await cg.add_variable(var.set_humidity_source, config.get(CONF_HUMIDITY_SOURCE)) if ( ambient_pressure_compensation := config.get(CONF_AMBIENT_PRESSURE_COMPENSATION) @@ -124,12 +122,9 @@ async def to_code(config: ConfigType) -> None: ) ) - if ( - ambient_pressure_compensation_source := config.get( - CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE - ) - ) is not None: - sens = await cg.get_variable(ambient_pressure_compensation_source) - cg.add(var.set_ambient_pressure_source(sens)) + await cg.add_variable( + var.set_ambient_pressure_source, + config.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), + ) cg.add(var.set_measurement_mode(config[CONF_MEASUREMENT_MODE])) diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index 71441e97823..c104316d475 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -118,8 +118,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.add_variable(var.set_time, config[CONF_TIME_ID]) cg.add(var.set_latitude(config[CONF_LATITUDE])) cg.add(var.set_longitude(config[CONF_LONGITUDE])) diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index ef4e51d5e5e..b6981422a05 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -40,5 +40,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_type(config[CONF_TYPE])) - paren = await cg.get_variable(config[CONF_SUN_ID]) - cg.add(var.set_parent(paren)) + await cg.register_parented(var, config[CONF_SUN_ID]) diff --git a/esphome/components/sun/text_sensor/__init__.py b/esphome/components/sun/text_sensor/__init__.py index 523471bd41e..b7db7cd39d9 100644 --- a/esphome/components/sun/text_sensor/__init__.py +++ b/esphome/components/sun/text_sensor/__init__.py @@ -53,8 +53,7 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_SUN_ID]) - cg.add(var.set_parent(paren)) + await cg.register_parented(var, config[CONF_SUN_ID]) cg.add(var.set_sunrise(SUN_TYPES[config[CONF_TYPE]])) cg.add(var.set_elevation(config[CONF_ELEVATION])) cg.add(var.set_format(config[CONF_FORMAT])) diff --git a/esphome/components/switch/binary_sensor/__init__.py b/esphome/components/switch/binary_sensor/__init__.py index 61ca1a14a24..ece53c0deb3 100644 --- a/esphome/components/switch/binary_sensor/__init__.py +++ b/esphome/components/switch/binary_sensor/__init__.py @@ -27,5 +27,4 @@ async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/sx126x/packet_transport/__init__.py b/esphome/components/sx126x/packet_transport/__init__.py index fc3bbf6df25..0c3ec56fcc0 100644 --- a/esphome/components/sx126x/packet_transport/__init__.py +++ b/esphome/components/sx126x/packet_transport/__init__.py @@ -23,5 +23,4 @@ CONFIG_SCHEMA = transport_schema(SX126xTransport).extend( async def to_code(config: ConfigType) -> None: var, _ = await new_packet_transport(config) - sx126x = await cg.get_variable(config[CONF_SX126X_ID]) - cg.add(var.set_parent(sx126x)) + await cg.register_parented(var, config[CONF_SX126X_ID]) diff --git a/esphome/components/sx127x/packet_transport/__init__.py b/esphome/components/sx127x/packet_transport/__init__.py index 33204a7d83d..0ec1a0f6950 100644 --- a/esphome/components/sx127x/packet_transport/__init__.py +++ b/esphome/components/sx127x/packet_transport/__init__.py @@ -23,5 +23,4 @@ CONFIG_SCHEMA = transport_schema(SX127xTransport).extend( async def to_code(config: ConfigType) -> None: var, _ = await new_packet_transport(config) - sx127x = await cg.get_variable(config[CONF_SX127X_ID]) - cg.add(var.set_parent(sx127x)) + await cg.register_parented(var, config[CONF_SX127X_ID]) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index bebdd7d0660..3e6d63adbb7 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -716,9 +716,7 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_sensor(sens)) - if CONF_HUMIDITY_SENSOR in config: - sens = await cg.get_variable(config[CONF_HUMIDITY_SENSOR]) - cg.add(var.set_humidity_sensor(sens)) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) cg.add(var.set_cool_deadband(config[CONF_COOL_DEADBAND])) cg.add(var.set_cool_overrun(config[CONF_COOL_OVERRUN])) diff --git a/esphome/components/tm1638/output/__init__.py b/esphome/components/tm1638/output/__init__.py index 961abfee476..4b85e73c88b 100644 --- a/esphome/components/tm1638/output/__init__.py +++ b/esphome/components/tm1638/output/__init__.py @@ -23,5 +23,4 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) await cg.register_component(var, config) cg.add(var.set_lednum(config[CONF_LED])) - hub = await cg.get_variable(config[CONF_TM1638_ID]) - cg.add(var.set_tm1638(hub)) + await cg.add_variable(var.set_tm1638, config[CONF_TM1638_ID]) diff --git a/esphome/components/tm1638/switch/__init__.py b/esphome/components/tm1638/switch/__init__.py index f42b835e039..eee55dc118e 100644 --- a/esphome/components/tm1638/switch/__init__.py +++ b/esphome/components/tm1638/switch/__init__.py @@ -25,5 +25,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) cg.add(var.set_lednum(config[CONF_LED])) - hub = await cg.get_variable(config[CONF_TM1638_ID]) - cg.add(var.set_tm1638(hub)) + await cg.add_variable(var.set_tm1638, config[CONF_TM1638_ID]) diff --git a/esphome/components/total_daily_energy/sensor.py b/esphome/components/total_daily_energy/sensor.py index af2f5ff561c..304688de07a 100644 --- a/esphome/components/total_daily_energy/sensor.py +++ b/esphome/components/total_daily_energy/sensor.py @@ -83,9 +83,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_POWER_ID]) - cg.add(var.set_parent(sens)) - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.register_parented(var, config[CONF_POWER_ID]) + await cg.add_variable(var.set_time, config[CONF_TIME_ID]) cg.add(var.set_restore(config[CONF_RESTORE])) cg.add(var.set_method(config[CONF_METHOD])) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index 7a23d140690..36679e828b7 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -155,8 +155,7 @@ TOUCHSCREEN_SCHEMA = touchscreen_schema(cv.UNDEFINED) async def register_touchscreen(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - disp = await cg.get_variable(config[CONF_DISPLAY]) - cg.add(var.set_display(disp)) + await cg.add_variable(var.set_display, config[CONF_DISPLAY]) if CONF_TOUCH_TIMEOUT in config: cg.add(var.set_touch_timeout(config[CONF_TOUCH_TIMEOUT])) diff --git a/esphome/components/touchscreen/binary_sensor/__init__.py b/esphome/components/touchscreen/binary_sensor/__init__.py index 6a66d00ea6c..fccaf26c39a 100644 --- a/esphome/components/touchscreen/binary_sensor/__init__.py +++ b/esphome/components/touchscreen/binary_sensor/__init__.py @@ -83,5 +83,4 @@ async def to_code(config: ConfigType) -> None: ) for page_id in config.get(CONF_PAGES, []): - page = await cg.get_variable(page_id) - cg.add(var.add_page(page)) + await cg.add_variable(var.add_page, page_id) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 4a334e3e518..0ec7452e379 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -85,8 +85,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await uart.new_uart_component(config) 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_)) + await cg.add_variable(var.set_time_id, time_id) if config[CONF_FORCE_TIME_SYNC]: cg.add(var.set_force_time_sync(True)) await cg.add_pin(var.set_status_pin, config.get(CONF_STATUS_PIN)) diff --git a/esphome/components/tuya/binary_sensor/__init__.py b/esphome/components/tuya/binary_sensor/__init__.py index 767d1c636e5..049386461af 100644 --- a/esphome/components/tuya/binary_sensor/__init__.py +++ b/esphome/components/tuya/binary_sensor/__init__.py @@ -28,7 +28,6 @@ async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_sensor_id(config[CONF_SENSOR_DATAPOINT])) diff --git a/esphome/components/tuya/climate/__init__.py b/esphome/components/tuya/climate/__init__.py index 91ef9a6a68a..65045e2cd27 100644 --- a/esphome/components/tuya/climate/__init__.py +++ b/esphome/components/tuya/climate/__init__.py @@ -196,8 +196,7 @@ async def to_code(config): var = await climate.new_climate(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) diff --git a/esphome/components/tuya/cover/__init__.py b/esphome/components/tuya/cover/__init__.py index 6e1daef884a..c93fdf55445 100644 --- a/esphome/components/tuya/cover/__init__.py +++ b/esphome/components/tuya/cover/__init__.py @@ -69,5 +69,4 @@ async def to_code(config): cg.add(var.set_invert_position(config[CONF_INVERT_POSITION])) cg.add(var.set_invert_position_report(config[CONF_INVERT_POSITION_REPORT])) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index 5ba55b6ccbb..9811995ad0c 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -112,5 +112,4 @@ async def to_code(config): ) cg.add(var.set_color_interlock(config[CONF_COLOR_INTERLOCK])) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) diff --git a/esphome/components/tuya/number/__init__.py b/esphome/components/tuya/number/__init__.py index 7a61e69c5cf..dcc2c15347b 100644 --- a/esphome/components/tuya/number/__init__.py +++ b/esphome/components/tuya/number/__init__.py @@ -85,8 +85,7 @@ async def to_code(config): ) cg.add(var.set_write_multiply(config[CONF_MULTIPLY])) - parent = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(parent)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_number_id(config[CONF_NUMBER_DATAPOINT])) if hidden_config := config.get(CONF_DATAPOINT_HIDDEN): diff --git a/esphome/components/tuya/select/__init__.py b/esphome/components/tuya/select/__init__.py index 9f2b6f1e12a..84819f8f497 100644 --- a/esphome/components/tuya/select/__init__.py +++ b/esphome/components/tuya/select/__init__.py @@ -52,8 +52,7 @@ async def to_code(config): var = await select.new_select(config, options=list(options_map.values())) await cg.register_component(var, config) cg.add(var.set_select_mappings(list(options_map.keys()))) - parent = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(parent)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) if (enum_datapoint := config.get(CONF_ENUM_DATAPOINT, None)) is not None: cg.add(var.set_select_id(enum_datapoint, False)) if (int_datapoint := config.get(CONF_INT_DATAPOINT, None)) is not None: diff --git a/esphome/components/tuya/sensor/__init__.py b/esphome/components/tuya/sensor/__init__.py index 861bbc656aa..191f1095e6d 100644 --- a/esphome/components/tuya/sensor/__init__.py +++ b/esphome/components/tuya/sensor/__init__.py @@ -27,7 +27,6 @@ async def to_code(config): await cg.register_component(var, config) await sensor.register_sensor(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_sensor_id(config[CONF_SENSOR_DATAPOINT])) diff --git a/esphome/components/tuya/switch/__init__.py b/esphome/components/tuya/switch/__init__.py index e9987050cb0..fc43b39b9c0 100644 --- a/esphome/components/tuya/switch/__init__.py +++ b/esphome/components/tuya/switch/__init__.py @@ -26,7 +26,6 @@ async def to_code(config): var = await switch.new_switch(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) diff --git a/esphome/components/tuya/text_sensor/__init__.py b/esphome/components/tuya/text_sensor/__init__.py index 3bae109b93a..af84d32f124 100644 --- a/esphome/components/tuya/text_sensor/__init__.py +++ b/esphome/components/tuya/text_sensor/__init__.py @@ -27,7 +27,6 @@ async def to_code(config): var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_sensor_id(config[CONF_SENSOR_DATAPOINT])) diff --git a/esphome/components/tuya/water_heater/__init__.py b/esphome/components/tuya/water_heater/__init__.py index 7d1af791b4f..96b7136c725 100644 --- a/esphome/components/tuya/water_heater/__init__.py +++ b/esphome/components/tuya/water_heater/__init__.py @@ -89,8 +89,7 @@ async def to_code(config: ConfigType) -> None: var = await water_heater.new_water_heater(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 6a632e05cd5..de68f56046b 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -68,9 +68,9 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) await sensors(CONF_EC, var.set_ec_sensor) - if CONF_TEMPERATURE_SENSOR in config: - sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) - cg.add(var.set_temperature_sensor_external(sens)) + await cg.add_variable( + var.set_temperature_sensor_external, config.get(CONF_TEMPERATURE_SENSOR) + ) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index 6f205af784d..f9aae747689 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -60,9 +60,9 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) await sensors(CONF_PH, var.set_ph_sensor) - if CONF_TEMPERATURE_SENSOR in config: - sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) - cg.add(var.set_temperature_sensor_external(sens)) + await cg.add_variable( + var.set_temperature_sensor_external, config.get(CONF_TEMPERATURE_SENSOR) + ) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index e23f4157a7e..d4d03d3e0f5 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -69,8 +69,7 @@ async def to_code(config: ConfigType) -> None: var = await uart.new_uart_component(config) if time_id := config.get(CONF_TIME_ID): - time_ = await cg.get_variable(time_id) - cg.add(var.set_time_id(time_)) + await cg.add_variable(var.set_time_id, time_id) if time_device_address := config.get(CONF_TIME_DEVICE_ADDRESS): cg.add(var.set_time_device_address(time_device_address)) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 04140ba431d..619d9ca8864 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -61,8 +61,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) if time_id_config := config.get(CONF_TIME_ID): - time_id = await cg.get_variable(time_id_config) - cg.add(var.set_time(time_id)) + await cg.add_variable(var.set_time, time_id_config) cg.add_define("USE_UPTIME_TIMESTAMP") diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 488e16a3839..1b7ad38c1c5 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -207,17 +207,9 @@ async def to_code(config: ConfigType) -> None: mic_source2 = await microphone.microphone_source_to_code(mic_sources[1]) cg.add(var.set_microphone_source2(mic_source2)) - if CONF_MICRO_WAKE_WORD in config: - mww = await cg.get_variable(config[CONF_MICRO_WAKE_WORD]) - cg.add(var.set_micro_wake_word(mww)) - - if CONF_MEDIA_PLAYER in config: - mp = await cg.get_variable(config[CONF_MEDIA_PLAYER]) - cg.add(var.set_media_player(mp)) - - if CONF_SPEAKER in config: - spkr = await cg.get_variable(config[CONF_SPEAKER]) - cg.add(var.set_speaker(spkr)) + await cg.add_variable(var.set_micro_wake_word, config.get(CONF_MICRO_WAKE_WORD)) + await cg.add_variable(var.set_media_player, config.get(CONF_MEDIA_PLAYER)) + await cg.add_variable(var.set_speaker, config.get(CONF_SPEAKER)) cg.add(var.set_use_wake_word(config[CONF_USE_WAKE_WORD])) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index e9641a5957c..07cb154c91c 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -137,7 +137,7 @@ async def to_code(config): ) ) - cg.add(var.set_srctime(await cg.get_variable(config[CONF_TIME_ID]))) + await cg.add_variable(var.set_srctime, config[CONF_TIME_ID]) if config[CONF_REQUIRE_CONNECTION_TO_PROCEED]: cg.add(var.disable_auto_proceed()) diff --git a/esphome/components/yashima/climate.py b/esphome/components/yashima/climate.py index cfc4744d82c..25e06b37586 100644 --- a/esphome/components/yashima/climate.py +++ b/esphome/components/yashima/climate.py @@ -32,9 +32,5 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) - if CONF_SENSOR in config: - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) - - transmitter = await cg.get_variable(config[CONF_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_transmitter, config[CONF_TRANSMITTER_ID]) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 0572e591cfc..dbbb6734920 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -113,5 +113,4 @@ async def _add_time_zephyr(config: ConfigType) -> None: cg.add(var.set_endpoint(slot_index + 1)) cg.add(var.set_cluster_attributes(time_attrs)) - hub = await cg.get_variable(config[CONF_ZIGBEE_ID]) - cg.add(var.set_parent(hub)) + await cg.register_parented(var, config[CONF_ZIGBEE_ID]) From b2d6c7849928b082d0af909b85bf1cb20886f2a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:18:47 -1000 Subject: [PATCH 578/651] [light] Add `LightState::set_external_state()` for outputs that mirror a light elsewhere (#20787) --- esphome/components/light/light_state.cpp | 23 +++ esphome/components/light/light_state.h | 5 + .../light/test_light_set_external_state.cpp | 179 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 tests/components/light/test_light_set_external_state.cpp diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 885440187f9..d8293338897 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -181,6 +181,29 @@ void LightState::loop() { } } +bool LightState::set_external_state(const LightColorValues &values) { + // Either would write its own values over the reported ones + if (this->next_write_ || this->transformer_ != nullptr) { + return false; + } + const bool had_effect = this->active_effect_index_ != 0; + this->stop_effect_(); // schedules no write, unlike a call that sets effect 0 + if (!had_effect && values == this->remote_values && values == this->current_values) { + return true; // nothing new to tell + } + this->current_values = values; + this->remote_values = values; + // Reached at once, as after an instant call, and told before the publish like there: an + // automation run by the publish may change the light again, and its own call reports that + if (this->target_state_reached_listeners_) { + for (auto *listener : *this->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } + } + this->publish_state(); + return true; +} + void LightState::publish_state() { if (this->remote_values_listeners_) { for (auto *listener : *this->remote_values_listeners_) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 2a99943e1df..946b4efae58 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -92,6 +92,11 @@ class LightState : public EntityBase, public Component { bool is_transitioning() const { return this->transformer_ != nullptr; } LightCall make_call(); + /// Take over the state of a light mirrored from elsewhere: current and target values at once, + /// published but not written or saved; a running effect stops. False while a write is pending or + /// a transition or flash runs. + bool set_external_state(const LightColorValues &values); + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Load state from preferences diff --git a/tests/components/light/test_light_set_external_state.cpp b/tests/components/light/test_light_set_external_state.cpp new file mode 100644 index 00000000000..624d374519a --- /dev/null +++ b/tests/components/light/test_light_set_external_state.cpp @@ -0,0 +1,179 @@ +#include + +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +// Counts the writes, like an output that mirrors a light elsewhere and sends each one on +class CountingOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + void write_state(LightState *state) override { this->writes++; } + int writes{0}; +}; + +class NoopEffect : public LightEffect { + public: + using LightEffect::LightEffect; + void apply() override {} +}; + +class ReachedListener : public LightTargetStateReachedListener { + public: + void on_light_target_state_reached() override { this->reached++; } + int reached{0}; +}; + +class PublishListener : public LightRemoteValuesListener { + public: + void on_light_remote_values_update() override { this->published++; } + int published{0}; +}; + +// Counts the target-reached calls that find the light off, as the on_turn_off trigger does +class OffReachedListener : public LightTargetStateReachedListener { + public: + explicit OffReachedListener(LightState *state) : state_(state) {} + void on_light_target_state_reached() override { this->reached_off += !this->state_->current_values.is_on(); } + int reached_off{0}; + + protected: + LightState *state_; +}; + +// An automation that turns the light off as soon as it is reported on +class TurnOffOnPublish : public LightRemoteValuesListener { + public: + explicit TurnOffOnPublish(LightState *state) : state_(state) {} + void on_light_remote_values_update() override { + if (this->state_->remote_values.is_on()) { + this->state_->make_call().set_state(false).perform(); + } + } + + protected: + LightState *state_; +}; + +LightColorValues on_at(float brightness) { + LightColorValues values; + values.set_color_mode(ColorMode::BRIGHTNESS); + values.set_state(true); + values.set_brightness(brightness); + return values; +} + +} // namespace + +TEST(LightStateExternal, TakesTheValuesWithoutAWrite) { + CountingOutput output; + LightState state(&output); + ReachedListener listener; + state.add_target_state_reached_listener(&listener); + state.loop(); // the initial write of a fresh state + const int writes = output.writes; + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(state.remote_values, on_at(0.5f)); + EXPECT_EQ(state.current_values, on_at(0.5f)); + EXPECT_EQ(listener.reached, 1); + state.loop(); + EXPECT_EQ(output.writes, writes); +} + +TEST(LightStateExternal, RefusesWhileALocalChangeWaitsToBeWritten) { + CountingOutput output; + LightState state(&output); + state.loop(); + + state.make_call().set_state(true).set_brightness(0.8f).perform(); + EXPECT_FALSE(state.set_external_state(on_at(0.5f))); + EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 0.8f); + state.loop(); // the local change is written + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); +} + +TEST(LightStateExternal, RefusesDuringATransition) { + CountingOutput output; + LightState state(&output); + state.loop(); + + state.make_call().set_state(true).set_brightness(1.0f).set_transition_length(1000).perform(); + state.loop(); + EXPECT_TRUE(state.is_transitioning()); + EXPECT_FALSE(state.set_external_state(on_at(0.5f))); +} + +TEST(LightStateExternal, StopsARunningEffect) { + CountingOutput output; + LightState state(&output); + NoopEffect effect("Noop"); + LightEffect *effects[] = {&effect}; + state.add_effects(effects, 1); + state.loop(); + + state.make_call().set_state(true).set_effect("Noop").perform(); + state.loop(); + ASSERT_EQ(state.get_current_effect_index(), 1u); + const auto writes = output.writes; + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(state.get_current_effect_index(), 0u); + EXPECT_EQ(state.remote_values, on_at(0.5f)); + state.loop(); + EXPECT_EQ(output.writes, writes); // the stopped effect schedules no write either +} + +TEST(LightStateExternal, PublishesWhenOnlyAnEffectStops) { + CountingOutput output; + LightState state(&output); + NoopEffect effect("Noop"); + LightEffect *effects[] = {&effect}; + state.add_effects(effects, 1); + PublishListener listener; + state.add_remote_values_listener(&listener); + state.loop(); + + state.make_call().set_state(true).set_effect("Noop").perform(); + state.loop(); + const int published = listener.published; + // The values stay, but the effect name clients show has to go + EXPECT_TRUE(state.set_external_state(state.remote_values)); + EXPECT_EQ(state.get_current_effect_index(), 0u); + EXPECT_EQ(listener.published, published + 1); +} + +TEST(LightStateExternal, AnAutomationRunByThePublishReportsItsOwnCall) { + CountingOutput output; + LightState state(&output); + OffReachedListener reached(&state); + state.add_target_state_reached_listener(&reached); + TurnOffOnPublish automation(&state); + state.add_remote_values_listener(&automation); + state.loop(); + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_FALSE(state.remote_values.is_on()); + EXPECT_EQ(reached.reached_off, 1); // once from the turn-off call, not again from the report +} + +TEST(LightStateExternal, SaysNothingWhenNothingChanges) { + CountingOutput output; + LightState state(&output); + ReachedListener listener; + state.add_target_state_reached_listener(&listener); + state.loop(); + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(listener.reached, 1); +} + +} // namespace esphome::light::testing From c35d98b622cd04e508e050d8731680444909b1f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:19:14 -1000 Subject: [PATCH 579/651] [core] Add `progmem_string_table_find()` (#20790) --- esphome/core/progmem.h | 14 +++++++ .../core/test_progmem_string_table_find.cpp | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/components/core/test_progmem_string_table_find.cpp diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 02ec4778237..983f2e31f85 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -155,4 +155,18 @@ struct LogString; } \ } +/// The index of the string value (len bytes, in RAM) in a PROGMEM_STRING_TABLE, or -1 when it is +/// not one of its strings. One pass over the blob, so a table without offsets costs the same. +template int16_t progmem_string_table_find(const char *value, size_t len) { + const char *entry = Table::BLOB.data(); + for (uint8_t i = 0; i < Table::COUNT; i++) { + const size_t entry_len = ESPHOME_strlen_P(entry); + if (entry_len == len && ESPHOME_strncmp_P(value, entry, len) == 0) { + return i; + } + entry += entry_len + 1; + } + return -1; +} + } // namespace esphome diff --git a/tests/components/core/test_progmem_string_table_find.cpp b/tests/components/core/test_progmem_string_table_find.cpp new file mode 100644 index 00000000000..b344e6d2920 --- /dev/null +++ b/tests/components/core/test_progmem_string_table_find.cpp @@ -0,0 +1,37 @@ +#include + +#include "esphome/core/progmem.h" + +namespace esphome::testing { + +PROGMEM_STRING_TABLE(Names, "light.turn_on", "entity_id", "rgb_color"); + +TEST(ProgmemStringTableFind, FindsWholeStringsOnly) { + EXPECT_EQ(progmem_string_table_find("entity_id", 9), 1); + EXPECT_EQ(progmem_string_table_find("rgb_color", 9), 2); + EXPECT_EQ(progmem_string_table_find("entity", 6), -1); + EXPECT_EQ(progmem_string_table_find("entity_id_x", 11), -1); + EXPECT_EQ(progmem_string_table_find("", 0), -1); +} + +// 130 strings, 520 bytes: over the one-byte offsets, so the blob is scanned +PROGMEM_STRING_TABLE(Big, "s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10", "s11", "s12", "s13", "s14", + "s15", "s16", "s17", "s18", "s19", "s20", "s21", "s22", "s23", "s24", "s25", "s26", "s27", "s28", + "s29", "s30", "s31", "s32", "s33", "s34", "s35", "s36", "s37", "s38", "s39", "s40", "s41", "s42", + "s43", "s44", "s45", "s46", "s47", "s48", "s49", "s50", "s51", "s52", "s53", "s54", "s55", "s56", + "s57", "s58", "s59", "s60", "s61", "s62", "s63", "s64", "s65", "s66", "s67", "s68", "s69", "s70", + "s71", "s72", "s73", "s74", "s75", "s76", "s77", "s78", "s79", "s80", "s81", "s82", "s83", "s84", + "s85", "s86", "s87", "s88", "s89", "s90", "s91", "s92", "s93", "s94", "s95", "s96", "s97", "s98", + "s99", "s100", "s101", "s102", "s103", "s104", "s105", "s106", "s107", "s108", "s109", "s110", + "s111", "s112", "s113", "s114", "s115", "s116", "s117", "s118", "s119", "s120", "s121", "s122", + "s123", "s124", "s125", "s126", "s127", "s128", "s129"); + +TEST(ProgmemStringTableFind, ReachesEveryIndexOfAScannedTable) { + EXPECT_EQ(progmem_string_table_find("s0", 2), 0); + EXPECT_EQ(progmem_string_table_find("s127", 4), 127); + EXPECT_EQ(progmem_string_table_find("s128", 4), 128); + EXPECT_EQ(progmem_string_table_find("s129", 4), 129); + EXPECT_EQ(progmem_string_table_find("s130", 4), -1); +} + +} // namespace esphome::testing From 619728b686c65adfa50a42e2ba7fc3de69b37284 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:23:06 -1000 Subject: [PATCH 580/651] [components] Drop stale UART receive bytes with discard_available (#21030) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/bl0906/bl0906.cpp | 4 +--- esphome/components/cm1106/cm1106.cpp | 4 +--- esphome/components/cse7761/cse7761.cpp | 4 +--- .../dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp | 6 +----- esphome/components/dlms_meter/dlms_meter.cpp | 12 +++--------- esphome/components/dlms_meter/dlms_meter.h | 1 - .../components/fingerprint_grow/fingerprint_grow.cpp | 3 +-- esphome/components/hc8/hc8.cpp | 7 ++----- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 11 +++-------- esphome/components/hydreon_rgxx/hydreon_rgxx.cpp | 4 +--- esphome/components/mhz19/mhz19.cpp | 4 +--- esphome/components/pylontech/pylontech.cpp | 6 +----- esphome/components/pzem004t/pzem004t.cpp | 7 ++----- esphome/components/shelly_dimmer/shelly_dimmer.cpp | 5 +---- esphome/components/t6615/t6615.cpp | 7 ++----- esphome/components/tmc22xx/tmc22xx.cpp | 6 ++---- 16 files changed, 23 insertions(+), 68 deletions(-) diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 6f59da77d34..681c7ad15eb 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -134,9 +134,7 @@ void BL0906::handle_actions_() { } } - while (this->available()) { - this->read(); - } + this->discard_available(); this->action_queue_.clear(); } diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index 55b7556cda5..8739e6c0805 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -115,9 +115,7 @@ bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t co bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len) { - // Empty RX Buffer - while (this->available()) - this->read(); + this->discard_available(); this->write_array(command, command_len - 1); this->write_byte(cm1106_checksum(command, command_len)); this->flush(); diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 0c56c741fe6..8ec520b7396 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -116,9 +116,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { } bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { - while (this->available()) { - this->read(); - } + this->discard_available(); this->write_(reg, 0); diff --git a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp index ed1c6733abb..08d6024c16b 100644 --- a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp +++ b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp @@ -40,11 +40,7 @@ void DFRobotSEN0465UARTComponent::loop() { bool DFRobotSEN0465UARTComponent::write_frame(const uint8_t *frame) { // Drop anything left over, such as the answer to the mode change or readings sent before passive mode was set - uint8_t byte; - while (this->available() > 0) { - if (!this->read_byte(&byte)) - break; - } + this->discard_available(); this->rx_count_ = 0; this->write_array(frame, FRAME_SIZE); return true; diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index 32d1817e0e7..7c0ad009205 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -73,7 +73,7 @@ DlmsMeterComponent::DlmsMeterComponent(const uint32_t receive_timeout_ms, const } } -void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } +void DlmsMeterComponent::setup() { this->discard_available(); } void DlmsMeterComponent::dump_config() { ESP_LOGCONFIG(TAG, "DLMS Meter:"); @@ -122,12 +122,6 @@ void DlmsMeterComponent::loop() { } } -void DlmsMeterComponent::flush_rx_buffer_() { - while (this->available()) { - this->read(); - } -} - void DlmsMeterComponent::read_rx_buffer_() { int available = this->available(); if (available == 0) @@ -137,7 +131,7 @@ void DlmsMeterComponent::read_rx_buffer_() { ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Dropping frame."); this->bytes_accumulated_ = 0; - this->flush_rx_buffer_(); + this->discard_available(); return; } @@ -145,7 +139,7 @@ void DlmsMeterComponent::read_rx_buffer_() { if (!success) { ESP_LOGW(TAG, "UART read failed. Dropping frame."); this->bytes_accumulated_ = 0; - this->flush_rx_buffer_(); + this->discard_available(); return; } diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index 2d7898f4d3e..9ff2cabf2b5 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -104,7 +104,6 @@ class DlmsMeterComponent final : public Component, public uart::UARTDevice { protected: void read_rx_buffer_(); - void flush_rx_buffer_(); void process_frame_(); void on_data_(const dlms_parser::AxdrCapture &capture); diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 99e88af5b74..3abd1862f34 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -362,8 +362,7 @@ void FingerprintGrowComponent::aura_led_control(uint8_t state, uint8_t speed, ui } uint8_t FingerprintGrowComponent::transfer_(std::vector &data_buffer) { - while (this->available()) - this->read(); + this->discard_available(); this->write((uint8_t) (START_CODE >> 8)); this->write((uint8_t) (START_CODE & 0xFF)); this->write(this->address_[0]); diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 4fbfbc84d70..2197a086c36 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -18,9 +18,7 @@ void HC8Component::setup() { this->write_array(HC8_COMMAND_GET_PPM); this->flush(); - // ensure the buffer is empty - while (this->available()) - this->read(); + this->discard_available(); } void HC8Component::update() { @@ -36,8 +34,7 @@ void HC8Component::update() { this->status_clear_warning(); } - while (this->available()) - this->read(); + this->discard_available(); this->write_array(HC8_COMMAND_GET_PPM); this->flush(); diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 8674a1de97c..43311f3d968 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -11,9 +11,7 @@ static constexpr uint32_t PAYLOAD_TIMEOUT_MS = 20; void HlkFm22xComponent::setup() { ESP_LOGCONFIG(TAG, "Setting up HLK-FM22X..."); this->set_enrolling_(false); - while (this->available() > 0) { - this->read(); - } + this->discard_available(); this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); }); } @@ -85,8 +83,7 @@ void HlkFm22xComponent::send_command_(HlkFm22xCommand command, const uint8_t *da } this->wait_cycles_ = 0; this->active_command_ = command; - while (this->available() > 0) - this->read(); + this->discard_available(); this->write((uint8_t) (START_CODE >> 8)); this->write((uint8_t) (START_CODE & 0xFF)); this->write((uint8_t) command); @@ -141,9 +138,7 @@ void HlkFm22xComponent::recv_command_() { if (millis() - start > PAYLOAD_TIMEOUT_MS) { ESP_LOGE(TAG, "Timeout waiting for payload (%u bytes)", length); // Drain any partial payload bytes to resync the parser - while (this->available() > 0) { - this->read(); - } + this->discard_available(); return; } delay(1); diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index a1d6553073a..4a479874f53 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -38,9 +38,7 @@ void HydreonRGxxComponent::dump_config() { } void HydreonRGxxComponent::setup() { - while (this->available() != 0) { - this->read(); - } + this->discard_available(); this->schedule_reboot_(); } diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index 0f623bfd726..eda4b4d8e78 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -120,9 +120,7 @@ void MHZ19Component::range_set(MHZ19DetectionRange detection_range) { } bool MHZ19Component::mhz19_write_command_(const uint8_t *command, uint8_t *response) { - // Empty RX Buffer - while (this->available()) - this->read(); + this->discard_available(); this->write_array(command, MHZ19_REQUEST_LENGTH); this->write_byte(mhz19_checksum(command)); this->flush(); diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 90cb4a8b855..acc80b0f839 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -99,11 +99,7 @@ void PylontechComponent::dump_config() { LOG_UPDATE_INTERVAL(this); } -void PylontechComponent::setup() { - while (this->available() != 0) { - this->read(); - } -} +void PylontechComponent::setup() { this->discard_available(); } void PylontechComponent::update() { #ifdef PYLONTECH_CELL_BATTERY_COUNT diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 2c5e349bd31..c27fb07ce08 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -9,9 +9,7 @@ namespace esphome::pzem004t { ESPHOME_LOG_TAG(TAG, "pzem004t"); void PZEM004T::setup() { - // Clear UART buffer - while (this->available()) - this->read(); + this->discard_available(); // Set module address this->write_state_(SET_ADDRESS); } @@ -19,8 +17,7 @@ void PZEM004T::setup() { void PZEM004T::loop() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_read_ > 500 && this->available() < 7) { - while (this->available()) - this->read(); + this->discard_available(); this->last_read_ = now; } diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index eedf7ff7ce2..8c44c8c5a08 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -483,10 +483,7 @@ void ShellyDimmer::reset_(bool boot0) { // Wait 50ms for the STM32 to reset. delay(50); // NOLINT - // Clear receive buffer. - while (this->available()) { - this->read(); - } + this->discard_available(); this->pin_nrst_->digital_write(true); // Wait 50ms for the STM32 to boot. diff --git a/esphome/components/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index d3997528890..6fb58345358 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -32,8 +32,7 @@ void T6615Component::loop() { if (this->available() < 5) { if (this->command_ == T6615Command::GET_PPM && millis() - this->command_time_ > T6615_TIMEOUT) { /* command got eaten, clear the buffer and fire another */ - while (this->available()) - this->read(); + this->discard_available(); this->send_ppm_command_(); } return; @@ -48,9 +47,7 @@ void T6615Component::loop() { if (response_buffer[0] != T6615_MAGIC || response_buffer[1] != T6615_ADDR_HOST) { ESP_LOGW(TAG, "Got bad data from T6615! Magic was %02X and address was %02X", response_buffer[0], response_buffer[1]); - /* make sure the buffer is empty */ - while (this->available()) - this->read(); + this->discard_available(); /* try again to read the sensor */ this->send_ppm_command_(); this->status_set_warning(); diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index f68496bd0ca..6ee4c9e3ee6 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -446,8 +446,7 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { 0}; datagram[7] = datagram_crc(datagram, 7); - while (this->available()) - this->read(); + this->discard_available(); this->write_array(datagram, DATAGRAM_SIZE); this->flush(); // On the single wire bus every byte sent is also received, drop that echo @@ -475,8 +474,7 @@ optional TMC22XXStepper::read_register(uint8_t reg) { uint8_t request[READ_REQUEST_SIZE] = {SYNC_BYTE, this->address_, reg, 0}; request[3] = datagram_crc(request, 3); - while (this->available()) - this->read(); + this->discard_available(); this->write_array(request, READ_REQUEST_SIZE); this->flush(); uint8_t reply[DATAGRAM_SIZE]; From deea006b396782aa27d708d648211c1d26e078b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:43:05 -1000 Subject: [PATCH 581/651] [components] Set referenced components with cg.add_variable and register_parented (a-l) (#21021) --- .../components/absolute_humidity/sensor.py | 7 ++--- .../analog_threshold/binary_sensor.py | 3 +- esphome/components/aqi/sensor.py | 7 ++--- esphome/components/bang_bang/climate.py | 8 ++---- esphome/components/bedjet/__init__.py | 4 +-- esphome/components/binary/fan/__init__.py | 13 ++------- esphome/components/binary/light/__init__.py | 3 +- .../components/bk72xx_ble_tracker/__init__.py | 3 +- esphome/components/bp1658cj/output.py | 3 +- esphome/components/bp5758d/output.py | 3 +- esphome/components/ccs811/sensor.py | 8 ++---- esphome/components/cd74hc4067/sensor.py | 3 +- esphome/components/climate_ir/__init__.py | 8 ++---- esphome/components/color_temperature/light.py | 7 ++--- .../components/copy/binary_sensor/__init__.py | 3 +- esphome/components/copy/button/__init__.py | 3 +- esphome/components/copy/cover/__init__.py | 3 +- esphome/components/copy/fan/__init__.py | 3 +- esphome/components/copy/lock/__init__.py | 3 +- esphome/components/copy/number/__init__.py | 3 +- esphome/components/copy/select/__init__.py | 3 +- esphome/components/copy/sensor/__init__.py | 3 +- esphome/components/copy/switch/__init__.py | 3 +- esphome/components/copy/text/__init__.py | 3 +- .../components/copy/text_sensor/__init__.py | 3 +- esphome/components/counter/sensor.py | 6 ++-- esphome/components/ct_clamp/sensor.py | 3 +- esphome/components/current_based/cover.py | 6 ++-- esphome/components/cwww/light.py | 6 ++-- esphome/components/datetime/__init__.py | 4 +-- esphome/components/deep_sleep/__init__.py | 3 +- esphome/components/dew_point/sensor.py | 7 ++--- .../components/display_menu_base/__init__.py | 9 ++---- esphome/components/duty_time/sensor.py | 4 +-- esphome/components/ecocomfort2/__init__.py | 3 +- esphome/components/endstop/cover.py | 6 ++-- esphome/components/ens160_base/__init__.py | 8 +++--- esphome/components/esp32_camera/__init__.py | 3 +- .../esp32_hosted/update/__init__.py | 3 +- esphome/components/feedback/cover.py | 28 +++++++------------ esphome/components/filter_lifetime/sensor.py | 8 +++--- esphome/components/gp2y1010au0f/sensor.py | 6 ++-- esphome/components/graph/__init__.py | 15 +++------- .../graphical_display_menu/__init__.py | 17 +++-------- esphome/components/hbridge/light/__init__.py | 6 ++-- .../http_request/update/__init__.py | 6 ++-- esphome/components/i2c/__init__.py | 3 +- esphome/components/improv_ble/__init__.py | 9 ++---- esphome/components/improv_serial/__init__.py | 2 +- esphome/components/integration/sensor.py | 3 +- esphome/components/key_collector/__init__.py | 3 +- esphome/components/lcd_menu/__init__.py | 3 +- .../components/ln882h_ble_tracker/__init__.py | 3 +- 53 files changed, 93 insertions(+), 203 deletions(-) diff --git a/esphome/components/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py index f11ba5af990..10ad57a8828 100644 --- a/esphome/components/absolute_humidity/sensor.py +++ b/esphome/components/absolute_humidity/sensor.py @@ -44,10 +44,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(temperature_sensor)) - - humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(humidity_sensor)) + await cg.add_variable(var.set_temperature_sensor, config[CONF_TEMPERATURE]) + await cg.add_variable(var.set_humidity_sensor, config[CONF_HUMIDITY]) cg.add(var.set_equation(config[CONF_EQUATION])) diff --git a/esphome/components/analog_threshold/binary_sensor.py b/esphome/components/analog_threshold/binary_sensor.py index b2de1d6184c..f21efb09055 100644 --- a/esphome/components/analog_threshold/binary_sensor.py +++ b/esphome/components/analog_threshold/binary_sensor.py @@ -37,8 +37,7 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR_ID]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR_ID]) if isinstance(config[CONF_THRESHOLD], dict): lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], cg.float_) diff --git a/esphome/components/aqi/sensor.py b/esphome/components/aqi/sensor.py index a98d977227c..6e0a3e74963 100644 --- a/esphome/components/aqi/sensor.py +++ b/esphome/components/aqi/sensor.py @@ -53,11 +53,8 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pm_2_5_sensor = await cg.get_variable(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(pm_2_5_sensor)) - - pm_10_0_sensor = await cg.get_variable(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor)) + await cg.add_variable(var.set_pm_2_5_sensor, config[CONF_PM_2_5]) + await cg.add_variable(var.set_pm_10_0_sensor, config[CONF_PM_10_0]) cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE])) cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False))) diff --git a/esphome/components/bang_bang/climate.py b/esphome/components/bang_bang/climate.py index 65c5eaed187..e09b187ae3d 100644 --- a/esphome/components/bang_bang/climate.py +++ b/esphome/components/bang_bang/climate.py @@ -46,12 +46,8 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) - - if CONF_HUMIDITY_SENSOR in config: - sens = await cg.get_variable(config[CONF_HUMIDITY_SENSOR]) - cg.add(var.set_humidity_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) normal_config = BangBangClimateTargetTempConfig( config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index 61dc73c73d5..406955084ea 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -44,8 +44,6 @@ 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): - time_ = await cg.get_variable(time_id) - cg.add(var.set_time_id(time_)) + await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID)) if (receive_timeout := config.get(CONF_RECEIVE_TIMEOUT)) is not None: cg.add(var.set_status_timeout(receive_timeout)) diff --git a/esphome/components/binary/fan/__init__.py b/esphome/components/binary/fan/__init__.py index 03a03ec7cac..a97920b408a 100644 --- a/esphome/components/binary/fan/__init__.py +++ b/esphome/components/binary/fan/__init__.py @@ -25,13 +25,6 @@ async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) - - if oscillation_output_id := config.get(CONF_OSCILLATION_OUTPUT): - oscillation_output = await cg.get_variable(oscillation_output_id) - cg.add(var.set_oscillating(oscillation_output)) - - if direction_output_id := config.get(CONF_DIRECTION_OUTPUT): - direction_output = await cg.get_variable(direction_output_id) - cg.add(var.set_direction(direction_output)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) + await cg.add_variable(var.set_oscillating, config.get(CONF_OSCILLATION_OUTPUT)) + await cg.add_variable(var.set_direction, config.get(CONF_DIRECTION_OUTPUT)) diff --git a/esphome/components/binary/light/__init__.py b/esphome/components/binary/light/__init__.py index b6eddac3410..3883394050c 100644 --- a/esphome/components/binary/light/__init__.py +++ b/esphome/components/binary/light/__init__.py @@ -20,5 +20,4 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index abef3c7752b..5a03fdd293c 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -144,8 +144,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_BK72XX_BLE_ID]) # The tracker registers itself as a controller scan listener in setup(); # request the codegen-sized StaticVector slot for it. bk72xx_ble.request_scan_listener_slot() diff --git a/esphome/components/bp1658cj/output.py b/esphome/components/bp1658cj/output.py index 93e3c75daf9..ca9c94f733b 100644 --- a/esphome/components/bp1658cj/output.py +++ b/esphome/components/bp1658cj/output.py @@ -24,6 +24,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_BP1658CJ_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_BP1658CJ_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/bp5758d/output.py b/esphome/components/bp5758d/output.py index bbca7c18cc5..24fe8a04e50 100644 --- a/esphome/components/bp5758d/output.py +++ b/esphome/components/bp5758d/output.py @@ -25,7 +25,6 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_BP5758D_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_BP5758D_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) cg.add(var.set_current(config[CONF_CURRENT])) diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index 80948a6b433..703ae619c5d 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -72,9 +72,5 @@ async def to_code(config: ConfigType) -> None: if (baseline := config.get(CONF_BASELINE)) is not None: cg.add(var.set_baseline(baseline)) - if temperature_id := config.get(CONF_TEMPERATURE): - sens = await cg.get_variable(temperature_id) - cg.add(var.set_temperature(sens)) - if humidity_id := config.get(CONF_HUMIDITY): - sens = await cg.get_variable(humidity_id) - cg.add(var.set_humidity(sens)) + await cg.add_variable(var.set_temperature, config.get(CONF_TEMPERATURE)) + await cg.add_variable(var.set_humidity, config.get(CONF_HUMIDITY)) diff --git a/esphome/components/cd74hc4067/sensor.py b/esphome/components/cd74hc4067/sensor.py index 670b0502715..aac921178e5 100644 --- a/esphome/components/cd74hc4067/sensor.py +++ b/esphome/components/cd74hc4067/sensor.py @@ -53,5 +53,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_pin(config[CONF_NUMBER])) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_source(sens)) + await cg.add_variable(var.set_source, config[CONF_SENSOR]) diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index f4b43e3a523..27c24464595 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -74,12 +74,8 @@ async def register_climate_ir(var: MockObj, config: ConfigType) -> None: cg.add(var.set_supports_heat_cool(False)) if remote_base.CONF_RECEIVER_ID in config: await remote_base.register_listener(var, config) - if sensor_id := config.get(CONF_SENSOR): - sens = await cg.get_variable(sensor_id) - cg.add(var.set_sensor(sens)) - if sensor_id := config.get(CONF_HUMIDITY_SENSOR): - sens = await cg.get_variable(sensor_id) - cg.add(var.set_humidity_sensor(sens)) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) async def new_climate_ir(config: ConfigType, *args: SafeExpType) -> MockObj: diff --git a/esphome/components/color_temperature/light.py b/esphome/components/color_temperature/light.py index 7686ede1558..f66b51eff4e 100644 --- a/esphome/components/color_temperature/light.py +++ b/esphome/components/color_temperature/light.py @@ -33,11 +33,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - color_temperature = await cg.get_variable(config[CONF_COLOR_TEMPERATURE]) - cg.add(var.set_color_temperature(color_temperature)) - - brightness = await cg.get_variable(config[CONF_BRIGHTNESS]) - cg.add(var.set_brightness(brightness)) + await cg.add_variable(var.set_color_temperature, config[CONF_COLOR_TEMPERATURE]) + await cg.add_variable(var.set_brightness, config[CONF_BRIGHTNESS]) cg.add(var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE])) cg.add(var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE])) diff --git a/esphome/components/copy/binary_sensor/__init__.py b/esphome/components/copy/binary_sensor/__init__.py index cc8492f21e0..e21743ef296 100644 --- a/esphome/components/copy/binary_sensor/__init__.py +++ b/esphome/components/copy/binary_sensor/__init__.py @@ -38,5 +38,4 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/button/__init__.py b/esphome/components/copy/button/__init__.py index 768131bbe58..17155b2f744 100644 --- a/esphome/components/copy/button/__init__.py +++ b/esphome/components/copy/button/__init__.py @@ -38,5 +38,4 @@ async def to_code(config: ConfigType) -> None: await button.register_button(var, config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/cover/__init__.py b/esphome/components/copy/cover/__init__.py index d23602fa741..7d01cd93ac2 100644 --- a/esphome/components/copy/cover/__init__.py +++ b/esphome/components/copy/cover/__init__.py @@ -36,5 +36,4 @@ async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/fan/__init__.py b/esphome/components/copy/fan/__init__.py index ffa414c5f21..44c0d654073 100644 --- a/esphome/components/copy/fan/__init__.py +++ b/esphome/components/copy/fan/__init__.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/lock/__init__.py b/esphome/components/copy/lock/__init__.py index 8d9c4b6eca7..5f2f8f2eb1e 100644 --- a/esphome/components/copy/lock/__init__.py +++ b/esphome/components/copy/lock/__init__.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: var = await lock.new_lock(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/number/__init__.py b/esphome/components/copy/number/__init__.py index 9659a605f91..c0370afc18c 100644 --- a/esphome/components/copy/number/__init__.py +++ b/esphome/components/copy/number/__init__.py @@ -38,5 +38,4 @@ async def to_code(config: ConfigType) -> None: var = await number.new_number(config, min_value=0, max_value=0, step=0) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/select/__init__.py b/esphome/components/copy/select/__init__.py index 97776b1edd7..e6a4091be8f 100644 --- a/esphome/components/copy/select/__init__.py +++ b/esphome/components/copy/select/__init__.py @@ -31,5 +31,4 @@ async def to_code(config: ConfigType) -> None: await select.register_select(var, config, options=[]) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/sensor/__init__.py b/esphome/components/copy/sensor/__init__.py index 54687980475..746a48e19ce 100644 --- a/esphome/components/copy/sensor/__init__.py +++ b/esphome/components/copy/sensor/__init__.py @@ -42,5 +42,4 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/switch/__init__.py b/esphome/components/copy/switch/__init__.py index 0e714540f96..6bcd10ff200 100644 --- a/esphome/components/copy/switch/__init__.py +++ b/esphome/components/copy/switch/__init__.py @@ -36,5 +36,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/text/__init__.py b/esphome/components/copy/text/__init__.py index 59fdce6c96e..44d61a88506 100644 --- a/esphome/components/copy/text/__init__.py +++ b/esphome/components/copy/text/__init__.py @@ -31,5 +31,4 @@ async def to_code(config: ConfigType) -> None: var = await text.new_text(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/text_sensor/__init__.py b/esphome/components/copy/text_sensor/__init__.py index 146beae5eaa..9c5495938e0 100644 --- a/esphome/components/copy/text_sensor/__init__.py +++ b/esphome/components/copy/text_sensor/__init__.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/counter/sensor.py b/esphome/components/counter/sensor.py index 0f36b1721bf..0c3766cbe7c 100644 --- a/esphome/components/counter/sensor.py +++ b/esphome/components/counter/sensor.py @@ -55,10 +55,8 @@ async def to_code(config): ) await cg.register_component(var, config) await sensor.register_sensor(var, config) - if (source := config.get(CONF_SENSOR)) is not None: - cg.add(var.count_updates_from(await cg.get_variable(source))) - if (source := config.get(CONF_BINARY_SENSOR)) is not None: - cg.add(var.count_true_from(await cg.get_variable(source))) + await cg.add_variable(var.count_updates_from, config.get(CONF_SENSOR)) + await cg.add_variable(var.count_true_from, config.get(CONF_BINARY_SENSOR)) automation.register_apply_action( diff --git a/esphome/components/ct_clamp/sensor.py b/esphome/components/ct_clamp/sensor.py index 8ef211cb247..b9959d65f72 100644 --- a/esphome/components/ct_clamp/sensor.py +++ b/esphome/components/ct_clamp/sensor.py @@ -41,6 +41,5 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_source(sens)) + await cg.add_variable(var.set_source, config[CONF_SENSOR]) cg.add(var.set_sample_duration(config[CONF_SAMPLE_DURATION])) diff --git a/esphome/components/current_based/cover.py b/esphome/components/current_based/cover.py index a552956082a..4edcb98a1d9 100644 --- a/esphome/components/current_based/cover.py +++ b/esphome/components/current_based/cover.py @@ -77,8 +77,7 @@ async def to_code(config: ConfigType) -> None: ) # OPEN - bin = await cg.get_variable(config[CONF_OPEN_SENSOR]) - cg.add(var.set_open_sensor(bin)) + await cg.add_variable(var.set_open_sensor, config[CONF_OPEN_SENSOR]) cg.add( var.set_open_moving_current_threshold( config[CONF_OPEN_MOVING_CURRENT_THRESHOLD] @@ -97,8 +96,7 @@ async def to_code(config: ConfigType) -> None: ) # CLOSE - bin = await cg.get_variable(config[CONF_CLOSE_SENSOR]) - cg.add(var.set_close_sensor(bin)) + await cg.add_variable(var.set_close_sensor, config[CONF_CLOSE_SENSOR]) cg.add( var.set_close_moving_current_threshold( config[CONF_CLOSE_MOVING_CURRENT_THRESHOLD] diff --git a/esphome/components/cwww/light.py b/esphome/components/cwww/light.py index 90fe6d0bad8..3ea6a85923a 100644 --- a/esphome/components/cwww/light.py +++ b/esphome/components/cwww/light.py @@ -36,13 +36,11 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - cwhite = await cg.get_variable(config[CONF_COLD_WHITE]) - cg.add(var.set_cold_white(cwhite)) + await cg.add_variable(var.set_cold_white, config[CONF_COLD_WHITE]) if cold_white_color_temperature := config.get(CONF_COLD_WHITE_COLOR_TEMPERATURE): cg.add(var.set_cold_white_temperature(cold_white_color_temperature)) - wwhite = await cg.get_variable(config[CONF_WARM_WHITE]) - cg.add(var.set_warm_white(wwhite)) + await cg.add_variable(var.set_warm_white, config[CONF_WARM_WHITE]) if warm_white_color_temperature := config.get(CONF_WARM_WHITE_COLOR_TEMPERATURE): cg.add(var.set_warm_white_temperature(warm_white_color_temperature)) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 20fd258cb00..e9ea05dcdab 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -148,9 +148,7 @@ async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: forward=[automation.parent_ref(var).state_as_esptime()], ) - if CONF_TIME_ID in config: - rtc = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_rtc(rtc)) + await cg.add_variable(var.set_rtc, config.get(CONF_TIME_ID)) for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 7230b1e8e9c..69aa1120ba1 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -491,8 +491,7 @@ async def deep_sleep_enter_to_code( until = config[CONF_UNTIL] cg.add(var.set_until(until[CONF_HOUR], until[CONF_MINUTE], until[CONF_SECOND])) - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.add_variable(var.set_time, config[CONF_TIME_ID]) return var diff --git a/esphome/components/dew_point/sensor.py b/esphome/components/dew_point/sensor.py index 555fdef289e..e7361c8dbfe 100644 --- a/esphome/components/dew_point/sensor.py +++ b/esphome/components/dew_point/sensor.py @@ -40,8 +40,5 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(temperature_sensor)) - - humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(humidity_sensor)) + await cg.add_variable(var.set_temperature_sensor, config[CONF_TEMPERATURE]) + await cg.add_variable(var.set_humidity_sensor, config[CONF_HUMIDITY]) diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 791242cda12..ba51bdccaae 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -280,15 +280,12 @@ async def menu_item_to_code(menu, config, parent): if CONF_IMMEDIATE_EDIT in config: cg.add(item.set_immediate_edit(config[CONF_IMMEDIATE_EDIT])) if config[CONF_TYPE] == CONF_SELECT: - var = await cg.get_variable(config[CONF_SELECT]) - cg.add(item.set_select_variable(var)) + await cg.add_variable(item.set_select_variable, config[CONF_SELECT]) if config[CONF_TYPE] == CONF_NUMBER: - var = await cg.get_variable(config[CONF_NUMBER]) - cg.add(item.set_number_variable(var)) + await cg.add_variable(item.set_number_variable, config[CONF_NUMBER]) cg.add(item.set_format(config[CONF_FORMAT])) if config[CONF_TYPE] == CONF_SWITCH: - var = await cg.get_variable(config[CONF_SWITCH]) - cg.add(item.set_switch_variable(var)) + await cg.add_variable(item.set_switch_variable, config[CONF_SWITCH]) cg.add(item.set_on_text(config[CONF_ON_TEXT])) cg.add(item.set_off_text(config[CONF_OFF_TEXT])) await _build_item_automations(item, config) diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 6b035d5c982..1f733fc6bb1 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -57,9 +57,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_restore(config[CONF_RESTORE])) - if CONF_SENSOR in config: - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) if CONF_LAMBDA in config: lambda_ = await cg.process_lambda(config[CONF_LAMBDA], [], return_type=cg.bool_) cg.add(var.set_lambda(lambda_)) diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py index 8a0e1654188..c2540db644e 100644 --- a/esphome/components/ecocomfort2/__init__.py +++ b/esphome/components/ecocomfort2/__init__.py @@ -53,8 +53,7 @@ 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))) + await cg.add_variable(var.set_time, config.get(CONF_TIME_ID)) def _final_validate(config: ConfigType) -> ConfigType: diff --git a/esphome/components/endstop/cover.py b/esphome/components/endstop/cover.py index 0e27189500c..066326e9d46 100644 --- a/esphome/components/endstop/cover.py +++ b/esphome/components/endstop/cover.py @@ -43,15 +43,13 @@ async def to_code(config: ConfigType) -> None: var.get_stop_trigger(), [], config[CONF_STOP_ACTION] ) - bin = await cg.get_variable(config[CONF_OPEN_ENDSTOP]) - cg.add(var.set_open_endstop(bin)) + await cg.add_variable(var.set_open_endstop, config[CONF_OPEN_ENDSTOP]) cg.add(var.set_open_duration(config[CONF_OPEN_DURATION])) await automation.build_automation( var.get_open_trigger(), [], config[CONF_OPEN_ACTION] ) - bin = await cg.get_variable(config[CONF_CLOSE_ENDSTOP]) - cg.add(var.set_close_endstop(bin)) + await cg.add_variable(var.set_close_endstop, config[CONF_CLOSE_ENDSTOP]) cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION])) await automation.build_automation( var.get_close_trigger(), [], config[CONF_CLOSE_ACTION] diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index 8ef5971f17a..61342cbe5c0 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -70,9 +70,9 @@ async def to_code_base(config: ConfigType) -> MockObj: await sensors(CONF_AQI, var.set_aqi) if compensation_config := config.get(CONF_COMPENSATION): - sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - sens = await cg.get_variable(compensation_config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) + await cg.add_variable( + var.set_temperature, compensation_config[CONF_TEMPERATURE] + ) + await cg.add_variable(var.set_humidity, compensation_config[CONF_HUMIDITY]) return var diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 0014d38a5c4..a83a4817b1a 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -376,8 +376,7 @@ async def to_code(config: ConfigType) -> None: extclk = config[CONF_EXTERNAL_CLOCK] cg.add(var.set_external_clock(extclk[CONF_PIN], extclk[CONF_FREQUENCY])) if i2c_id := config.get(CONF_I2C_ID): - i2c_hub = await cg.get_variable(i2c_id) - cg.add(var.set_i2c_id(i2c_hub)) + await cg.add_variable(var.set_i2c_id, i2c_id) else: i2c_pins = config[CONF_I2C_PINS] cg.add(var.set_i2c_pins(i2c_pins[CONF_SDA], i2c_pins[CONF_SCL])) diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index 8e85cce75a5..4ccd6a12f8b 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -104,8 +104,7 @@ async def to_code(config: dict[str, Any]) -> None: cg.add(var.set_firmware_data(prog_arr)) cg.add(var.set_firmware_size(len(firmware_data))) else: - http_request_var = await cg.get_variable(config[CONF_HTTP_REQUEST_ID]) - cg.add(var.set_http_request_parent(http_request_var)) + await cg.add_variable(var.set_http_request_parent, config[CONF_HTTP_REQUEST_ID]) cg.add(var.set_source_url(config[CONF_SOURCE])) cg.add_define("USE_ESP32_HOSTED_HTTP_UPDATE") diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index 9fbda9a9737..f203e8d5186 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -129,30 +129,22 @@ async def to_code(config: ConfigType) -> None: var.get_open_trigger(), [], config[CONF_OPEN_ACTION] ) cg.add(var.set_open_duration(config[CONF_OPEN_DURATION])) - if CONF_OPEN_ENDSTOP in config: - bin = await cg.get_variable(config[CONF_OPEN_ENDSTOP]) - cg.add(var.set_open_endstop(bin)) - if CONF_OPEN_SENSOR in config: - bin = await cg.get_variable(config[CONF_OPEN_SENSOR]) - cg.add(var.set_open_sensor(bin)) - if CONF_OPEN_OBSTACLE_SENSOR in config: - bin = await cg.get_variable(config[CONF_OPEN_OBSTACLE_SENSOR]) - cg.add(var.set_open_obstacle_sensor(bin)) + await cg.add_variable(var.set_open_endstop, config.get(CONF_OPEN_ENDSTOP)) + await cg.add_variable(var.set_open_sensor, config.get(CONF_OPEN_SENSOR)) + await cg.add_variable( + var.set_open_obstacle_sensor, config.get(CONF_OPEN_OBSTACLE_SENSOR) + ) # CLOSE await automation.build_automation( var.get_close_trigger(), [], config[CONF_CLOSE_ACTION] ) cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION])) - if CONF_CLOSE_ENDSTOP in config: - bin = await cg.get_variable(config[CONF_CLOSE_ENDSTOP]) - cg.add(var.set_close_endstop(bin)) - if CONF_CLOSE_SENSOR in config: - bin = await cg.get_variable(config[CONF_CLOSE_SENSOR]) - cg.add(var.set_close_sensor(bin)) - if CONF_CLOSE_OBSTACLE_SENSOR in config: - bin = await cg.get_variable(config[CONF_CLOSE_OBSTACLE_SENSOR]) - cg.add(var.set_close_obstacle_sensor(bin)) + await cg.add_variable(var.set_close_endstop, config.get(CONF_CLOSE_ENDSTOP)) + await cg.add_variable(var.set_close_sensor, config.get(CONF_CLOSE_SENSOR)) + await cg.add_variable( + var.set_close_obstacle_sensor, config.get(CONF_CLOSE_OBSTACLE_SENSOR) + ) # OTHER if CONF_MAX_DURATION in config: diff --git a/esphome/components/filter_lifetime/sensor.py b/esphome/components/filter_lifetime/sensor.py index 58af033f329..549f5a20a44 100644 --- a/esphome/components/filter_lifetime/sensor.py +++ b/esphome/components/filter_lifetime/sensor.py @@ -72,15 +72,15 @@ async def to_code(config: ConfigType) -> None: if (is_on := config.get(CONF_IS_ON)) is not None: cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool))) - elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None: - cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor))) + await cg.add_variable(var.set_is_on_sensor, config.get(CONF_IS_ON_SENSOR)) if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None: cg.add( var.set_current_speed_lambda(await cg.templatable(current_speed, [], float)) ) - elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None: - cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor))) + await cg.add_variable( + var.set_current_speed_sensor, config.get(CONF_CURRENT_SPEED_SENSOR) + ) sensors = sensor.sub_sensors(config) await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor) diff --git a/esphome/components/gp2y1010au0f/sensor.py b/esphome/components/gp2y1010au0f/sensor.py index 3121aa1de50..9d541a3d88a 100644 --- a/esphome/components/gp2y1010au0f/sensor.py +++ b/esphome/components/gp2y1010au0f/sensor.py @@ -49,8 +49,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) # the ADC sensor to read voltage from - adc_sensor = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_adc_source(adc_sensor)) + await cg.add_variable(var.set_adc_source, config[CONF_SENSOR]) cg.add( var.set_voltage_refs( config[CONF_ADC_VOLTAGE_OFFSET], config[CONF_ADC_VOLTAGE_MULTIPLIER] @@ -58,5 +57,4 @@ async def to_code(config: ConfigType) -> None: ) # the binary output to control the module's internal IR LED - led_output = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_led_output(led_output)) + await cg.add_variable(var.set_led_output, config[CONF_OUTPUT]) diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index ce9035655c3..9dfa8df4cc8 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -169,8 +169,7 @@ async def to_code(config: ConfigType) -> None: # Trace options for trace in config[CONF_TRACES]: tr = cg.new_Pvariable(trace[CONF_ID], GraphTrace()) - sens = await cg.get_variable(trace[CONF_SENSOR]) - cg.add(tr.set_sensor(sens)) + await cg.add_variable(tr.set_sensor, trace[CONF_SENSOR]) if CONF_NAME in trace: cg.add(tr.set_name(trace[CONF_NAME])) else: @@ -179,9 +178,7 @@ async def to_code(config: ConfigType) -> None: cg.add(tr.set_line_thickness(trace[CONF_LINE_THICKNESS])) if CONF_LINE_TYPE in trace: cg.add(tr.set_line_type(trace[CONF_LINE_TYPE])) - if CONF_COLOR in trace: - c = await cg.get_variable(trace[CONF_COLOR]) - cg.add(tr.set_line_color(c)) + await cg.add_variable(tr.set_line_color, trace.get(CONF_COLOR)) if CONF_CONTINUOUS in trace: cg.add(tr.set_continuous(trace[CONF_CONTINUOUS])) cg.add(var.add_trace(tr)) @@ -189,12 +186,8 @@ async def to_code(config: ConfigType) -> None: if CONF_LEGEND in config: lgd = config[CONF_LEGEND] legend = cg.new_Pvariable(lgd[CONF_ID], GraphLegend()) - if CONF_NAME_FONT in lgd: - font = await cg.get_variable(lgd[CONF_NAME_FONT]) - cg.add(legend.set_name_font(font)) - if CONF_VALUE_FONT in lgd: - font = await cg.get_variable(lgd[CONF_VALUE_FONT]) - cg.add(legend.set_value_font(font)) + await cg.add_variable(legend.set_name_font, lgd.get(CONF_NAME_FONT)) + await cg.add_variable(legend.set_value_font, lgd.get(CONF_VALUE_FONT)) if CONF_WIDTH in lgd: cg.add(legend.set_width(lgd[CONF_WIDTH])) if CONF_HEIGHT in lgd: diff --git a/esphome/components/graphical_display_menu/__init__.py b/esphome/components/graphical_display_menu/__init__.py index a398aea6192..5534321dd7f 100644 --- a/esphome/components/graphical_display_menu/__init__.py +++ b/esphome/components/graphical_display_menu/__init__.py @@ -55,12 +55,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if display_config := config.get(CONF_DISPLAY): - drawing_display = await cg.get_variable(display_config) - cg.add(var.set_display(drawing_display)) - - menu_font = await cg.get_variable(config[CONF_FONT]) - cg.add(var.set_font(menu_font)) + await cg.add_variable(var.set_display, config.get(CONF_DISPLAY)) + await cg.add_variable(var.set_font, config[CONF_FONT]) if (menu_item_value_config := config.get(CONF_MENU_ITEM_VALUE, None)) is not None: if isinstance(menu_item_value_config, core.Lambda): @@ -73,13 +69,8 @@ async def to_code(config: ConfigType) -> None: else: cg.add(var.set_menu_item_value(menu_item_value_config)) - if foreground_color_config := config.get(CONF_FOREGROUND_COLOR): - foreground_color = await cg.get_variable(foreground_color_config) - cg.add(var.set_foreground_color(foreground_color)) - - if background_color_config := config.get(CONF_BACKGROUND_COLOR): - background_color = await cg.get_variable(background_color_config) - cg.add(var.set_background_color(background_color)) + await cg.add_variable(var.set_foreground_color, config.get(CONF_FOREGROUND_COLOR)) + await cg.add_variable(var.set_background_color, config.get(CONF_BACKGROUND_COLOR)) for conf in config.get(CONF_ON_REDRAW, []): await automation.build_callback_automation( diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index f7866cb9907..95cf9dc6dc8 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -28,7 +28,5 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await light.register_light(var, config) - hside = await cg.get_variable(config[CONF_PIN_A]) - cg.add(var.set_pina_pin(hside)) - lside = await cg.get_variable(config[CONF_PIN_B]) - cg.add(var.set_pinb_pin(lside)) + await cg.add_variable(var.set_pina_pin, config[CONF_PIN_A]) + await cg.add_variable(var.set_pinb_pin, config[CONF_PIN_B]) diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index 4bdc30e4cf3..e22270f1f2e 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -32,10 +32,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await update.new_update(config) - ota_parent = await cg.get_variable(config[CONF_OTA_ID]) - cg.add(var.set_ota_parent(ota_parent)) - request_parent = await cg.get_variable(config[CONF_HTTP_REQUEST_ID]) - cg.add(var.set_request_parent(request_parent)) + await cg.add_variable(var.set_ota_parent, config[CONF_OTA_ID]) + await cg.add_variable(var.set_request_parent, config[CONF_HTTP_REQUEST_ID]) cg.add(var.set_source_url(config[CONF_SOURCE])) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 84f6d0c2e66..8598998f563 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -390,8 +390,7 @@ async def register_i2c_device(var: MockObj, config: ConfigType) -> None: This is a coroutine, you need to await it with an 'await' expression! """ - parent = await cg.get_variable(config[CONF_I2C_ID]) - cg.add(var.set_i2c_bus(parent)) + await cg.add_variable(var.set_i2c_bus, config[CONF_I2C_ID]) cg.add(var.set_i2c_address(config[CONF_ADDRESS])) diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index e7d4be8cc3d..dbc6542ec81 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -135,13 +135,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_wifi_timeout(config[CONF_WIFI_TIMEOUT])) - if CONF_AUTHORIZER in config and config[CONF_AUTHORIZER] is not None: - activator = await cg.get_variable(config[CONF_AUTHORIZER]) - cg.add(var.set_authorizer(activator)) - - if CONF_STATUS_INDICATOR in config: - status_indicator = await cg.get_variable(config[CONF_STATUS_INDICATOR]) - cg.add(var.set_status_indicator(status_indicator)) + await cg.add_variable(var.set_authorizer, config.get(CONF_AUTHORIZER)) + await cg.add_variable(var.set_status_indicator, config.get(CONF_STATUS_INDICATOR)) if any(config.get(entry.conf_key) for entry in _CALLBACK_AUTOMATIONS): cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 3e1aef21bb6..6bb5725b416 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -68,5 +68,5 @@ async def to_code(config: ConfigType) -> None: await improv_base.setup_improv_core(var, config) cg.add_define("USE_IMPROV_SERIAL") if (uart_id := config.get(CONF_UART_ID)) is not None: - cg.add(var.set_uart(await cg.get_variable(uart_id))) + await cg.add_variable(var.set_uart, uart_id) cg.add_define("USE_IMPROV_SERIAL_UART") diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index d8e2c82cf6f..6a7dc63dbd5 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -93,8 +93,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await sensor.register_sensor(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) cg.add(var.set_time(config[CONF_TIME_UNIT])) cg.add(var.set_method(config[CONF_INTEGRATION_METHOD])) cg.add(var.set_restore(config[CONF_RESTORE])) diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index 8dafb5da070..dc50f8cdc0d 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -93,8 +93,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) for source_conf in config.get(CONF_SOURCE_ID, ()): - source = await cg.get_variable(source_conf) - cg.add(var.add_provider(source)) + await cg.add_variable(var.add_provider, source_conf) if CONF_MIN_LENGTH in config: cg.add(var.set_min_length(config[CONF_MIN_LENGTH])) if CONF_MAX_LENGTH in config: diff --git a/esphome/components/lcd_menu/__init__.py b/esphome/components/lcd_menu/__init__.py index 483a36491db..b314a0ae02d 100644 --- a/esphome/components/lcd_menu/__init__.py +++ b/esphome/components/lcd_menu/__init__.py @@ -61,8 +61,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - disp = await cg.get_variable(config[CONF_DISPLAY_ID]) - cg.add(var.set_display(disp)) + await cg.add_variable(var.set_display, config[CONF_DISPLAY_ID]) cg.add(var.set_dimensions(config[CONF_DIMENSIONS][0], config[CONF_DIMENSIONS][1])) await display_menu_to_code(var, config) cg.add(var.set_mark_selected(config[CONF_MARK_SELECTED])) diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 0d2b3a51804..607ea4448fd 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -126,8 +126,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_LN882H_BLE_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_LN882H_BLE_ID]) # The tracker registers itself as a controller scan listener in setup(); # request the codegen-sized StaticVector slot for it. ln882h_ble.request_scan_listener_slot() From 85724892cf034be0068cad11a826a26bb942327d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Vo=C3=9F?= <46140304+mvoss96@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:08:31 +0200 Subject: [PATCH 582/651] [light] Add power estimation and current limiting for addressable LED strips (#15348) 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/light/__init__.py | 34 +++ .../components/light/addressable_light.cpp | 122 ++++++++ esphome/components/light/addressable_light.h | 53 ++++ .../components/light/esp_color_correction.cpp | 24 +- .../components/light/esp_color_correction.h | 36 ++- esphome/components/light/esp_color_view.h | 9 + esphome/components/light/esp_range_view.cpp | 7 +- esphome/components/light/light_state.cpp | 8 +- esphome/components/light/light_state.h | 3 + esphome/components/partition/light.py | 4 + .../components/partition/light_partition.h | 6 + esphome/core/defines.h | 1 + tests/component_tests/partition/__init__.py | 0 tests/component_tests/partition/test_light.py | 29 ++ tests/components/light/__init__.py | 2 + tests/components/light/test_power_limit.cpp | 276 ++++++++++++++++++ tests/components/spi_led_strip/common.yaml | 13 + 17 files changed, 615 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/partition/__init__.py create mode 100644 tests/component_tests/partition/test_light.py create mode 100644 tests/components/light/test_power_limit.cpp diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index c490300b58c..056ec7c44f9 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -90,6 +90,25 @@ DOMAIN = "light" CONF_GAMMA_TABLE_ID = "gamma_table_id" CONF_RESUME_EFFECT = "resume_effect" +CONF_POWER_ESTIMATION = "power_estimation" +CONF_MA_PER_LED_RED = "ma_per_led_red" +CONF_MA_PER_LED_GREEN = "ma_per_led_green" +CONF_MA_PER_LED_BLUE = "ma_per_led_blue" +CONF_MA_PER_LED_WHITE = "ma_per_led_white" +CONF_IDLE_MA_PER_LED = "idle_ma_per_led" +CONF_MAX_CURRENT_MA = "max_current_ma" + +POWER_ESTIMATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_MA_PER_LED_RED, default=20.0): cv.positive_float, + cv.Optional(CONF_MA_PER_LED_GREEN, default=20.0): cv.positive_float, + cv.Optional(CONF_MA_PER_LED_BLUE, default=20.0): cv.positive_float, + cv.Optional(CONF_MA_PER_LED_WHITE, default=20.0): cv.positive_float, + cv.Optional(CONF_IDLE_MA_PER_LED, default=1.0): cv.positive_float, + cv.Optional(CONF_MAX_CURRENT_MA): cv.positive_not_null_float, + } +) + @dataclass class EffectRef: @@ -446,6 +465,7 @@ ADDRESSABLE_LIGHT_SCHEMA = RGB_LIGHT_SCHEMA.extend( [cv.percentage], cv.Length(min=3, max=4) ), cv.Optional(CONF_POWER_SUPPLY): cv.use_id(power_supply.PowerSupply), + cv.Optional(CONF_POWER_ESTIMATION): POWER_ESTIMATION_SCHEMA, } ) @@ -617,6 +637,20 @@ async def setup_light_core_(light_var, config, output_var): var_ = await cg.get_variable(power_supply_id) cg.add(output_var.set_power_supply(var_)) + if (power_estimation := config.get(CONF_POWER_ESTIMATION)) is not None: + cg.add_define("USE_LIGHT_POWER_ESTIMATION") + cg.add( + output_var.set_power_estimation( + power_estimation[CONF_MA_PER_LED_RED], + power_estimation[CONF_MA_PER_LED_GREEN], + power_estimation[CONF_MA_PER_LED_BLUE], + power_estimation[CONF_MA_PER_LED_WHITE], + power_estimation[CONF_IDLE_MA_PER_LED], + ) + ) + if (max_current_ma := power_estimation.get(CONF_MAX_CURRENT_MA)) is not None: + cg.add(output_var.set_max_current_ma(max_current_ma)) + if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, light_var) await mqtt.register_mqtt_component(mqtt_, config) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index a6440700fa9..5292db13ac0 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -1,12 +1,134 @@ #include "addressable_light.h" #include "esphome/core/log.h" +#include +#include + namespace esphome::light { ESPHOME_LOG_TAG(TAG, "light.addressable"); +#ifdef USE_LIGHT_POWER_ESTIMATION +float AddressableLightState::get_estimated_current_ma() const { + return static_cast(this->output_)->get_estimated_current_ma(); +} + +void AddressableLightState::loop() { + this->apply_effect_and_transformer_(); + if (this->next_write_) + static_cast(this->output_)->limit_power_(); + this->write_if_pending_(); +} + +float AddressableLight::get_estimated_current_ma() const { + return this->power_estimation_ == nullptr ? NAN : this->frame_ma_(); +} + +// Idle draw plus the weighted levels in the buffer +float AddressableLight::frame_ma_(float *slack_ma) const { + uint32_t sums[4]{}; + uint32_t lit[4]{}; + const int32_t count = this->size(); + for (int32_t i = 0; i < count; i++) { + const auto view = this->get_view_internal(i); + const uint8_t levels[4] = {view.get_red_raw(), view.get_green_raw(), view.get_blue_raw(), view.get_white_raw()}; + for (size_t channel = 0; channel < 4; channel++) { + sums[channel] += levels[channel]; + lit[channel] += levels[channel] != 0; + } + } + if (slack_ma != nullptr) { + const auto &power = *this->power_estimation_; + *slack_ma = 0.0f; + for (size_t channel = 0; channel < 4; channel++) + *slack_ma += lit[channel] * power.ma_per_level[channel]; + } + return this->draw_ma_(sums); +} + +float AddressableLight::draw_ma_(const uint32_t sums[4]) const { + const auto &power = *this->power_estimation_; + float ma = power.idle_ma; + for (size_t channel = 0; channel < 4; channel++) + ma += sums[channel] * power.ma_per_level[channel]; + return ma; +} + +void AddressableLight::setup_power_budget_() { + auto &power = *this->power_estimation_; + power.idle_ma = this->size() * power.idle_ma_per_led; + power.last_frame_ma = NAN; + if (std::isnan(power.max_current_ma)) + return; + power.budget_ma = power.max_current_ma - power.idle_ma; + if (power.budget_ma <= 0.0f) { + ESP_LOGW(TAG, "max_current_ma %.0f leaves nothing for the pixels after the idle draw of %.0f mA", + power.max_current_ma, power.idle_ma); + } +} + +// Rounded up on the way down so a lit channel stays lit, down on the way up so a level does not creep +static uint8_t rescale_level(uint8_t level, uint8_t scale, uint8_t previous) { + if (level == 0) + return 0; + const uint32_t scaled = static_cast(level) * scale; + const uint32_t moved = scale < previous ? (scaled + previous - 1) / previous : scaled / previous; + // A level written past the scale in force (set_raw, a partition over another strip) must not wrap + return std::clamp(moved, 1, 255); +} + +// The frame is moved to the new scale before it goes out, so every write path is limited +void AddressableLight::limit_power_() { + auto *power = this->power_estimation_; + if (power == nullptr || std::isnan(power->budget_ma)) + return; + // A frame the last pass already left in the buffer, as a driver retry sends, is not limited again: its + // levels were rounded up on the way down and read as a hair over budget, which would walk the scale down + float slack_ma; + const float frame_ma = this->frame_ma_(&slack_ma); + if (frame_ma == power->last_frame_ma) + return; + power->last_frame_ma = frame_ma; + // The stored levels already carry the limit in force; the scale comes from the draw the frame asked for + const uint8_t previous = this->correction_.get_power_brightness(); + const float intended_ma = (frame_ma - power->idle_ma) * 255.0f / previous; + // Never 0, which would black the strip out and swing the next estimate back to full + const uint8_t scale = intended_ma <= power->budget_ma + ? 255 + : static_cast(std::max(power->budget_ma / intended_ma * 255.0f, 1.0f)); + if (scale == previous) + return; + // Below full scale the levels were rounded up on the way down, so a frame can read as over budget by up + // to one level per lit channel. A drop the frame does not need once that is taken off would only move the + // scale under levels that stay where they are, and inflate what they read back as + if (previous < 255 && scale < previous && frame_ma - power->idle_ma - slack_ma <= power->budget_ma) + return; + this->correction_.set_power_brightness(scale); + // One divide per level instead of one per channel; ESP8266 has no hardware divide + uint8_t map[256]; + for (size_t level = 0; level < 256; level++) + map[level] = rescale_level(level, scale, previous); + uint32_t sums[4]{}; + for (auto view : *this) { + const uint8_t r = map[view.get_red_raw()], g = map[view.get_green_raw()], b = map[view.get_blue_raw()], + w = map[view.get_white_raw()]; + view.set_raw(r, g, b, w); + sums[0] += r; + sums[1] += g; + sums[2] += b; + sums[3] += w; + } + power->last_frame_ma = this->draw_ma_(sums); +} + +#endif + void AddressableLight::call_setup() { this->setup(); +#ifdef USE_LIGHT_POWER_ESTIMATION + if (this->power_estimation_ != nullptr) + this->setup_power_budget_(); +#endif #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE this->set_interval(5000, [this]() { diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 57e9caf289c..b6446f2b9d3 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -10,6 +10,8 @@ #include "light_state.h" #include "light_transformer.h" +#include + #ifdef USE_POWER_SUPPLY #include "esphome/components/power_supply/power_supply.h" #endif @@ -23,8 +25,28 @@ Color color_from_light_color_values(LightColorValues val); /// non-addressable lights. class AddressableLightState final : public LightState { using LightState::LightState; + + public: +#ifdef USE_LIGHT_POWER_ESTIMATION + float get_estimated_current_ma() const; + void loop() override; +#endif }; +#ifdef USE_LIGHT_POWER_ESTIMATION +// Allocated only for a strip with power_estimation configured +struct PowerEstimation { + float ma_per_level[4]; + float idle_ma_per_led; + float max_current_ma{NAN}; + // Set by call_setup() once size() is known; the budget is what is left for the pixels, NAN for no limit + float idle_ma{0.0f}; + float budget_ma{NAN}; + // The draw the last pass left in the buffer; a pass that sees it again has nothing new to limit + float last_frame_ma{NAN}; +}; +#endif + class AddressableLight : public LightOutput, public Component { public: virtual int32_t size() const = 0; @@ -65,6 +87,23 @@ class AddressableLight : public LightOutput, public Component { this->correction_.set_max_brightness( Color(to_uint8_scale(red), to_uint8_scale(green), to_uint8_scale(blue), to_uint8_scale(white))); } +#ifdef USE_LIGHT_POWER_ESTIMATION + // Per-channel draw at full level and idle draw per LED + void set_power_estimation(float ma_red, float ma_green, float ma_blue, float ma_white, float idle_ma_per_led) { + if (this->power_estimation_ == nullptr) + this->power_estimation_ = new PowerEstimation(); // NOLINT(cppcoreguidelines-owning-memory) + auto *power = this->power_estimation_; + power->ma_per_level[0] = ma_red / 255.0f; + power->ma_per_level[1] = ma_green / 255.0f; + power->ma_per_level[2] = ma_blue / 255.0f; + power->ma_per_level[3] = ma_white / 255.0f; + power->idle_ma_per_led = idle_ma_per_led; + } + // Called after set_power_estimation() + void set_max_current_ma(float max_current_ma) { this->power_estimation_->max_current_ma = max_current_ma; } + // The draw of the frame in the buffer, NAN without power_estimation + float get_estimated_current_ma() const; +#endif void setup_state(LightState *state) override { #ifdef USE_LIGHT_GAMMA_LUT this->correction_.set_gamma_table(state->get_gamma_table()); @@ -96,8 +135,22 @@ class AddressableLight : public LightOutput, public Component { } virtual ESPColorView get_view_internal(int32_t index) const = 0; +#ifdef USE_LIGHT_POWER_ESTIMATION + friend class AddressableLightState; + // Run by the light state before each frame goes out + void limit_power_(); + void setup_power_budget_(); + // slack_ma, when asked for, is one level's draw for every lit channel: the most the round-up on the way + // down can have added to the frame + float frame_ma_(float *slack_ma = nullptr) const; + float draw_ma_(const uint32_t sums[4]) const; +#endif + ESPColorCorrection correction_{}; LightState *state_parent_{nullptr}; +#ifdef USE_LIGHT_POWER_ESTIMATION + PowerEstimation *power_estimation_{nullptr}; +#endif #ifdef USE_POWER_SUPPLY power_supply::PowerSupplyRequester power_; #endif diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index 12eb6a30089..567d1c3c0b3 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -32,12 +32,34 @@ Color ESPColorCorrection::color_uncorrect(Color color) const { this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); } +// Under the limit, gamma and the limit are undone together: the largest level that encodes to at most the +// stored one, so a read, change and write of a pixel re-encodes to what was stored +uint8_t ESPColorCorrection::uncorrect_level_(uint8_t value) const { +#ifdef USE_LIGHT_POWER_ESTIMATION + if (this->power_brightness_ == 255) + return this->gamma_uncorrect_(value); + uint8_t lo = 0; + uint8_t hi = 255; + while (lo < hi) { + const uint8_t mid = (lo + hi + 1) / 2; + if (this->limit_(this->gamma_correct_(mid)) <= value) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; +#else + return this->gamma_uncorrect_(value); +#endif +} + uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const { if (max_brightness == 0 || this->local_brightness_ == 0) return 0; // Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero, // (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs. - uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL; + uint32_t uncorrected = this->uncorrect_level_(value) * 255UL; uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_; return static_cast(std::min(res, uint32_t(255))); } diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 0295fb36b8c..46b8e9db5da 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -1,8 +1,11 @@ #pragma once #include "esphome/core/color.h" +#include "esphome/core/defines.h" #include "esphome/core/hal.h" +#include + namespace esphome::light { /// A gamma curve as codegen emits it into PROGMEM: the lookup table and the gamma it was built from @@ -30,6 +33,11 @@ class ESPColorCorrection { public: void set_max_brightness(const Color &max_brightness) { this->max_brightness_ = max_brightness; } void set_local_brightness(uint8_t local_brightness) { this->local_brightness_ = local_brightness; } +#ifdef USE_LIGHT_POWER_ESTIMATION + // The current limit's factor, applied after gamma + void set_power_brightness(uint8_t power_brightness) { this->power_brightness_ = power_brightness; } + uint8_t get_power_brightness() const { return this->power_brightness_; } +#endif void set_gamma_table(const uint16_t *table) { this->gamma_table_ = table; } inline Color color_correct(Color color) const ESPHOME_ALWAYS_INLINE { // corrected = (uncorrected * max_brightness * local_brightness) ^ gamma @@ -37,20 +45,16 @@ class ESPColorCorrection { this->color_correct_blue(color.blue), this->color_correct_white(color.white)); } inline uint8_t color_correct_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(red, this->max_brightness_.red, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(red, this->max_brightness_.red); } inline uint8_t color_correct_green(uint8_t green) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(green, this->max_brightness_.green, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(green, this->max_brightness_.green); } inline uint8_t color_correct_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(blue, this->max_brightness_.blue, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(blue, this->max_brightness_.blue); } inline uint8_t color_correct_white(uint8_t white) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(white, this->max_brightness_.white); } Color color_uncorrect(Color color) const; inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { @@ -67,8 +71,21 @@ class ESPColorCorrection { } protected: + inline uint8_t color_correct_channel_(uint8_t value, uint8_t max_brightness) const ESPHOME_ALWAYS_INLINE { + return this->limit_(this->gamma_correct_(esp_scale8_twice(value, max_brightness, this->local_brightness_))); + } + // The limit on a gamma-corrected level; rounded up, so a lit channel stays lit + inline uint8_t limit_(uint8_t res) const ESPHOME_ALWAYS_INLINE { +#ifdef USE_LIGHT_POWER_ESTIMATION + if (res != 0) + res = std::max(esp_scale8(res, this->power_brightness_), 1); +#endif + return res; + } /// Forward gamma: read uint16 PROGMEM table, convert to uint8 uint8_t gamma_correct_(uint8_t value) const; + /// Reverse gamma and limit for one stored level + uint8_t uncorrect_level_(uint8_t value) const; /// Reverse gamma: binary search the forward PROGMEM table uint8_t gamma_uncorrect_(uint8_t value) const; /// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line @@ -78,6 +95,9 @@ class ESPColorCorrection { const uint16_t *gamma_table_{nullptr}; Color max_brightness_{255, 255, 255, 255}; uint8_t local_brightness_{255}; +#ifdef USE_LIGHT_POWER_ESTIMATION + uint8_t power_brightness_{255}; +#endif }; } // namespace esphome::light diff --git a/esphome/components/light/esp_color_view.h b/esphome/components/light/esp_color_view.h index 018e30a9a81..f764f7667da 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -89,9 +89,18 @@ class ESPColorView : public ESPColorSettable { return 0; return *this->effect_data_; } + const ESPColorCorrection *get_color_correction() const { return this->color_correction_; } void raw_set_color_correction(const ESPColorCorrection *color_correction) { this->color_correction_ = color_correction; } + /// Store hardware levels as they are, without the correction + void set_raw(uint8_t red, uint8_t green, uint8_t blue, uint8_t white) { + *this->red_ = red; + *this->green_ = green; + *this->blue_ = blue; + if (this->white_ != nullptr) + *this->white_ = white; + } protected: uint8_t *const red_; diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index 5d372983d90..fe7c4a086eb 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -72,15 +72,18 @@ ESPRangeView &ESPRangeView::operator=(const ESPRangeView &rhs) { // NOLINT if (rhs.begin_ == this->begin_) return *this; + // The source is read before the destination view is fetched: a partition's views share one correction if (rhs.begin_ > this->begin_) { // Copy from left for (int32_t i = 0; i < this->size(); i++) { - (*this)[i].set(rhs[i].get()); + const Color color = rhs[i].get(); + (*this)[i].set(color); } } else { // Copy from right for (int32_t i = this->size() - 1; i >= 0; i--) { - (*this)[i].set(rhs[i].get()); + const Color color = rhs[i].get(); + (*this)[i].set(color); } } diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index d8293338897..84db3653a36 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -121,6 +121,11 @@ void LightState::dump_config() { } } void LightState::loop() { + this->apply_effect_and_transformer_(); + this->write_if_pending_(); +} + +void LightState::apply_effect_and_transformer_() { // Apply effect (if any) auto *effect = this->get_active_effect_(); if (effect != nullptr) { @@ -171,8 +176,9 @@ void LightState::loop() { this->disable_loop_if_idle_(); } } +} - // Write state to the light +void LightState::write_if_pending_() { if (this->next_write_) { this->next_write_ = false; this->output_->write_state(this); diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 946b4efae58..86a132c1434 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -313,6 +313,9 @@ class LightState : public EntityBase, public Component { bool is_transformer_active() const { return this->is_transformer_active_; } protected: + // The two halves of loop() + void apply_effect_and_transformer_(); + void write_if_pending_(); friend LightOutput; friend LightCall; friend class AddressableLight; diff --git a/esphome/components/partition/light.py b/esphome/components/partition/light.py index b2341f95b7f..eab2e1ac660 100644 --- a/esphome/components/partition/light.py +++ b/esphome/components/partition/light.py @@ -84,6 +84,10 @@ CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( ), cv.Length(min=1), ), + cv.Optional(light.CONF_POWER_ESTIMATION): cv.invalid( + "power_estimation belongs on the strip a partition draws from; " + "the strip's limit covers every partition on it" + ), } ) diff --git a/esphome/components/partition/light_partition.h b/esphome/components/partition/light_partition.h index adadde068cb..c843474ab4c 100644 --- a/esphome/components/partition/light_partition.h +++ b/esphome/components/partition/light_partition.h @@ -85,6 +85,12 @@ class PartitionLightOutput final : public light::AddressableLight { } auto view = (*seg.get_src())[src_off]; +#ifdef USE_LIGHT_POWER_ESTIMATION + // Writes go through the strip's power factor; a partition has no limit of its own. A view kept alive + // across two strips with different limits carries the factor of the strip fetched last. + const_cast(this->correction_) + .set_power_brightness(view.get_color_correction()->get_power_brightness()); +#endif view.raw_set_color_correction(&this->correction_); return view; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 852ea3439a9..ed8f7b984a0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -114,6 +114,7 @@ #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_POWER_ESTIMATION #define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LINE_READER diff --git a/tests/component_tests/partition/__init__.py b/tests/component_tests/partition/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/partition/test_light.py b/tests/component_tests/partition/test_light.py new file mode 100644 index 00000000000..eee7d8a48fe --- /dev/null +++ b/tests/component_tests/partition/test_light.py @@ -0,0 +1,29 @@ +"""Partition light schema.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.light import CONF_POWER_ESTIMATION +from esphome.components.partition.light import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_partition_rejects_its_own_power_estimation( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="belongs on the strip") as exc: + CONFIG_SCHEMA( + { + "name": "Part", + "segments": [{"id": "strip", "from": 0, "to": 3}], + CONF_POWER_ESTIMATION: { + "ma_per_led_red": 16.0, + "ma_per_led_green": 11.0, + "ma_per_led_blue": 15.0, + "idle_ma_per_led": 1.0, + }, + } + ) + assert exc.value.path == [CONF_POWER_ESTIMATION] diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py index 2d5eb9223dc..f15cad320ba 100644 --- a/tests/components/light/__init__.py +++ b/tests/components/light/__init__.py @@ -10,6 +10,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_WEBSERVER_PORT", 80) # The gamma table and its stored gamma are behind this define cg.add_define("USE_LIGHT_GAMMA_LUT") + # The current estimate and limit of addressable lights + cg.add_define("USE_LIGHT_POWER_ESTIMATION") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + ["json"] diff --git a/tests/components/light/test_power_limit.cpp b/tests/components/light/test_power_limit.cpp new file mode 100644 index 00000000000..62183e5da33 --- /dev/null +++ b/tests/components/light/test_power_limit.cpp @@ -0,0 +1,276 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/esp_color_correction.h" + +namespace esphome::light::testing { + +namespace { + +std::array build_gamma_table(double gamma) { + std::array table{}; + for (int i = 1; i < 256; i++) { + double raw = std::round(std::pow(i / 255.0, gamma) * 65535.0); + table[i] = static_cast(std::max(1.0, std::min(65535.0, raw))); + } + return table; +} + +// A strip of RGBW pixels in memory, with the protected hooks the tests drive +class Strip : public AddressableLight { + public: + explicit Strip(int32_t count) : buffer_(count * 4) {} + ~Strip() { delete this->power_estimation_; } // NOLINT(cppcoreguidelines-owning-memory) + int32_t size() const override { return this->buffer_.size() / 4; } + void clear_effect_data() override {} + LightTraits get_traits() override { return {}; } + void write_state(LightState *state) override { this->sent_red = (*this)[0].get_red_raw(); } + uint8_t sent_red{0}; + // Every channel draws ma_per_channel at full level; setup turns the settings into the idle draw and budget + void configure_power(float ma_per_channel, float idle_ma_per_led, float max_current_ma) { + this->set_power_estimation(ma_per_channel, ma_per_channel, ma_per_channel, ma_per_channel, idle_ma_per_led); + this->set_max_current_ma(max_current_ma); + this->call_setup(); + } + // What the light state does before a frame goes out + void limit() { this->limit_power_(); } + uint8_t power_brightness() const { return this->correction_.get_power_brightness(); } + + protected: + ESPColorView get_view_internal(int32_t index) const override { + auto *pixel = const_cast(&this->buffer_[index * 4]); + return {pixel, pixel + 1, pixel + 2, pixel + 3, nullptr, &this->correction_}; + } + + std::vector buffer_; +}; + +} // namespace + +TEST(PowerLimit, CorrectionRoundTripsUnderTheLimit) { + const auto table = build_gamma_table(2.2); + for (uint8_t scale : {128, 200}) { + for (bool with_gamma : {false, true}) { + ESPColorCorrection correction; + if (with_gamma) + correction.set_gamma_table(table.data()); + correction.set_power_brightness(scale); + for (int value = 0; value < 256; value++) { + const uint8_t stored = correction.color_correct_red(value); + const int back = correction.color_uncorrect_red(stored); + // A read, change-nothing and write re-encodes to the stored level, so the dimming never compounds + EXPECT_EQ(correction.color_correct_red(back), stored) + << "scale=" << int(scale) << " gamma=" << with_gamma << " value=" << value; + // Below 96 the gamma curve leaves the limited hardware range too few levels to tell apart + if (with_gamma ? value >= 96 : value >= 2) { + EXPECT_LE(std::abs(back - value), with_gamma ? 4 : 2) + << "scale=" << int(scale) << " gamma=" << with_gamma << " value=" << value; + } + } + } + } +} + +TEST(PowerLimit, StaysAtFullScaleUnderBudget) { + Strip strip(10); + // 10 pixels at 20 mA per channel draw 10 x 60 + 10 mA idle; the budget is twice that + strip.configure_power(20, 1, 1300); + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + EXPECT_EQ(strip.power_brightness(), 255); + EXPECT_NEAR(strip.get_estimated_current_ma(), 610.0f, 0.5f); +} + +TEST(PowerLimit, SettlesOnTheBudgetAndKeepsLogicalLevels) { + Strip strip(10); + strip.configure_power(20, 1, 310); // 300 mA for the pixels of a 600 mA frame + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t scale = strip.power_brightness(); + EXPECT_NEAR(scale, 128, 2); + // The estimate is of the frame that goes out, not of what was asked for + EXPECT_NEAR(strip.get_estimated_current_ma(), 310.0f, 5.0f); + // The sent frame was rescaled, so a second pass sees the same draw and keeps the scale + strip.limit(); + EXPECT_EQ(strip.power_brightness(), scale); + EXPECT_NEAR(strip.get_estimated_current_ma(), 310.0f, 5.0f); + // Read-back still gives the levels that were written + EXPECT_NEAR(strip[0].get_red(), 255, 2); + EXPECT_NEAR(strip[0].get_green(), 255, 2); +} + +TEST(PowerLimit, APixelWrittenUnderTheLimitLandsLimited) { + Strip strip(4); + strip.configure_power(20, 1, 124); // 120 mA for pixels that ask for 240 mA + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t scale = strip.power_brightness(); + ASSERT_LT(scale, 255); + // A single pixel rewritten by a lambda lands at the limited level straight away + strip[1] = Color(128, 0, 0, 0); + EXPECT_NEAR(strip[1].get_red_raw(), (128 * scale) / 255.0f, 2); + EXPECT_NEAR(strip[1].get_red(), 128, 2); +} + +TEST(PowerLimit, LevelsDoNotCreepOverALimitCycle) { + Strip strip(4); + // Levels written once and never touched again, as a lambda would leave them + for (int i = 0; i < 4; i++) + strip[i].set_raw(1 + i * 40, 2, 100, 0); + // 1020 mA at full level, so the budget of 300 mA limits the strip + strip.configure_power(255, 0, 300); + strip.limit(); + const uint8_t limited = strip.power_brightness(); + ASSERT_LT(limited, 255); + // Lit channels stay lit while limited + for (int i = 0; i < 4; i++) + EXPECT_GE(strip[i].get_green_raw(), 1) << i; + // A budget the strip cannot reach, and the next frame restores the scale + strip.configure_power(255, 0, 100000); + strip.limit(); + ASSERT_EQ(strip.power_brightness(), 255); + // Each level comes back within one quantisation step of where it started, never below it + const int step = (255 + limited - 1) / limited; + for (int i = 0; i < 4; i++) { + const int red = 1 + i * 40; + EXPECT_GE(strip[i].get_red_raw(), red) << i; + EXPECT_LE(strip[i].get_red_raw(), red + step) << i; + EXPECT_GE(strip[i].get_blue_raw(), 100) << i; + EXPECT_LE(strip[i].get_blue_raw(), 100 + step) << i; + } +} + +TEST(PowerLimit, ADimPixelSettlesWhileDemandAlternates) { + Strip strip(2); + strip.configure_power(20, 0, 10.04f); // two full red pixels would draw 40 mA + strip[0].set_raw(1, 0, 0, 0); + uint8_t dim = 0; + for (int cycle = 0; cycle < 4; cycle++) { + strip[1].set_raw(255, 0, 0, 0); + strip.limit(); + ASSERT_LT(strip.power_brightness(), 255) << cycle; + strip[1].set_raw(0, 0, 0, 0); + strip.limit(); + ASSERT_EQ(strip.power_brightness(), 255) << cycle; + // The first cycle may move the level by one quantisation step; after that it stays put + if (cycle > 0) + EXPECT_EQ(strip[0].get_red_raw(), dim) << cycle; + dim = strip[0].get_red_raw(); + EXPECT_GE(dim, 1) << cycle; + EXPECT_LE(dim, 3) << cycle; + } +} + +TEST(PowerLimit, TheStateLimitsAFrameBeforeTheDriverSendsIt) { + Strip strip(2); + AddressableLightState state(&strip); + strip.setup_state(&state); + strip.configure_power(20, 0, 10); // two full red pixels would draw 40 mA + strip.all() = Color(255, 0, 0, 0); + strip.schedule_show(); + state.loop(); + EXPECT_LT(strip.sent_red, 255); + EXPECT_EQ(strip.sent_red, strip[0].get_red_raw()); + // Nothing pending: the next loop neither limits nor writes + strip[0].set_raw(255, 0, 0, 0); + state.loop(); + EXPECT_EQ(strip[0].get_red_raw(), 255); +} + +TEST(PowerLimit, ALevelPastTheScaleInForceSaturatesWhenTheScaleRises) { + Strip strip(2); + strip.configure_power(20, 0, 30); // two full red pixels would draw 40 mA + strip.all() = Color(255, 0, 0, 0); + strip.limit(); + ASSERT_LT(strip.power_brightness(), 255); + // A raw write past the scale, as a lambda or a partition over another strip can make; one such pixel + // alone fits the budget, so the scale goes back up + strip[0].set_raw(255, 0, 0, 0); + strip[1].set_raw(0, 0, 0, 0); + strip.limit(); + ASSERT_EQ(strip.power_brightness(), 255); + EXPECT_EQ(strip[0].get_red_raw(), 255); +} + +TEST(PowerLimit, RepeatedPassesOverAnUnchangedFrameHoldTheScale) { + Strip strip(1); + strip.configure_power(20, 0, 5); // a half-red pixel asks for 10 mA + strip[0] = Color(128, 0, 0, 0); + strip.limit(); + const uint8_t scale = strip.power_brightness(); + EXPECT_NEAR(scale, 127, 2); + // A driver retry re-runs the pass with nothing rewritten: the scale and the read-back stay put + for (int pass = 0; pass < 20; pass++) + strip.limit(); + EXPECT_EQ(strip.power_brightness(), scale); + EXPECT_NEAR(strip[0].get_red(), 128, 2); +} + +TEST(PowerLimit, AHeavyLimitStaysWithinOneStepOfTheBudget) { + Strip strip(1); + strip.configure_power(20, 0, 0.3f); // a full red pixel asks for 20 mA against a 0.3 mA budget + strip[0] = Color(255, 0, 0, 0); + for (int pass = 0; pass < 20; pass++) + strip.limit(); + const uint8_t scale = strip.power_brightness(); + EXPECT_GE(scale, 1); + EXPECT_LE(scale, 4); + // No dead band this low: the frame is within one level of the budget + EXPECT_LE(strip.get_estimated_current_ma(), 0.3f + 20.0f / 255.0f); +} + +TEST(PowerLimit, AChangedFrameTakesTheDropAtALowScale) { + Strip strip(10); + strip.configure_power(20, 1, 20); // white pixels draw 60 mA each against 10 mA left for the pixels + for (int i = 0; i < 5; i++) + strip[i] = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t five = strip.power_brightness(); + EXPECT_NEAR(five, 8, 1); + // Twice the pixels lit: well past one level per channel over the budget, so the scale drops + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t ten = strip.power_brightness(); + EXPECT_LT(ten, five); + // A retry with nothing rewritten then holds it + strip.limit(); + EXPECT_EQ(strip.power_brightness(), ten); +} + +TEST(PowerLimit, AChangingPixelOverAStaticBackgroundDoesNotWalkTheScaleDown) { + Strip strip(100); + strip.configure_power(20, 0, 60); // 99 pixels at red 100 ask for 776 mA + for (int i = 1; i < 100; i++) + strip[i] = Color(100, 0, 0, 0); + strip[0] = Color(255, 0, 0, 0); + strip.limit(); + const uint8_t first = strip.power_brightness(); + EXPECT_NEAR(first, 19, 2); + // A dot blinks over the background: every frame differs, so every pass runs + for (int pass = 0; pass < 40; pass++) { + strip[0] = Color(pass % 2 ? 255 : 0, 0, 0, 0); + strip.limit(); + } + EXPECT_NEAR(strip.power_brightness(), first, 2); + // One raw level at this scale is about 13 logical levels + EXPECT_NEAR(strip[50].get_red(), 100, 16); +} + +TEST(PowerLimit, AFrameJustOverBudgetAtFullScaleIsLimited) { + Strip strip(10); + strip.configure_power(20, 0, 199); // ten full red pixels draw 200 mA + strip.all() = Color(255, 0, 0, 0); + strip.limit(); + // Nothing was rounded at full scale, so the overshoot is real and the scale drops + EXPECT_LT(strip.power_brightness(), 255); + EXPECT_LE(strip.get_estimated_current_ma(), 199.0f + 10 * 20.0f / 255.0f); +} + +} // namespace esphome::light::testing diff --git a/tests/components/spi_led_strip/common.yaml b/tests/components/spi_led_strip/common.yaml index 30e2ef22f78..1927f53fae4 100644 --- a/tests/components/spi_led_strip/common.yaml +++ b/tests/components/spi_led_strip/common.yaml @@ -5,3 +5,16 @@ light: id: rgb_led name: RGB LED data_rate: 8MHz + power_estimation: + ma_per_led_red: 16.0 + ma_per_led_green: 11.0 + ma_per_led_blue: 15.0 + idle_ma_per_led: 1.0 + max_current_ma: 100.0 + +# get_estimated_current_ma() is callable from a lambda +interval: + - interval: 60s + then: + - lambda: |- + ESP_LOGI("test", "Estimated current: %.1f mA", id(rgb_led).get_estimated_current_ma()); From a153fcc8eede4d218b3d3c801cf3a9a59a47fe21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 08:30:10 -1000 Subject: [PATCH 583/651] [bl0906][kamstrup_kmp][ufm01] Drop stale receive bytes with discard_available (#21071) --- esphome/components/bl0906/bl0906.cpp | 6 ++---- esphome/components/kamstrup_kmp/kamstrup_kmp.cpp | 9 +-------- esphome/components/kamstrup_kmp/kamstrup_kmp.h | 1 - esphome/components/ufm01/ufm01.cpp | 5 +---- 4 files changed, 4 insertions(+), 17 deletions(-) diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 681c7ad15eb..12ea9f40faa 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,8 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) } void BL0906::loop() { - while (this->available()) - this->read(); + this->discard_available(); if (this->current_stage_ == STAGE_IDLE) { // Woken up between cycles to drain the action queue. Go back to sleep. @@ -92,8 +91,7 @@ void BL0906::advance_stage_() { } void BL0906::setup() { - while (this->available()) - this->read(); + this->discard_available(); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); // Calibration (1: register address; 2: value before calibration; 3: value after calibration) this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 396f4514cfe..f69bafcaf16 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -77,7 +77,7 @@ void KamstrupKMPComponent::send_command_(uint16_t command) { msg[3] = command >> 8; msg[4] = command & 0xFF; - this->clear_uart_rx_buffer_(); + this->discard_available(); this->send_message_(msg, msg_len); this->read_command_(command); } @@ -122,13 +122,6 @@ void KamstrupKMPComponent::send_message_(const uint8_t *msg, int msg_len) { this->write_array(tx_msg, tx_msg_len); } -void KamstrupKMPComponent::clear_uart_rx_buffer_() { - uint8_t tmp; - while (this->available()) { - this->read_byte(&tmp); - } -} - void KamstrupKMPComponent::read_command_(uint16_t command) { uint8_t buffer[20] = {0}; size_t buffer_len = 0; diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.h b/esphome/components/kamstrup_kmp/kamstrup_kmp.h index 57a89f77a1c..49a80d73157 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.h +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.h @@ -114,7 +114,6 @@ class KamstrupKMPComponent final : public PollingComponent, public uart::UARTDev // Sends a message to the meter. A prefix/suffix and CRC are added void send_message_(const uint8_t *msg, int msg_len); // Clears and data that might be in the UART Rx buffer - void clear_uart_rx_buffer_(); // Reads and validates the response to a send command void read_command_(uint16_t command); // Parses a received message diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 47ff2165f8c..15d645ce1a9 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -214,10 +214,7 @@ static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { } void UFM01Component::flush_rx_() { - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - } + this->discard_available(); this->read_index_ = 0; } From 71c0f46e35ffb099b1ab6389be8c91d379a09dad Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:33:17 +0200 Subject: [PATCH 584/651] [bme68x_bsec2] disable loop (#20994) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../components/bme68x_bsec2/bme68x_bsec2.cpp | 44 ++++++++++++++----- .../components/bme68x_bsec2/bme68x_bsec2.h | 1 + 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index f5a674d6b92..c563e120842 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -16,6 +17,7 @@ namespace esphome::bme68x_bsec2 { ESPHOME_LOG_TAG(TAG, "bme68x_bsec2.sensor"); static constexpr uint32_t READ_TIMEOUT_ID = 0; +static constexpr uint32_t RUN_TIMEOUT_ID = 1; static constexpr const char *const IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; @@ -61,7 +63,9 @@ void BME68xBSEC2Component::setup() { return; } + this->disable_loop(); this->load_state_(); + this->run_(); } void BME68xBSEC2Component::dump_config() { @@ -115,9 +119,7 @@ void BME68xBSEC2Component::dump_config() { #endif } -void BME68xBSEC2Component::loop() { - this->run_(); - +void BME68xBSEC2Component::check_status_() { if (this->bsec_status_ < BSEC_OK || this->bme68x_status_ < BME68X_OK) { this->status_set_error(); } else { @@ -129,12 +131,17 @@ void BME68xBSEC2Component::loop() { } else { this->status_clear_warning(); } +} + +void BME68xBSEC2Component::loop() { // Process a single action from the queue. These are primarily sensor state publishes // that in totality take too long to send in a single call. if (this->queue_.size()) { auto action = std::move(this->queue_.front()); this->queue_.pop(); action(); + } else { + this->disable_loop(); } } @@ -219,15 +226,15 @@ void BME68xBSEC2Component::update_subscription_() { void BME68xBSEC2Component::run_() { this->op_mode_ = this->bsec_settings_.op_mode; int64_t curr_time_ns = this->get_time_ns_(); - if (curr_time_ns < this->bsec_settings_.next_call) { - return; - } ESP_LOGV(TAG, "Performing sensor run"); struct bme68x_conf bme68x_conf; this->bsec_status_ = bsec_sensor_control_m(&this->bsec_instance_, curr_time_ns, &this->bsec_settings_); if (this->bsec_status_ < BSEC_OK) { ESP_LOGW(TAG, "Fetching control settings failed (BSEC2 error code %d)", this->bsec_status_); + this->check_status_(); + // schedule next run after short delay + this->set_timeout(RUN_TIMEOUT_ID, 50, [this]() { this->run_(); }); return; } @@ -287,12 +294,25 @@ void BME68xBSEC2Component::run_() { uint32_t meas_dur = bme68x_get_meas_dur(this->op_mode_, &bme68x_conf, &this->bme68x_); ESP_LOGV(TAG, "Queueing read in %" PRIu32 "us", meas_dur); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout(READ_TIMEOUT_ID, meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, meas_dur / 1000, [this]() { + this->read_(this->trigger_time_ns_); + this->check_status_(); + }); } else { ESP_LOGV(TAG, "Measurement not required, queueing immediate read"); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout(READ_TIMEOUT_ID, 0, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, 0, [this]() { + this->read_(this->trigger_time_ns_); + this->check_status_(); + }); } + + // schedule next run + int64_t delay_ns = this->bsec_settings_.next_call - this->get_time_ns_(); + uint32_t delay_ms = delay_ns > 0 ? static_cast((delay_ns + 999999) / 1000000) : 1; + ESP_LOGV(TAG, "Next run in: %" PRIu32 "ms", delay_ms); + this->set_timeout(RUN_TIMEOUT_ID, delay_ms, [this]() { this->run_(); }); + this->check_status_(); } void BME68xBSEC2Component::read_(int64_t trigger_time_ns) { @@ -454,6 +474,7 @@ void BME68xBSEC2Component::publish_(const bsec_output_t *outputs, uint8_t num_ou // Queue up an opportunity to save state this->queue_push_([this, max_accuracy]() { this->save_state_(max_accuracy); }); } + this->enable_loop(); } int64_t BME68xBSEC2Component::get_time_ns_() { @@ -496,13 +517,15 @@ void BME68xBSEC2Component::load_state_() { bsec_set_state_m(&this->bsec_instance_, state, BSEC_MAX_STATE_BLOB_SIZE, work_buffer, sizeof(work_buffer)); if (this->bsec_status_ != BSEC_OK) { ESP_LOGW(TAG, "Failed to load state (BSEC2 error code %d)", this->bsec_status_); + // no need to set error here } ESP_LOGI(TAG, "Loaded state"); } } void BME68xBSEC2Component::save_state_(uint8_t accuracy) { - if (accuracy < 3 || (millis() - this->last_state_save_ms_ < this->state_save_interval_ms_)) { + if (accuracy < 3 || + (App.get_loop_component_start_time() - this->last_state_save_ms_ < this->state_save_interval_ms_)) { return; } @@ -516,6 +539,7 @@ void BME68xBSEC2Component::save_state_(uint8_t accuracy) { BSEC_MAX_STATE_BLOB_SIZE, &num_serialized_state); if (this->bsec_status_ != BSEC_OK) { ESP_LOGW(TAG, "Failed fetch state for save (BSEC2 error code %d)", this->bsec_status_); + this->check_status_(); return; } @@ -523,7 +547,7 @@ void BME68xBSEC2Component::save_state_(uint8_t accuracy) { ESP_LOGW(TAG, "Failed to save state"); return; } - this->last_state_save_ms_ = millis(); + this->last_state_save_ms_ = App.get_loop_component_start_time(); ESP_LOGI(TAG, "Saved state"); } diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.h b/esphome/components/bme68x_bsec2/bme68x_bsec2.h index 9317229a1fb..9e1e46c8043 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.h +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.h @@ -91,6 +91,7 @@ class BME68xBSEC2Component : public Component { void read_(int64_t trigger_time_ns); void publish_(const bsec_output_t *outputs, uint8_t num_outputs); int64_t get_time_ns_(); + void check_status_(); #ifdef USE_SENSOR void publish_sensor_(sensor::Sensor *sensor, float value, bool change_only = false); From a3bb17d2c688092e3b834cd7d15956fe826ae619 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 09:09:58 -1000 Subject: [PATCH 585/651] [components] Create BLE and modbus devices with the new helpers (#21026) --- esphome/components/alpha3/sensor.py | 5 +---- esphome/components/aranet/sensor.py | 7 +++---- esphome/components/atc_mithermometer/sensor.py | 5 +---- esphome/components/b_parasite/sensor.py | 5 +---- esphome/components/ecocomfort2/__init__.py | 6 ++---- esphome/components/growatt_solar/sensor.py | 5 +---- esphome/components/havells_solar/sensor.py | 5 +---- esphome/components/inkbird_ibsth1_mini/sensor.py | 5 +---- esphome/components/kuntze/sensor.py | 5 +---- esphome/components/mopeka_pro_check/sensor.py | 5 +---- esphome/components/mopeka_std_check/sensor.py | 5 +---- esphome/components/pvvx_mithermometer/sensor.py | 5 +---- esphome/components/pzem6l24/sensor.py | 4 +--- esphome/components/pzemac/sensor.py | 4 +--- esphome/components/pzemdc/sensor.py | 4 +--- esphome/components/renogy_inverter_ble/__init__.py | 5 +---- esphome/components/ruuvitag/sensor.py | 5 +---- esphome/components/sdm_meter/sensor.py | 5 +---- esphome/components/selec_meter/sensor.py | 5 +---- esphome/components/thermopro_ble/sensor.py | 5 +---- esphome/components/xiaomi_body_scale/sensor.py | 7 ++----- esphome/components/xiaomi_cgd1/sensor.py | 5 +---- esphome/components/xiaomi_cgdk2/sensor.py | 5 +---- esphome/components/xiaomi_cgg1/sensor.py | 5 +---- esphome/components/xiaomi_gcls002/sensor.py | 5 +---- esphome/components/xiaomi_hhccjcy01/sensor.py | 5 +---- esphome/components/xiaomi_hhccjcy10/sensor.py | 5 +---- esphome/components/xiaomi_hhccpot002/sensor.py | 5 +---- esphome/components/xiaomi_jqjcy01ym/sensor.py | 5 +---- esphome/components/xiaomi_lywsd02/sensor.py | 5 +---- esphome/components/xiaomi_lywsd02mmc/sensor.py | 5 +---- esphome/components/xiaomi_lywsd03mmc/sensor.py | 5 +---- esphome/components/xiaomi_lywsdcgq/sensor.py | 5 +---- esphome/components/xiaomi_mhoc303/sensor.py | 5 +---- esphome/components/xiaomi_mhoc401/sensor.py | 5 +---- esphome/components/xiaomi_miscale/sensor.py | 5 +---- esphome/components/xiaomi_rtcgq02lm/__init__.py | 6 ++---- esphome/components/xiaomi_xmwsdj04mmc/sensor.py | 5 +---- 38 files changed, 43 insertions(+), 150 deletions(-) diff --git a/esphome/components/alpha3/sensor.py b/esphome/components/alpha3/sensor.py index d8d59242fac..ada426ca095 100644 --- a/esphome/components/alpha3/sensor.py +++ b/esphome/components/alpha3/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_FLOW, CONF_HEAD, - CONF_ID, CONF_POWER, CONF_SPEED, CONF_VOLTAGE, @@ -70,9 +69,7 @@ CONFIG_SCHEMA = ( 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) + var = await ble_client.new_ble_node_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FLOW, var.set_flow_sensor) diff --git a/esphome/components/aranet/sensor.py b/esphome/components/aranet/sensor.py index f22d242f9cc..c31325df933 100644 --- a/esphome/components/aranet/sensor.py +++ b/esphome/components/aranet/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CO2, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_PRESSURE, CONF_RADON, @@ -131,9 +130,9 @@ CONFIG_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) + var = await ble_device_base.new_ble_device_component( + config, config[CONF_MAC_ADDRESS].as_hex + ) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index cde1a41f657..bacb6e46b87 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_SIGNAL_STRENGTH, CONF_TEMPERATURE, @@ -79,9 +78,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/b_parasite/sensor.py b/esphome/components/b_parasite/sensor.py index cb8f569c0d6..74573fee262 100644 --- a/esphome/components/b_parasite/sensor.py +++ b/esphome/components/b_parasite/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -76,9 +75,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py index c2540db644e..9cb9bc0e970 100644 --- a/esphome/components/ecocomfort2/__init__.py +++ b/esphome/components/ecocomfort2/__init__.py @@ -6,7 +6,7 @@ from esphome.components.esp32_ble import ( CONF_MAX_KEY_SIZE, ) import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TIME_ID +from esphome.const import CONF_TIME_ID from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -50,9 +50,7 @@ async def register_ecocomfort2_child(var: MockObj, config: ConfigType) -> None: 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) + var = await ble_client.new_ble_node_component(config) await cg.add_variable(var.set_time, config.get(CONF_TIME_ID)) diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 0f49a30e6da..2f277fbbebf 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_ACTIVE_POWER, CONF_CURRENT, CONF_FREQUENCY, - CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C, @@ -168,9 +167,7 @@ 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 modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index d2db05381d8..20047f9ea81 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_ACTIVE_POWER, CONF_CURRENT, CONF_FREQUENCY, - CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C, @@ -225,9 +224,7 @@ 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 modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FREQUENCY, var.set_frequency_sensor) diff --git a/esphome/components/inkbird_ibsth1_mini/sensor.py b/esphome/components/inkbird_ibsth1_mini/sensor.py index f6524021c08..03543cba3b3 100644 --- a/esphome/components/inkbird_ibsth1_mini/sensor.py +++ b/esphome/components/inkbird_ibsth1_mini/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_EXTERNAL_TEMPERATURE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -65,9 +64,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 40e7447d669..0278bb93e68 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -3,7 +3,6 @@ from esphome.components import modbus, sensor import esphome.config_validation as cv from esphome.const import ( CONF_EC, - CONF_ID, CONF_PH, CONF_TEMPERATURE, DEVICE_CLASS_EMPTY, @@ -97,9 +96,7 @@ 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 modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PH, var.set_ph_sensor) diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 761e5831c11..8f4147d0188 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_DISTANCE, - CONF_ID, CONF_LEVEL, CONF_MAC_ADDRESS, CONF_TEMPERATURE, @@ -132,9 +131,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/mopeka_std_check/sensor.py b/esphome/components/mopeka_std_check/sensor.py index b3e55766d8a..644684afa67 100644 --- a/esphome/components/mopeka_std_check/sensor.py +++ b/esphome/components/mopeka_std_check/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_DISTANCE, - CONF_ID, CONF_LEVEL, CONF_MAC_ADDRESS, CONF_TEMPERATURE, @@ -103,9 +102,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/pvvx_mithermometer/sensor.py b/esphome/components/pvvx_mithermometer/sensor.py index e4a7781538d..a2add0b88a9 100644 --- a/esphome/components/pvvx_mithermometer/sensor.py +++ b/esphome/components/pvvx_mithermometer/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_SIGNAL_STRENGTH, CONF_TEMPERATURE, @@ -79,9 +78,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index 7788caca442..1202f8a15c2 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -184,9 +184,7 @@ FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device("pzem6l24", role="cl 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) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors("voltage_a", var.set_voltage_a_sensor) diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 90e0abb14c1..9ed0284cff0 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -99,9 +99,7 @@ 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 modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index b3f59720ad3..80008f6b592 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -81,9 +81,7 @@ 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 modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/renogy_inverter_ble/__init__.py b/esphome/components/renogy_inverter_ble/__init__.py index 808f27a67a8..162ba871f9a 100644 --- a/esphome/components/renogy_inverter_ble/__init__.py +++ b/esphome/components/renogy_inverter_ble/__init__.py @@ -1,7 +1,6 @@ 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"] @@ -35,6 +34,4 @@ CONFIG_SCHEMA = ( 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) + await ble_client.new_ble_node_component(config) diff --git a/esphome/components/ruuvitag/sensor.py b/esphome/components/ruuvitag/sensor.py index abbda5872b2..bad973c6315 100644 --- a/esphome/components/ruuvitag/sensor.py +++ b/esphome/components/ruuvitag/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_ACCELERATION_Z, CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_MEASUREMENT_SEQUENCE_NUMBER, CONF_MOVEMENT_COUNTER, @@ -123,9 +122,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 74c56acf120..806b7ee27b5 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_EXPORT_ACTIVE_ENERGY, CONF_EXPORT_REACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, CONF_PHASE_A, @@ -156,9 +155,7 @@ 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 modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 5e6709ec97d..6cf79592eb0 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_EXPORT_ACTIVE_ENERGY, CONF_EXPORT_REACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, CONF_MODEL, @@ -337,9 +336,7 @@ 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 modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) cg.add(var.set_model(config[CONF_MODEL])) cg.add(var.set_word_swap(config[CONF_BYTE_ORDER])) if config[CONF_MODEL] == MODEL_EM4M: diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index ed42312a26b..c050ffd94fe 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_EXTERNAL_TEMPERATURE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_SIGNAL_STRENGTH, CONF_TEMPERATURE, @@ -76,9 +75,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_body_scale/sensor.py b/esphome/components/xiaomi_body_scale/sensor.py index cb0802d270a..bd66e2eb400 100644 --- a/esphome/components/xiaomi_body_scale/sensor.py +++ b/esphome/components/xiaomi_body_scale/sensor.py @@ -3,7 +3,6 @@ from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BINDKEY, - CONF_ID, CONF_MAC_ADDRESS, CONF_WEIGHT, DEVICE_CLASS_WEIGHT, @@ -72,11 +71,9 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable( - config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex, config[CONF_BINDKEY] + var = await ble_device_base.new_ble_device_component( + config, config[CONF_MAC_ADDRESS].as_hex, config[CONF_BINDKEY] ) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) sensors = sensor.sub_sensors(config) await sensors(CONF_WEIGHT, var.set_weight) diff --git a/esphome/components/xiaomi_cgd1/sensor.py b/esphome/components/xiaomi_cgd1/sensor.py index 162ba7016be..5649a1d9746 100644 --- a/esphome/components/xiaomi_cgd1/sensor.py +++ b/esphome/components/xiaomi_cgd1/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgdk2/sensor.py b/esphome/components/xiaomi_cgdk2/sensor.py index e1d44d6bac5..7e96b231371 100644 --- a/esphome/components/xiaomi_cgdk2/sensor.py +++ b/esphome/components/xiaomi_cgdk2/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgg1/sensor.py b/esphome/components/xiaomi_cgg1/sensor.py index 01ca8d1f84d..a81d8d2f4e4 100644 --- a/esphome/components/xiaomi_cgg1/sensor.py +++ b/esphome/components/xiaomi_cgg1/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if CONF_BINDKEY in config: diff --git a/esphome/components/xiaomi_gcls002/sensor.py b/esphome/components/xiaomi_gcls002/sensor.py index 75138970246..f9e44fb94fd 100644 --- a/esphome/components/xiaomi_gcls002/sensor.py +++ b/esphome/components/xiaomi_gcls002/sensor.py @@ -3,7 +3,6 @@ from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -65,9 +64,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy01/sensor.py b/esphome/components/xiaomi_hhccjcy01/sensor.py index 11bbceef5e3..f9d06017f0e 100644 --- a/esphome/components/xiaomi_hhccjcy01/sensor.py +++ b/esphome/components/xiaomi_hhccjcy01/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CONDUCTIVITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -75,9 +74,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy10/sensor.py b/esphome/components/xiaomi_hhccjcy10/sensor.py index c5d256db1d3..76fe80b19c8 100644 --- a/esphome/components/xiaomi_hhccjcy10/sensor.py +++ b/esphome/components/xiaomi_hhccjcy10/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CONDUCTIVITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -75,9 +74,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccpot002/sensor.py b/esphome/components/xiaomi_hhccpot002/sensor.py index 73d5f3ff192..555c6a164ae 100644 --- a/esphome/components/xiaomi_hhccpot002/sensor.py +++ b/esphome/components/xiaomi_hhccpot002/sensor.py @@ -3,7 +3,6 @@ from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_MOISTURE, ICON_FLOWER, @@ -47,9 +46,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_jqjcy01ym/sensor.py b/esphome/components/xiaomi_jqjcy01ym/sensor.py index 4bfe9c8f756..5c4600f5d12 100644 --- a/esphome/components/xiaomi_jqjcy01ym/sensor.py +++ b/esphome/components/xiaomi_jqjcy01ym/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -66,9 +65,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsd02/sensor.py b/esphome/components/xiaomi_lywsd02/sensor.py index d37d7540c76..d29cd7c789a 100644 --- a/esphome/components/xiaomi_lywsd02/sensor.py +++ b/esphome/components/xiaomi_lywsd02/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -57,9 +56,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsd02mmc/sensor.py b/esphome/components/xiaomi_lywsd02mmc/sensor.py index 5b088318ae7..a1a6cef3a4a 100644 --- a/esphome/components/xiaomi_lywsd02mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd02mmc/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsd03mmc/sensor.py b/esphome/components/xiaomi_lywsd03mmc/sensor.py index 32977d0ed6f..a1d7af7590f 100644 --- a/esphome/components/xiaomi_lywsd03mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd03mmc/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -61,9 +60,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsdcgq/sensor.py b/esphome/components/xiaomi_lywsdcgq/sensor.py index 75a8a2d847e..7f98c88df7f 100644 --- a/esphome/components/xiaomi_lywsdcgq/sensor.py +++ b/esphome/components/xiaomi_lywsdcgq/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -57,9 +56,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_mhoc303/sensor.py b/esphome/components/xiaomi_mhoc303/sensor.py index 44fb9c76548..76acc91dfd3 100644 --- a/esphome/components/xiaomi_mhoc303/sensor.py +++ b/esphome/components/xiaomi_mhoc303/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -57,9 +56,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_mhoc401/sensor.py b/esphome/components/xiaomi_mhoc401/sensor.py index e74c656e512..d9f2f3f5cf3 100644 --- a/esphome/components/xiaomi_mhoc401/sensor.py +++ b/esphome/components/xiaomi_mhoc401/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_miscale/sensor.py b/esphome/components/xiaomi_miscale/sensor.py index c6277a515d7..9c3120d064a 100644 --- a/esphome/components/xiaomi_miscale/sensor.py +++ b/esphome/components/xiaomi_miscale/sensor.py @@ -3,7 +3,6 @@ from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR_IMPEDANCE, - CONF_ID, CONF_IMPEDANCE, CONF_MAC_ADDRESS, CONF_WEIGHT, @@ -51,9 +50,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_clear_impedance(config[CONF_CLEAR_IMPEDANCE])) diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index 71d7e0a265b..9cbf9456959 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import ble_device_base import esphome.config_validation as cv -from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS +from esphome.const import CONF_BINDKEY, CONF_MAC_ADDRESS from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] @@ -29,9 +29,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py index 8c47b526b1d..6a57b3699e0 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) From e54d66e62069e2e17a0485e40a7c811fedc80f48 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 18 Oct 2026 14:36:35 -0500 Subject: [PATCH 586/651] [api] Poll the outgoing dial every loop on deep sleep builds (#21077) --- esphome/components/api/api_outgoing_connection.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index 16d50c34255..fff11522d79 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -59,17 +59,20 @@ class OutgoingConnectionManager { static constexpr uint32_t BACKOFF_MIN_MS = 5000; static constexpr uint32_t BACKOFF_MAX_MS = 300000; static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; - static constexpr uint32_t NETWORK_RETRY_MS = 500; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // A deep sleep wake window is too short to spend on the delay, so those - // builds dial out as soon as the target is gone + // builds dial out as soon as the target is gone, recheck the network on + // every loop and notice the connect completing on the loop it does #ifdef USE_DEEP_SLEEP static constexpr uint32_t BOOT_WAIT_MS = 0; static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 0; + static constexpr uint32_t NETWORK_RETRY_MS = 0; #else static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; + static constexpr uint32_t NETWORK_RETRY_MS = 500; #endif void try_dial_(APIServer *server, uint32_t now); From dd456ea0b2d895911a5ae44bed400d58dec9a86e Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Sun, 18 Oct 2026 23:02:31 +0300 Subject: [PATCH 587/651] [homeassistant] Add the light platform (#11354) 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/api/wizard.py | 10 +- .../homeassistant/homeassistant.cpp | 11 + .../components/homeassistant/homeassistant.h | 4 + .../homeassistant/light/__init__.py | 81 +++ .../light/homeassistant_light.cpp | 477 ++++++++++++++++++ .../homeassistant/light/homeassistant_light.h | 70 +++ tests/component_tests/api/test_wizard.py | 10 + .../homeassistant/mixed_entities.yaml | 6 + .../test_state_subscription_count.py | 4 +- tests/components/homeassistant/common.yaml | 6 + .../fixtures/api_homeassistant_light.yaml | 39 ++ .../test_api_homeassistant_light.py | 393 +++++++++++++++ 13 files changed, 1109 insertions(+), 3 deletions(-) create mode 100644 esphome/components/homeassistant/homeassistant.cpp create mode 100644 esphome/components/homeassistant/light/__init__.py create mode 100644 esphome/components/homeassistant/light/homeassistant_light.cpp create mode 100644 esphome/components/homeassistant/light/homeassistant_light.h create mode 100644 tests/integration/fixtures/api_homeassistant_light.yaml create mode 100644 tests/integration/test_api_homeassistant_light.py diff --git a/CODEOWNERS b/CODEOWNERS index 1f1375ba968..61f1f4722cc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -279,6 +279,7 @@ esphome/components/hmac_sha256/* @dwmw2 esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/button/* @jesserockz +esphome/components/homeassistant/light/* @egormanga esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/select/* @jesserockz esphome/components/homeassistant/switch/* @Links2004 diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 8e5c808e24c..200c4f9f120 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -208,6 +208,7 @@ WIZARD_SCHEMA = cv.All( WIZARD_INPUT_DOMAINS = ( "binary_sensor", "button", + "light", "number", "select", "sensor", @@ -216,7 +217,14 @@ WIZARD_INPUT_DOMAINS = ( "text_sensor", ) # Platforms that act on one family of Home Assistant domains, which the input's filters must stay within -WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text") +WIZARD_DOMAIN_LIMITED_PLATFORMS = ( + "button", + "light", + "number", + "select", + "switch", + "text", +) def wizard_input_ids(api_config: ConfigType) -> set[str]: diff --git a/esphome/components/homeassistant/homeassistant.cpp b/esphome/components/homeassistant/homeassistant.cpp new file mode 100644 index 00000000000..5d6a41c677b --- /dev/null +++ b/esphome/components/homeassistant/homeassistant.cpp @@ -0,0 +1,11 @@ +#include "homeassistant.h" + +#include "esphome/core/progmem.h" + +namespace esphome::homeassistant { + +PROGMEM_STRING_TABLE(UnsetValues, "None", "unavailable", "unknown"); + +bool is_unset(StringRef value) { return progmem_string_table_find(value.c_str(), value.size()) >= 0; } + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/homeassistant.h b/esphome/components/homeassistant/homeassistant.h index 65eb6ef5fd2..580c1d89a90 100644 --- a/esphome/components/homeassistant/homeassistant.h +++ b/esphome/components/homeassistant/homeassistant.h @@ -7,6 +7,10 @@ namespace esphome::homeassistant { +/// True for what Home Assistant reports when there is no value: "None" for an attribute that is not +/// set, "unavailable" and "unknown" for the state of an entity that is not ready +bool is_unset(StringRef value); + /// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the item early. template bool emit_utf8(uint32_t code_point, C &on_char) { if (code_point == 0 || code_point > 0x10FFFF) diff --git a/esphome/components/homeassistant/light/__init__.py b/esphome/components/homeassistant/light/__init__.py new file mode 100644 index 00000000000..abb6b7289f7 --- /dev/null +++ b/esphome/components/homeassistant/light/__init__.py @@ -0,0 +1,81 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEFAULT_TRANSITION_LENGTH, + CONF_EFFECTS, + CONF_FLASH_TRANSITION_LENGTH, + CONF_GAMMA_CORRECT, + CONF_INITIAL_STATE, + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, +) +from esphome.core import TimePeriod +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@egormanga"] +DEPENDENCIES = ["api"] +AUTO_LOAD = ["homeassistant"] + +CONF_REPORT_TIMEOUT = "report_timeout" +SUPPORTED_DOMAINS = ["light"] + +HomeassistantLight = homeassistant_ns.class_( + "HomeassistantLight", light.LightOutput, cg.Component +) + +_RENDERED_BY_HOME_ASSISTANT = cv.invalid( + "Home Assistant renders this light and keeps its state; this option has no effect here" +) + +CONFIG_SCHEMA = cv.All( + light.light_schema(HomeassistantLight, light.LightType.RGB) + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend( + { + # A transition would be stepped over the API; the target is sent once instead + cv.Optional( + CONF_DEFAULT_TRANSITION_LENGTH, default="0s" + ): cv.positive_time_period_milliseconds, + # Stored in a byte of seconds; a light polled by Home Assistant can take a minute + cv.Optional(CONF_REPORT_TIMEOUT, default="10s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(min=TimePeriod(seconds=1), max=TimePeriod(seconds=255)), + ), + **{ + cv.Optional(key): _RENDERED_BY_HOME_ASSISTANT + for key in ( + CONF_GAMMA_CORRECT, + CONF_EFFECTS, + CONF_FLASH_TRANSITION_LENGTH, + light.CONF_TRANSITION_STATE_PUBLISH_INTERVAL, + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, + CONF_INITIAL_STATE, + ) + }, + } + ) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("light", SUPPORTED_DOMAINS), +) + +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + var = await light.new_light(config) + await cg.register_component(var, config) + cg.add(var.set_report_timeout(config[CONF_REPORT_TIMEOUT].total_seconds)) + # 1 state + supported_color_modes, min/max_color_temp_kelvin, color_mode, brightness, + # color_temp_kelvin and rgb_color in HomeassistantLight::setup() + setup_home_assistant_entity(var, config, state_subscription_count=8) diff --git a/esphome/components/homeassistant/light/homeassistant_light.cpp b/esphome/components/homeassistant/light/homeassistant_light.cpp new file mode 100644 index 00000000000..6ebe0cadbfb --- /dev/null +++ b/esphome/components/homeassistant/light/homeassistant_light.cpp @@ -0,0 +1,477 @@ +#include "homeassistant_light.h" + +#include +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "../homeassistant.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +ESPHOME_LOG_TAG(TAG, "homeassistant.light"); + +static constexpr uint32_t ECHO_TIMEOUT_ID = 0; + +using namespace esphome::light; + +namespace { + +// Home Assistant's color mode names, in flash on the ESP8266. The names from "hs" on are every +// color mode, all handled as RGB: Home Assistant reports rgb_color for all of them and derives +// the white channels from rgb_color itself. +PROGMEM_STRING_TABLE(ColorModeNames, "onoff", "brightness", "white", "color_temp", "hs", "xy", "rgb", "rgbw", "rgbww"); +const uint8_t COLOR_MODES[] PROGMEM = { + static_cast(ColorMode::ON_OFF), + static_cast(ColorMode::BRIGHTNESS), + static_cast(ColorMode::WHITE), + static_cast(ColorMode::COLOR_TEMPERATURE), +}; + +optional parse_color_mode(StringRef name) { + const int16_t i = progmem_string_table_find(name.c_str(), name.size()); + if (i < 0) { + return nullopt; + } + return i < sizeof(COLOR_MODES) ? static_cast(progmem_read_byte(&COLOR_MODES[i])) : ColorMode::RGB; +} + +/// Reads a Python tuple of three integers such as "(255, 0, 0)"; false for anything else +bool parse_rgb(const char *text, std::array &values) { + for (auto &value : values) { + while (*text == ' ' || *text == ',' || *text == '(') { + text++; + } + if (*text < '0' || *text > '9') { + return false; + } + uint16_t number = 0; + while (*text >= '0' && *text <= '9') { + number = number * 10 + (*text++ - '0'); + if (number > 255) { + return false; + } + } + value = static_cast(number); + } + while (*text == ' ' || *text == ',' || *text == ')') { + text++; + } + return *text == '\0'; +} + +} // namespace + +void HomeassistantLight::setup() { + auto *server = api::global_api_server; + // Home Assistant sends these on subscribe and when they change, so a light that comes up late gets them + server->subscribe_home_assistant_state(this->entity_id_, "supported_color_modes", [this](StringRef value) { + if (is_unset(value)) { + return; // the light is unavailable; its traits stay + } + // A Python list of the mode names, as strings or as the enum members + ColorModeMask modes; + char name[ColorModeNames::MAX_LENGTH + 1]; + size_t length = 0; + const bool valid = parse_python_list( + value, + [&](char c) { + if (length < sizeof(name)) { + name[length] = c; + } + length++; + }, + [&]() { + auto mode = length <= ColorModeNames::MAX_LENGTH ? parse_color_mode(StringRef(name, length)) : nullopt; + if (mode.has_value()) { + modes.insert(*mode); + } else { + ESP_LOGW(TAG, "'%s': Unknown color mode in '%s'", this->entity_id_, value.c_str()); + } + length = 0; + }); + if (!valid) { + ESP_LOGW(TAG, "'%s': Can't parse 'supported_color_modes' value '%s'", this->entity_id_, value.c_str()); + return; + } + if (modes.empty()) { + ESP_LOGW(TAG, "'%s': No color mode found in '%s', keeping the current ones", this->entity_id_, value.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Supported color modes retrieved: %s", this->entity_id_, value.c_str()); + this->traits_.set_supported_color_modes(modes); + // Attributes that arrived before the traits were validated against empty ones; apply them again + this->schedule_apply_(); + }); + // Home Assistant reports the temperature range in Kelvin, the light core works in mireds + server->subscribe_home_assistant_state(this->entity_id_, "min_color_temp_kelvin", [this](StringRef value) { + if (auto kelvin = this->parse_integer_("min_color_temp_kelvin", value, UINT16_MAX)) { + this->traits_.set_max_mireds(1e6f / *kelvin); + this->schedule_apply_(); + } + }); + server->subscribe_home_assistant_state(this->entity_id_, "max_color_temp_kelvin", [this](StringRef value) { + if (auto kelvin = this->parse_integer_("max_color_temp_kelvin", value, UINT16_MAX)) { + this->traits_.set_min_mireds(1e6f / *kelvin); + this->schedule_apply_(); + } + }); + + // An attribute is "None" while the light is off or does not report it; the last value stays + server->subscribe_home_assistant_state(this->entity_id_, nullptr, [this](StringRef value) { + if (is_unset(value)) { + return; // normal while Home Assistant or the light is starting up or offline + } + auto on_off = parse_on_off(value.c_str()); + if (on_off != PARSE_ON && on_off != PARSE_OFF) { + ESP_LOGW(TAG, "'%s': Can't parse '%s' as state!", this->entity_id_, value.c_str()); + return; + } + const bool state = on_off == PARSE_ON; + this->on_report_(this->imported_.state != state); + this->imported_.state = state; + }); + server->subscribe_home_assistant_state(this->entity_id_, "color_mode", [this](StringRef value) { + if (is_unset(value)) { + return; + } + auto mode = parse_color_mode(value); + if (!mode.has_value()) { + ESP_LOGE(TAG, "'%s': Can't parse 'color_mode' value '%s' as color mode!", this->entity_id_, value.c_str()); + return; + } + this->on_report_(this->imported_.color_mode != *mode); + this->imported_.color_mode = *mode; + }); + server->subscribe_home_assistant_state(this->entity_id_, "brightness", [this](StringRef value) { + if (auto brightness = this->parse_integer_("brightness", value, 255)) { + this->on_report_(this->imported_.brightness != *brightness); + this->imported_.brightness = *brightness; + } + }); + server->subscribe_home_assistant_state(this->entity_id_, "color_temp_kelvin", [this](StringRef value) { + if (auto kelvin = this->parse_integer_("color_temp_kelvin", value, UINT16_MAX)) { + this->on_report_(this->imported_.color_temp_kelvin != *kelvin); + this->imported_.color_temp_kelvin = *kelvin; + } + }); + server->subscribe_home_assistant_state(this->entity_id_, "rgb_color", [this](StringRef value) { + if (is_unset(value)) { + return; + } + std::array rgb; + if (!parse_rgb(value.c_str(), rgb)) { + ESP_LOGE(TAG, "'%s': Can't parse 'rgb_color' value '%s'!", this->entity_id_, value.c_str()); + return; + } + this->on_report_(!this->imported_.has_rgb || this->imported_.rgb != rgb); + this->imported_.rgb = rgb; + this->imported_.has_rgb = true; + }); +} + +void HomeassistantLight::dump_config() { + ESP_LOGCONFIG(TAG, + "Homeassistant Light:\n" + " Entity ID: '%s'", + this->entity_id_); +} + +optional HomeassistantLight::parse_integer_(const char *name, StringRef value, uint16_t max) { + if (is_unset(value)) { + return nullopt; + } + auto number = parse_number(value.c_str()); + if (!number.has_value()) { + ESP_LOGE(TAG, "'%s': Can't convert '%s' value '%s' to number!", this->entity_id_, name, value.c_str()); + return nullopt; + } + // Written so that NaN fails too + const bool in_range = *number >= 1.0f && *number <= max; + if (!in_range) { + ESP_LOGW(TAG, "'%s': '%s' value %.0f is out of range!", this->entity_id_, name, *number); + return nullopt; + } + return static_cast(*number); +} + +void HomeassistantLight::on_report_(bool changed) { + // A report that changed an attribute answers the change that armed the echo timeout; a repeated + // value (or a trait) does not, so a refused change still gets its warning + if (changed && this->echo_pending_) { + this->echo_pending_ = false; + this->cancel_timeout(ECHO_TIMEOUT_ID); + } + this->report_changed_ |= changed; + this->schedule_apply_(); +} + +void HomeassistantLight::schedule_apply_() { + // One update changes several attributes, each arriving as its own message; apply them as one call + if (!this->apply_scheduled_) { + this->apply_scheduled_ = true; + this->defer([this]() { this->apply_imported_(); }); + } +} + +void HomeassistantLight::apply_imported_() { + this->apply_scheduled_ = false; + // Nothing to apply before the first state; while a sent change waits for its report the reported + // state is the old one, so a trait that arrives then does not bring it back + if (!this->imported_.state.has_value() || this->echo_pending_) { + return; + } + // Attributes Home Assistant has not reported keep what the light has. An "off" report carries + // no levels, so the ones reported last stay with the light: a turn_on without a brightness + // then sends what Home Assistant last had, not a level that never reached it + const Attributes &imported = this->imported_; + LightColorValues values = this->state_->remote_values; + values.set_state(*imported.state); + if (imported.color_mode != ColorMode::UNKNOWN) { + values.set_color_mode(imported.color_mode); + } + if (imported.brightness != 0) { + values.set_brightness(imported.brightness / 255.0f); + } + // Home Assistant reports rgb_color in every mode and keeps the attributes of the other + // modes, so only the ones of the mode it reports are taken over + const ColorMode mode = values.get_color_mode(); + if (mode == ColorMode::COLOR_TEMPERATURE) { + values.set_color_temperature_kelvin(imported.color_temp_kelvin); // ignores an unreported 0 + } else if (mode == ColorMode::RGB) { + if (imported.has_rgb) { + values.set_red(imported.rgb[0] / 255.0f); + values.set_green(imported.rgb[1] / 255.0f); + values.set_blue(imported.rgb[2] / 255.0f); + } + values.set_color_brightness(1.0f); // brightness is the only level Home Assistant has + } else if (mode == ColorMode::WHITE) { + values.set_white(1.0f); + } + if (!this->state_->set_external_state(values)) { + // A report that changed something during a transition or flash is applied once it ends, in + // place of the values it ends at; a trait or a repeated report has nothing newer to say + if (this->state_->is_transitioning() && this->report_changed_) { + this->report_held_ = true; + this->held_hash_ = state_hash(this->state_->remote_values); + return; + } + // A local change still waits for LightState::loop() to send it. Home Assistant reports again + // once that change reaches it; when it does not, the echo timeout write_state() arms brings the + // light back to what was reported last + ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_); + return; + } + this->report_changed_ = false; + ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_); +} + +uint32_t HomeassistantLight::state_hash(const LightColorValues &values) { + uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS, static_cast(values.get_color_mode())); + for (const float channel : + {values.get_state(), values.get_brightness(), values.get_color_brightness(), values.get_red(), + values.get_green(), values.get_blue(), values.get_white(), values.get_cold_white(), values.get_warm_white()}) { + hash = fnv1a_hash_extend(hash, to_uint8_scale(channel)); + } + return fnv1a_hash_extend(hash, static_cast(values.get_color_temperature())); // mireds exceed a byte +} + +void HomeassistantLight::update_state(LightState *state) { + // The light core's setup performs one call for the initial state; a second one before the first + // write (an on_boot automation) is a local change merged into that write, so it must be sent + if (this->initial_write_) { + if (this->initial_seen_) { + this->initial_write_ = false; + } + this->initial_seen_ = true; + } + // A call without a transition replaces whatever ran, so a report held for it is older than the call + if (!state->is_transitioning()) { + this->report_held_ = false; + } +} + +bool HomeassistantLight::is_imported_(const Attributes &outgoing) const { + const Attributes &imported = this->imported_; + if (!imported.state.has_value() || !outgoing.state.has_value() || *imported.state != *outgoing.state) { + return false; + } + if (!*outgoing.state) { + return true; + } + if (imported.color_mode != outgoing.color_mode || imported.brightness != outgoing.brightness) { + return false; + } + // rgb_color stays reported in the other modes, so only the attributes of the mode count + switch (outgoing.color_mode) { + case ColorMode::COLOR_TEMPERATURE: + return imported.color_temp_kelvin == outgoing.color_temp_kelvin; + case ColorMode::RGB: + return imported.has_rgb && imported.rgb == outgoing.rgb; + default: + return true; + } +} + +void HomeassistantLight::fall_back_() { + // Back to the state Home Assistant reported, which the light still has + if (!this->imported_.state.has_value()) { + ESP_LOGW(TAG, "'%s': Nothing reported yet to fall back to, the light keeps the unsent change", this->entity_id_); + return; + } + this->schedule_apply_(); +} + +void HomeassistantLight::write_state(LightState *state) { + // A reported state is taken over without a write, so every write is a local change, except + // the very first: the light core writes its initial state, which Home Assistant does not need + if (this->initial_write_) { + this->initial_write_ = false; + return; + } + if (state->is_transitioning()) { + // A transition or flash runs here alone; Home Assistant gets the state it ends at. A call that + // replaces the transition a report was held for retargets it and is newer than the report + if (this->report_held_ && state_hash(state->remote_values) != this->held_hash_) { + this->report_held_ = false; + } + this->transitioned_ = true; + return; + } + const bool held = this->report_held_; + const bool transitioned = this->transitioned_; + this->report_held_ = false; + this->transitioned_ = false; + this->report_changed_ = false; // this change is newer than any report so far, held or dropped + // Home Assistant changed meanwhile; its report is newer than what the transition ends at or + // what the flash goes back to + if (held) { + ESP_LOGD(TAG, "'%s': Applying the report that arrived during the transition", this->entity_id_); + this->schedule_apply_(); + return; + } + // What Home Assistant holds after the change. It has one level, so the channel brightness of the + // mode folds into it + const LightColorValues &values = state->current_values; + Attributes outgoing; + outgoing.state = values.is_on(); + if (values.is_on()) { + outgoing.color_mode = values.get_color_mode(); + switch (outgoing.color_mode) { + case ColorMode::ON_OFF: + break; + case ColorMode::WHITE: + outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_white()); + break; + case ColorMode::COLOR_TEMPERATURE: + outgoing.brightness = to_uint8_scale(values.get_brightness()); + if (values.get_color_temperature() > 0.0f) { + // A mired value below the reported range could give more than 65535 K + outgoing.color_temp_kelvin = lroundf(std::min(values.get_color_temperature_kelvin(), 65535.0f)); + } + break; + case ColorMode::RGB: + outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_color_brightness()); + outgoing.rgb = {to_uint8_scale(values.get_red()), to_uint8_scale(values.get_green()), + to_uint8_scale(values.get_blue())}; + outgoing.has_rgb = true; + break; + default: + outgoing.brightness = to_uint8_scale(values.get_brightness()); + break; + } + } + // Home Assistant reports only what changed, so a change that leaves it as it is gets no report: + // nothing to send and no echo to wait for. A change still on its way may alter it first, though + if (!this->echo_pending_ && this->is_imported_(outgoing)) { + if (transitioned) { + // A flash ends where it began, and Home Assistant only ever gets the end + ESP_LOGW(TAG, "'%s': Ended at the state Home Assistant already has, nothing to send (a flash never is)", + this->entity_id_); + } else { + ESP_LOGD(TAG, "'%s': Home Assistant already has this state", this->entity_id_); + } + return; + } + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "'%s': No clients connected to API server, change lost", this->entity_id_); + this->fall_back_(); + return; + } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", state->get_name().c_str()); + this->fall_back_(); + return; + } +#endif + + // In flash on the ESP8266 and encoded from there + static const char SERVICE_TURN_ON[] PROGMEM = "light.turn_on"; + static const char SERVICE_TURN_OFF[] PROGMEM = "light.turn_off"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char BRIGHTNESS_KEY[] PROGMEM = "brightness"; + static const char WHITE_KEY[] PROGMEM = "white"; + static const char COLOR_TEMP_KELVIN_KEY[] PROGMEM = "color_temp_kelvin"; + static const char RGB_COLOR_KEY[] PROGMEM = "rgb_color"; + + // The request refers to these buffers until it is sent below + char level_buf[4]; // up to 255 + char color_temp_buf[6]; // up to 65535 + char color_buf[16]; + + api::HomeassistantActionRequest resp; + resp.data.init(3); + auto &entity_id = resp.data.emplace_back(); + entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); + + auto add_number = [&](ProgmemStringRef key, auto &buf, unsigned number) { + auto &entry = resp.data.emplace_back(); + entry.key = key; + entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, sizeof(buf), 0, "%u", number))); + }; + if (!*outgoing.state) { + resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_OFF); + } else { + resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_ON); + if (outgoing.color_mode != ColorMode::ON_OFF) { + // The level goes under "white" in that mode, which also selects the mode + add_number(outgoing.color_mode == ColorMode::WHITE ? ProgmemStringRef::from_lit(WHITE_KEY) + : ProgmemStringRef::from_lit(BRIGHTNESS_KEY), + level_buf, outgoing.brightness); + } + // Home Assistant converts the color to the light's own channels, which loses the white balance + // of a cold/warm white light, so the color goes only when it or the mode changed (or may have, + // while a change still waits for its report) + const Attributes &imported = this->imported_; + const bool new_color = this->echo_pending_ || outgoing.color_mode != imported.color_mode; + if (outgoing.color_temp_kelvin != 0 && (new_color || outgoing.color_temp_kelvin != imported.color_temp_kelvin)) { + add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, outgoing.color_temp_kelvin); + } + if (outgoing.has_rgb && (new_color || !imported.has_rgb || outgoing.rgb != imported.rgb)) { + // A list, which only a template renders as one + resp.data_template.init(1); + auto &color = resp.data_template.emplace_back(); + color.key = ProgmemStringRef::from_lit(RGB_COLOR_KEY); + color.value = + ProgmemStringRef(StringRef(color_buf, buf_append_printf(color_buf, sizeof(color_buf), 0, "[%u,%u,%u]", + outgoing.rgb[0], outgoing.rgb[1], outgoing.rgb[2]))); + } + } + api::global_api_server->send_homeassistant_action(resp); + // Home Assistant reports the change back in a moment, which cancels this; when it does not, the + // change got lost on the way (no subscriber yet, the entity unavailable, the action refused) + this->echo_pending_ = true; + this->set_timeout(ECHO_TIMEOUT_ID, this->report_timeout_s_ * 1000u, [this]() { + this->echo_pending_ = false; + ESP_LOGW(TAG, "'%s': Home Assistant did not report the change back", this->entity_id_); + this->fall_back_(); + }); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/light/homeassistant_light.h b/esphome/components/homeassistant/light/homeassistant_light.h new file mode 100644 index 00000000000..a7f316a22f8 --- /dev/null +++ b/esphome/components/homeassistant/light/homeassistant_light.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +/// A light in Home Assistant, driven from here: its state and attributes are imported over the +/// native API, and every change made on this side is sent back as a light.turn_on or +/// light.turn_off action. +class HomeassistantLight final : public light::LightOutput, public Component { + public: + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + /// How long a sent change may take to come back as a report before the light returns to the reported state + void set_report_timeout(uint8_t seconds) { this->report_timeout_s_ = seconds; } + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + light::LightTraits get_traits() override { return this->traits_; } + void setup_state(light::LightState *state) override { this->state_ = state; } + void update_state(light::LightState *state) override; + void write_state(light::LightState *state) override; + + protected: + /// What Home Assistant reported last, in its own units. Each attribute arrives as its own + /// message and stays until the next report of it, so an update may change any subset; zero, + /// ColorMode::UNKNOWN and has_rgb say an attribute was never reported. + struct Attributes { + uint16_t color_temp_kelvin{0}; + uint8_t brightness{0}; + std::array rgb{}; + light::ColorMode color_mode{light::ColorMode::UNKNOWN}; + bool has_rgb{false}; + optional state; + }; + + /// Parses a Home Assistant attribute as an integer from 1 to max, logging anything else + optional parse_integer_(const char *name, StringRef value, uint16_t max); + /// A state or attribute report: answers a pending echo when it changed something, and schedules the apply + void on_report_(bool changed); + /// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call + static uint32_t state_hash(const light::LightColorValues &values); + /// Schedules apply_imported_() once for the attributes of one update + void schedule_apply_(); + void apply_imported_(); + /// True when Home Assistant reported exactly what a change would make of it + bool is_imported_(const Attributes &outgoing) const; + /// Returns the light to the reported state after a change that did not reach Home Assistant + void fall_back_(); + + const char *entity_id_{nullptr}; + light::LightState *state_{nullptr}; + light::LightTraits traits_{}; + Attributes imported_{}; + uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at + bool apply_scheduled_ : 1 {false}; + bool initial_write_ : 1 {true}; // the light core's own first write is not a local change + bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state + bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back + bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends + bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent + bool report_changed_ : 1 {false}; // the reports since the last apply changed an attribute + uint8_t report_timeout_s_{10}; +}; + +} // namespace esphome::homeassistant diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index f55a6974e7f..d8ca84720ed 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -303,6 +303,16 @@ def test_entity_id_is_optional_only_for_wizard_inputs( ), errors +def test_light_is_a_wizard_input(tmp_path: Path) -> None: + entities = "light:\n - platform: homeassistant\n id: ha_light\n" + errors = config_errors( + write_config( + tmp_path, ESP32_HEADER, wizard_inputs("- entity: ha_light"), entities + ) + ) + assert errors == [] + + def test_entity_id_is_optional_for_an_input_and_its_domain_is_checked( tmp_path: Path, ) -> None: diff --git a/tests/component_tests/homeassistant/mixed_entities.yaml b/tests/component_tests/homeassistant/mixed_entities.yaml index 8c96e1ad521..da9246f2fea 100644 --- a/tests/component_tests/homeassistant/mixed_entities.yaml +++ b/tests/component_tests/homeassistant/mixed_entities.yaml @@ -63,3 +63,9 @@ button: id: ha_button name: HA Button entity_id: button.restart + +light: + - platform: homeassistant + id: ha_light + name: HA Light + entity_id: light.kitchen diff --git a/tests/component_tests/homeassistant/test_state_subscription_count.py b/tests/component_tests/homeassistant/test_state_subscription_count.py index c3832369117..5e7afed1ade 100644 --- a/tests/component_tests/homeassistant/test_state_subscription_count.py +++ b/tests/component_tests/homeassistant/test_state_subscription_count.py @@ -15,12 +15,12 @@ def test_mixed_entities_state_subscription_count( generate_main(DIR / "mixed_entities.yaml") # sensor (2*1) + binary_sensor (1) + text_sensor (1) + switch (1) - # + select (2) + number (4) + text (4) + button (0) = 15 + # + select (2) + number (4) + text (4) + button (0) + light (8) = 23 define_match = [ d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" ] assert len(define_match) == 1 - assert str(define_match[0].value) == "15" + assert str(define_match[0].value) == "23" assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 078036598a9..f39b3e1b2d2 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -54,6 +54,12 @@ switch: entity_id: switch.my_cool_switch id: my_cool_switch +light: + - platform: homeassistant + entity_id: light.my_cool_light + id: ha_my_cool_light + report_timeout: 30s + binary_sensor: - platform: homeassistant entity_id: binary_sensor.hello_world diff --git a/tests/integration/fixtures/api_homeassistant_light.yaml b/tests/integration/fixtures/api_homeassistant_light.yaml new file mode 100644 index 00000000000..e2cf9e5fbe5 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_light.yaml @@ -0,0 +1,39 @@ +esphome: + name: ha-light + +host: + +api: + actions: + - action: set_force_off + variables: + value: bool + then: + - globals.set: + id: force_off + value: !lambda return value; + +logger: + level: DEBUG + +globals: + - id: force_off + type: bool + initial_value: "false" + +light: + - platform: homeassistant + name: HA Light + id: ha_light + entity_id: light.kitchen + internal: false + report_timeout: 2s + on_turn_off: + - logger.log: "turned off" + on_turn_on: + # An automation reacting to a state reported by Home Assistant with a local change + - if: + condition: + lambda: return id(force_off); + then: + - light.turn_off: ha_light diff --git a/tests/integration/test_api_homeassistant_light.py b/tests/integration/test_api_homeassistant_light.py new file mode 100644 index 00000000000..2cb9b4d6d05 --- /dev/null +++ b/tests/integration/test_api_homeassistant_light.py @@ -0,0 +1,393 @@ +"""Test the homeassistant light platform against a fake Home Assistant.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from aioesphomeapi import ( + APIClient, + EntityState, + HomeassistantServiceCall, + LightColorCapability, + LightState, + UserService, +) +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + +ENTITY = "light.kitchen" +SUBSCRIPTIONS = { + (ENTITY, None), + (ENTITY, "supported_color_modes"), + (ENTITY, "min_color_temp_kelvin"), + (ENTITY, "max_color_temp_kelvin"), + (ENTITY, "color_mode"), + (ENTITY, "brightness"), + (ENTITY, "color_temp_kelvin"), + (ENTITY, "rgb_color"), +} +COLOR_TEMPERATURE = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLOR_TEMPERATURE +) +RGB = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB +) + + +@dataclass +class FakeHomeAssistant: + """The light entity as the device sees it over the API, and what the device sends back.""" + + client: APIClient + key: int + services: list[UserService] + waiter: LineWaiter + calls: asyncio.Queue[HomeassistantServiceCall] + states: asyncio.Queue[LightState] + + async def next_call(self) -> HomeassistantServiceCall: + return await asyncio.wait_for(self.calls.get(), timeout=5.0) + + async def next_state(self, matches: Callable[[LightState], bool]) -> LightState: + async with asyncio.timeout(5.0): + while not matches(state := await self.states.get()): + pass + return state + + def report(self, attribute: str | None, value: str) -> None: + self.client.send_home_assistant_state(ENTITY, attribute, value) + + +@asynccontextmanager +async def light_entity( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + modes: str, +) -> AsyncIterator[FakeHomeAssistant]: + """Boots the device and answers its subscriptions with a light of the given color modes.""" + loop = asyncio.get_running_loop() + waiter = LineWaiter() + subscribed: set[tuple[str, str | None]] = set() + all_subscribed = loop.create_future() + calls: asyncio.Queue[HomeassistantServiceCall] = asyncio.Queue() + states: asyncio.Queue[LightState] = asyncio.Queue() + + def on_state_sub(entity_id: str, attribute: str | None) -> None: + subscribed.add((entity_id, attribute or None)) + if not all_subscribed.done() and subscribed >= SUBSCRIPTIONS: + all_subscribed.set_result(None) + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState): + states.put_nowait(state) + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + key = next(e.key for e in entities if e.name == "HA Light") + client.subscribe_service_calls(calls.put_nowait) + client.subscribe_states(on_state) + client.subscribe_home_assistant_states(on_state_sub) + try: + await asyncio.wait_for(all_subscribed, timeout=5.0) + except TimeoutError: + pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}") + ha = FakeHomeAssistant(client, key, services, waiter, calls, states) + ha.report("supported_color_modes", modes) + ha.report("min_color_temp_kelvin", "2000") + ha.report("max_color_temp_kelvin", "6500") + await waiter.wait_for("Supported color modes retrieved", timeout=5.0) + yield ha + + +@pytest.mark.asyncio +async def test_api_homeassistant_light( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Imported attributes, echo suppression and the actions sent for local changes.""" + async with light_entity( + yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgb']" + ) as ha: + set_force_off = next(s for s in ha.services if s.name == "set_force_off") + # Normal while Home Assistant starts, so no warning + ha.report(None, "unavailable") + ha.report(None, "unknown") + # Rejected, so the color stays unknown and the warnings name the attribute + ha.report("rgb_color", "(127.5, 64.0, 0.0)") + ha.report("rgb_color", "(0, 256, 0)") + ha.report("color_temp_kelvin", "0") + await ha.waiter.wait_for( + "'color_temp_kelvin' value 0 is out of range", timeout=5.0 + ) + assert sum("Can't parse 'rgb_color'" in line for line in ha.waiter.lines) == 2 + assert not any("as state!" in line for line in ha.waiter.lines) + + # A report arrives as several attribute messages and is applied as one change + ha.report(None, "on") + ha.report("color_mode", "rgb") + ha.report("brightness", "128") + ha.report("rgb_color", "(255, 0, 0)") + state = await ha.next_state(lambda s: s.state) + assert state.color_mode == RGB + assert state.brightness == pytest.approx(128 / 255) + assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0) + # A rejected color leaves the reported one untouched + ha.report("rgb_color", "(0, 256, 0)") + ha.report("brightness", "255") + state = await ha.next_state(lambda s: s.brightness == 1.0) + assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0) + + # The attributes of other modes stay with their mode, whatever order they arrive in + ha.report("color_mode", "color_temp") + ha.report("color_temp_kelvin", "4000") + state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE) + assert state.color_temperature == 250.0 + ha.report("rgb_color", "(0, 255, 0)") + ha.report("brightness", "200") + state = await ha.next_state(lambda s: s.brightness == pytest.approx(200 / 255)) + assert state.color_mode == COLOR_TEMPERATURE + + # Local changes go to Home Assistant with its attribute names and scales; the color + # temperature it reported is not repeated + ha.client.light_command(ha.key, brightness=0.5) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert not call.data_template + ha.client.light_command(ha.key, rgb=(0.0, 0.0, 1.0)) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert call.data_template == {"rgb_color": "[0,0,255]"} + + # Home Assistant echoes the result; that is not sent back. Returning to the state it + # reported last is a local change and is sent. + ha.report(None, "off") + ha.report("brightness", "None") + await ha.next_state(lambda s: not s.state) + await ha.waiter.wait_for( + "turned off", timeout=5.0 + ) # a reported state runs the triggers too + ha.client.light_command(ha.key, state=True) + assert (await ha.next_call()).service == "light.turn_on" + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + + # Without a report of the change within report_timeout, the light goes back to the reported state + ha.report(None, "on") + await ha.next_state(lambda s: s.state) + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + await ha.next_state(lambda s: s.state) + await ha.waiter.wait_for("did not report the change back", timeout=5.0) + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # The fallback also drops the levels of the unsent change: the light keeps the brightness + # Home Assistant reported last, which a later turn_on without a brightness would send + ha.client.light_command(ha.key, state=True, brightness=0.4) + call = await ha.next_call() + assert call.data["brightness"] == "102" + state = await ha.next_state(lambda s: not s.state) + assert state.brightness == pytest.approx(200 / 255) + + # An automation changing the light while a report is applied is a local change too + await ha.client.execute_service(set_force_off, {"value": True}) + ha.report(None, "on") + assert (await ha.next_call()).service == "light.turn_off" + await ha.client.execute_service(set_force_off, {"value": False}) + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # A change that leaves Home Assistant as it is gets no report, so none is sent + ha.client.light_command(ha.key, state=False) + await ha.waiter.wait_for("already has this state", timeout=5.0) + + # A trait that arrives while a change waits for its report is not that report, and does + # not bring the old state back early either + ha.client.light_command(ha.key, state=True) + assert (await ha.next_call()).service == "light.turn_on" + await ha.next_state(lambda s: s.state) + ha.report("max_color_temp_kelvin", "6500") + await ha.next_state(lambda s: not s.state) + assert ( + sum("did not report the change back" in line for line in ha.waiter.lines) + == 3 + ) + + # A report that arrives during a flash is applied when the flash ends instead of being undone + ha.client.light_command(ha.key, state=True, flash_length=1.0) + ha.report(None, "on") + ha.report("brightness", "77") + state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255)) + assert state.state + await ha.waiter.wait_for("arrived during the transition", timeout=5.0) + + # A trait or a repeated report during a local transition is nothing new, so the change the + # transition ends at is still sent + ha.client.light_command(ha.key, state=False, transition_length=1.0) + ha.report("max_color_temp_kelvin", "6500") + ha.report(None, "on") + assert (await ha.next_call()).service == "light.turn_off" + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # A newer local command wins over a report held during a transition + ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) # held before the command arrives + ha.client.light_command(ha.key, state=True, brightness=1.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data["brightness"] == "255" + ha.report(None, "on") + ha.report("brightness", "255") + await ha.next_state(lambda s: s.state and s.brightness == 1.0) + + # Also when it changes only the color, at the level the transition was heading for + ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, color_temperature=400.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == { + "entity_id": ENTITY, + "brightness": "128", + "color_temp_kelvin": "2500", + } + ha.report("brightness", "128") + ha.report("color_temp_kelvin", "2500") + await ha.next_state(lambda s: s.color_temperature == 400.0) + + # Or when it is a transition itself, replacing the one the report was held for + ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, color_temperature=250.0, transition_length=1.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == { + "entity_id": ENTITY, + "brightness": "255", + "color_temp_kelvin": "4000", + } + ha.report("brightness", "255") + ha.report("color_temp_kelvin", "4000") + await ha.next_state(lambda s: s.color_temperature == 250.0) + + # Or a transition back to where the one the report was held for started + ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data["brightness"] == "255" + ha.report("brightness", "255") + await ha.next_state(lambda s: s.brightness == 1.0) + + # And when it goes back to the state the transition started from + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.report(None, "on") + ha.report("brightness", "60") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # A flash ends where it began, which Home Assistant has, so nothing is sent and the user is told + ha.client.light_command(ha.key, state=True, flash_length=0.5) + await ha.waiter.wait_for("nothing to send (a flash never is)", timeout=5.0) + + assert ha.calls.empty() + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("api_homeassistant_light") +async def test_api_homeassistant_light_rgbww( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A cold/warm white light keeps its white balance: the color goes only when it changes.""" + async with light_entity( + yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgbww']" + ) as ha: + # Home Assistant reports rgb_color in every color mode, so the light is an RGB one here + ha.report(None, "on") + ha.report("color_mode", "rgbww") + ha.report("brightness", "200") + ha.report("rgb_color", "(255, 180, 120)") + state = await ha.next_state(lambda s: s.state) + assert state.color_mode == RGB + assert state.brightness == pytest.approx(200 / 255) + assert (state.red, state.green, state.blue) == pytest.approx( + (1.0, 180 / 255, 120 / 255) + ) + + # A level change sends the level alone; Home Assistant derived the color it reports from + # the white balance and would derive a different balance from the color sent back + ha.client.light_command(ha.key, brightness=0.5) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert not call.data_template + ha.report("brightness", "128") + await ha.next_state(lambda s: s.brightness == pytest.approx(128 / 255)) + + # A new color goes along + ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) + call = await ha.next_call() + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert call.data_template == {"rgb_color": "[0,255,0]"} + ha.report("rgb_color", "(0, 255, 0)") + await ha.next_state(lambda s: s.green == 1.0 and s.red == 0.0) + + # Switching to color temperature sends it, and the level alone once there + ha.client.light_command(ha.key, color_temperature=250.0) + call = await ha.next_call() + assert call.data == { + "entity_id": ENTITY, + "brightness": "128", + "color_temp_kelvin": "4000", + } + assert not call.data_template + ha.report("color_mode", "color_temp") + ha.report("color_temp_kelvin", "4000") + state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE) + assert state.color_temperature == 250.0 + ha.client.light_command(ha.key, brightness=1.0) + call = await ha.next_call() + assert call.data == {"entity_id": ENTITY, "brightness": "255"} + ha.report("brightness", "255") + await ha.next_state(lambda s: s.brightness == 1.0) + + # Back to the color Home Assistant still reports: the mode changed, so the color is sent + ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) + call = await ha.next_call() + assert call.data == {"entity_id": ENTITY, "brightness": "255"} + assert call.data_template == {"rgb_color": "[0,255,0]"} + ha.report("color_mode", "rgbww") + await ha.next_state(lambda s: s.color_mode == RGB) + + assert ha.calls.empty() From 3e68e0f5e161ed7575b4253e91b8d8e80c600cd2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:40:16 +1100 Subject: [PATCH 588/651] [epaper_spi] Add the UC8151D controller and the Waveshare 2.13" B V3 (#21022) --- .../epaper_spi/epaper_waveshare_bwr.cpp | 9 ++- .../epaper_spi/epaper_waveshare_bwr.h | 8 +-- .../components/epaper_spi/models/uc8151.py | 60 +++++++++++++++++++ .../epaper_spi/models/waveshare_bwr.py | 14 +++-- tests/component_tests/epaper_spi/test_init.py | 4 +- .../epaper_spi/test.esp32-s3-idf.yaml | 22 +++++++ 6 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 esphome/components/epaper_spi/models/uc8151.py diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp index 004597b72bd..eecb1882613 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp @@ -20,7 +20,7 @@ static BwrState color_to_bwr(Color color) { return BwrState::BWR_BLACK; } -// UC8179 3-color display buffer layout: +// 3-color display buffer layout (UC8179 and UC8151D): // - 1 bit per pixel, 8 pixels per byte // - Buffer first half: Black/White plane (1=black, 0=white) // - Buffer second half: Red plane (1=red, 0=white) @@ -79,7 +79,7 @@ bool HOT EPaperWaveshareBWR::transfer_data() { uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; // Phase 1: send Black/White plane (first half) via command 0x10 (DTM1) - // UC8179 DTM1 (0x10): inverted to get 0=black, 1=white + // DTM1 (0x10): inverted to get 0=black, 1=white if (this->current_data_index_ < half_buffer) { if (this->current_data_index_ == 0) { this->command(0x10); // DATA START TRANSMISSION 1 (black channel) @@ -101,7 +101,7 @@ bool HOT EPaperWaveshareBWR::transfer_data() { } // Phase 2: send Red plane (second half) via command 0x13 (DTM2) - // UC8179 DTM2 (0x13): 1=red, 0=white + // DTM2 (0x13): 1=red, 0=white; on the UC8151D this relies on the 0x50 value in uc8151.py if (this->current_data_index_ < buffer_length) { if (this->current_data_index_ == half_buffer) { this->command(0x13); // DATA START TRANSMISSION 2 (red channel) @@ -127,8 +127,7 @@ bool HOT EPaperWaveshareBWR::transfer_data() { } void EPaperWaveshareBWR::power_on() { - this->cmd_data(0x01, {0x07, 0x17, 0x3F, 0x3F}); // POWER SETTING - this->command(0x04); // POWER ON + this->command(0x04); // POWER ON } void EPaperWaveshareBWR::refresh_screen(bool /*partial*/) { diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h index d34f05cbb2b..f5cbb5fde47 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.h +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -5,8 +5,8 @@ namespace esphome::epaper_spi { /** - * Waveshare 3-color e-paper displays (UC8179 controller). - * Supports: 7.5" V2 BWR (EDP_7in5b_V2), 800x480 pixels. + * Waveshare 3-color e-paper displays (UC8179 and UC8151D controllers). + * Supports: 7.5" V2 BWR (EPD_7in5b_V2), 800x480 pixels; 2.13" B V3 (EPD_2in13b_V3), 104x212 pixels. * * Color scheme: Black, White, Red (BWR) * Buffer layout: 1 bit per pixel, separate planes @@ -14,8 +14,8 @@ namespace esphome::epaper_spi { * - Buffer second half: Red plane (1=red, 0=no red) * - Total buffer: width * height / 4 bytes (2 * width * height / 8) * - * The init sequence (INITIALISE state) sends panel configuration only. - * Power-on (0x01 + 0x04) is sent in the POWER_ON state after data transfer; + * The init sequence (INITIALISE state) sends the power and panel configuration. + * Power-on (0x04) is sent in the POWER_ON state after data transfer; * the state machine then busy-waits before triggering REFRESH_SCREEN (0x12). */ class EPaperWaveshareBWR : public EPaperBase { diff --git a/esphome/components/epaper_spi/models/uc8151.py b/esphome/components/epaper_spi/models/uc8151.py new file mode 100644 index 00000000000..49d0d6818b4 --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8151.py @@ -0,0 +1,60 @@ +"""Black/white/red e-paper displays using the UltraChip UC8151D controller. + +Supported models: +- uc8151: the controller on its own; dimensions and pins must be configured +- waveshare-2.13in-bv3: 104x212 pixels (2.13" B V3, EPD_2in13b_V3) + +The busy pin of these displays is low while busy, so configure it with ``inverted: true``. +""" + +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DIMENSIONS +from esphome.types import ConfigType + +from . import EpaperModel + +MAX_SOURCES = 160 +MAX_GATES = 296 + + +class UC8151(EpaperModel): + """EpaperModel class for black/white/red displays using the UC8151D controller.""" + + def __init__(self, name: str, **defaults: Any) -> None: + super().__init__(name, "EPaperWaveshareBWR", **defaults) + + def validate_config(self, config: ConfigType) -> ConfigType: + width, height = self.get_dimensions(config) + if width > MAX_SOURCES or height > MAX_GATES: + raise cv.Invalid( + f"{self.name} supports at most {MAX_SOURCES}x{MAX_GATES} pixels (width x height)", + path=[CONF_DIMENSIONS], + ) + return config + + def get_init_sequence(self, config: dict) -> tuple: + width, height = self.get_dimensions(config) + return ( + # PANEL SETTING (black/white/red mode, LUT from OTP) + (0x00, 0x0F, 0x89), + # RESOLUTION SETTING: sources, then gates + (0x61, width, height >> 8, height & 0xFF), + # VCOM AND DATA INTERVAL SETTING; 0x57, not Waveshare's 0x77, so that 1 in DTM2 is red + (0x50, 0x57), + ) + + +uc8151 = UC8151( + "uc8151", + data_rate="10MHz", + minimum_update_interval="15s", + width_multiple=8, +) + +uc8151.extend( + "waveshare-2.13in-bv3", + width=104, + height=212, +) diff --git a/esphome/components/epaper_spi/models/waveshare_bwr.py b/esphome/components/epaper_spi/models/waveshare_bwr.py index e124ea7083b..18c673fbeee 100644 --- a/esphome/components/epaper_spi/models/waveshare_bwr.py +++ b/esphome/components/epaper_spi/models/waveshare_bwr.py @@ -1,10 +1,10 @@ """Waveshare Black/White/Red e-paper displays using UC8179 controller. Supported models: -- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EDP_7in5b_V2) +- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EPD_7in5b_V2) -These displays use the UC8179 controller. Panel configuration is sent during -the INITIALISE state. Power-on is handled in the POWER_ON state, after data +These displays use the UC8179 controller. Power and panel configuration are sent +during the INITIALISE state. Power-on is handled in the POWER_ON state, after data transfer, so the state machine's built-in busy wait covers the power-on delay. """ @@ -20,8 +20,8 @@ class WaveshareBWR(EpaperModel): def get_init_sequence(self, config): """Generate initialization sequence for UC8179 BWR displays. - Panel configuration only — power-on is handled separately in power_on() - after data transfer, with the state machine busy-waiting before refresh. + Power and panel configuration only — power-on is handled separately in + power_on() after data transfer, with the state machine busy-waiting before refresh. """ width, height = self.get_dimensions(config) return ( @@ -43,10 +43,12 @@ class WaveshareBWR(EpaperModel): (0x60, 0x22), # RESOLUTION GATE SETTING (0x65, 0x00, 0x00, 0x00, 0x00), + # POWER SETTING + (0x01, 0x07, 0x17, 0x3F, 0x3F), ) -# Model: Waveshare 7.5" V2 BWR (EDP_7in5b_V2) — 800x480, UC8179 controller +# Model: Waveshare 7.5" V2 BWR (EPD_7in5b_V2) — 800x480, UC8179 controller WaveshareBWR( "waveshare-7.5in-bv2-bwr", width=800, diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 644a505bf82..5e9cc6d703a 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -143,7 +143,7 @@ def test_all_predefined_models( # Add dimensions if not provided by model if not model.get_default(CONF_WIDTH): - config[CONF_DIMENSIONS] = {CONF_HEIGHT: 240, CONF_WIDTH: 320} + config[CONF_DIMENSIONS] = {CONF_HEIGHT: 120, CONF_WIDTH: 120} # Add init sequence if model doesn't provide one if model.initsequence is None: @@ -199,7 +199,7 @@ def test_individual_models( config[CONF_DC_PIN] = 21 if not model.get_default(CONF_WIDTH): - config[CONF_DIMENSIONS] = {CONF_HEIGHT: 240, CONF_WIDTH: 320} + config[CONF_DIMENSIONS] = {CONF_HEIGHT: 120, CONF_WIDTH: 120} if model.initsequence is None: config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 144c60dd2ff..d0d5f5a6aab 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -285,6 +285,28 @@ 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)); + # Waveshare 2.13" V3 B series 3-color e-paper (104x212, BWR, UC8151D) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-bv3 + 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, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Waveshare 2.66" B 3-color e-paper (152x296, BWR, SSD1680) - platform: epaper_spi spi_id: spi_bus From 218e696654aba4c7a08b5257bd00cf76d9c05282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Sun, 18 Oct 2026 22:40:55 +0200 Subject: [PATCH 589/651] [globals] Add RTC memory support (#13954) Co-authored-by: Copilot <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 Co-authored-by: J. Nick Koston --- esphome/components/globals/__init__.py | 26 ++++- .../components/globals/globals_component.h | 29 ++++- .../config/globals_esp8266_storage_test.yaml | 13 +++ .../globals/config/globals_esp8266_test.yaml | 12 ++ .../globals/config/globals_storage_test.yaml | 17 +++ .../globals/config/globals_test.yaml | 4 + tests/component_tests/globals/test_storage.py | 103 ++++++++++++++++++ tests/components/globals/storage.yaml | 16 +++ tests/components/globals/test.esp32-idf.yaml | 4 +- .../components/globals/test.esp8266-ard.yaml | 4 +- tests/components/globals/test.rp2040-ard.yaml | 3 +- 11 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/globals/config/globals_esp8266_storage_test.yaml create mode 100644 tests/component_tests/globals/config/globals_esp8266_test.yaml create mode 100644 tests/component_tests/globals/config/globals_storage_test.yaml create mode 100644 tests/component_tests/globals/test_storage.py create mode 100644 tests/components/globals/storage.yaml diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index a35e2280455..c699c933ba1 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -1,10 +1,11 @@ import hashlib -from esphome import automation, codegen as cg, config_validation as cv +from esphome import automation, codegen as cg, config_validation as cv, preferences from esphome.const import ( CONF_ID, CONF_INITIAL_VALUE, CONF_RESTORE_VALUE, + CONF_STORAGE, CONF_TYPE, CONF_VALUE, ) @@ -21,6 +22,7 @@ RestoringGlobalsComponent = globals_ns.class_( RestoringGlobalStringComponent = globals_ns.class_( "RestoringGlobalStringComponent", cg.PollingComponent ) +Storage = globals_ns.enum("Storage", is_class=True) CONF_MAX_RESTORE_DATA_LENGTH = "max_restore_data_length" @@ -37,6 +39,9 @@ _NON_RESTORING_SCHEMA = cv.Schema( { **_BASE_SCHEMA, cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, + cv.Optional(CONF_STORAGE): cv.invalid( + f"'{CONF_STORAGE}' needs '{CONF_RESTORE_VALUE}: true'" + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -45,6 +50,9 @@ _RESTORING_SCHEMA = cv.Schema( { **_BASE_SCHEMA, cv.Optional(CONF_RESTORE_VALUE, default=True): cv.boolean, + # No default: when omitted the global keeps the platform's own default backend, + # which on ESP8266 follows restore_from_flash + cv.Optional(CONF_STORAGE): preferences.validate_storage, } ).extend(cv.polling_component_schema("1s")) @@ -68,13 +76,17 @@ async def to_code(config: ConfigType) -> None: # Special casing the strings to their own class with a different save/restore mechanism if str(type_) == "std::string" and restore: - template_args = cg.TemplateArguments( - type_, config.get(CONF_MAX_RESTORE_DATA_LENGTH, 63) + 1 - ) + args = [type_, config.get(CONF_MAX_RESTORE_DATA_LENGTH, 63) + 1] type = RestoringGlobalStringComponent else: - template_args = cg.TemplateArguments(type_) + args = [type_] type = RestoringGlobalsComponent if restore else GlobalsComponent + in_flash = None + if (storage := config.get(CONF_STORAGE)) is not None: + # is_in_flash() also requests the RTC backend when rtc is chosen + in_flash = preferences.is_in_flash(storage) + args.append(Storage.STORAGE_FLASH if in_flash else Storage.STORAGE_RTC) + template_args = cg.TemplateArguments(*args) res_type = type.template(template_args) initial_value = None @@ -91,6 +103,10 @@ async def to_code(config: ConfigType) -> None: value = value.encode() hash_ = int(hashlib.md5(value).hexdigest()[:8], 16) cg.add(glob.set_name_hash(hash_)) + if type is RestoringGlobalStringComponent: + # The ESP8266 adds up every saved value to check that they fit its store; only the + # string's size is known here + preferences.reserve_preferences(1, args[1], in_flash) # A lambda returns the global's own type so `return 42;` reduces to a cast; the value diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index b7acb065695..1f9503c2c3c 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -25,7 +25,11 @@ template class GlobalsComponent final : public Component { T value_{}; }; -template class RestoringGlobalsComponent : public PollingComponent { +// Where a restoring global saves its value; STORAGE_PLATFORM keeps the platform default +enum class Storage : uint8_t { STORAGE_PLATFORM, STORAGE_FLASH, STORAGE_RTC }; + +template +class RestoringGlobalsComponent : public PollingComponent { public: using value_type = T; explicit RestoringGlobalsComponent() : PollingComponent(1000) {} @@ -39,7 +43,12 @@ template class RestoringGlobalsComponent : public PollingComponent { T &value() { return this->value_; } void setup() override { - this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + if constexpr (STORAGE == Storage::STORAGE_PLATFORM) { + this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + } else { + this->rtc_ = + global_preferences->make_preference(1944399030U ^ this->name_hash_, STORAGE == Storage::STORAGE_FLASH); + } this->rtc_.load(&this->value_); memcpy(&this->last_checked_value_, &this->value_, sizeof(T)); } @@ -68,7 +77,8 @@ template class RestoringGlobalsComponent : public PollingComponent { }; // Use with string or subclasses of strings -template class RestoringGlobalStringComponent : public PollingComponent { +template +class RestoringGlobalStringComponent : public PollingComponent { public: using value_type = T; explicit RestoringGlobalStringComponent() : PollingComponent(1000) {} @@ -83,7 +93,12 @@ template class RestoringGlobalStringComponent : public P void setup() override { char temp[SZ]; - this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + if constexpr (STORAGE == Storage::STORAGE_PLATFORM) { + this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + } else { + this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_, + STORAGE == Storage::STORAGE_FLASH); + } bool hasdata = this->rtc_.load(&temp); if (hasdata) { this->value_.assign(temp + 1, static_cast(temp[0])); @@ -130,7 +145,9 @@ template class RestoringGlobalStringComponent : public P }; template T &id(GlobalsComponent *value) { return value->value(); } -template T &id(RestoringGlobalsComponent *value) { return value->value(); } -template T &id(RestoringGlobalStringComponent *value) { return value->value(); } +template T &id(RestoringGlobalsComponent *value) { return value->value(); } +template T &id(RestoringGlobalStringComponent *value) { + return value->value(); +} } // namespace esphome::globals diff --git a/tests/component_tests/globals/config/globals_esp8266_storage_test.yaml b/tests/component_tests/globals/config/globals_esp8266_storage_test.yaml new file mode 100644 index 00000000000..b921899b3c7 --- /dev/null +++ b/tests/component_tests/globals/config/globals_esp8266_storage_test.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +globals: + - id: rtc_string + type: std::string + restore_value: true + storage: rtc + max_restore_data_length: 15 + initial_value: '"boot"' diff --git a/tests/component_tests/globals/config/globals_esp8266_test.yaml b/tests/component_tests/globals/config/globals_esp8266_test.yaml new file mode 100644 index 00000000000..f321ecf84f1 --- /dev/null +++ b/tests/component_tests/globals/config/globals_esp8266_test.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp8266: + board: d1_mini + restore_from_flash: true + +globals: + - id: my_restoring_int + type: int + restore_value: true + initial_value: "8" diff --git a/tests/component_tests/globals/config/globals_storage_test.yaml b/tests/component_tests/globals/config/globals_storage_test.yaml new file mode 100644 index 00000000000..d224408921f --- /dev/null +++ b/tests/component_tests/globals/config/globals_storage_test.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + +globals: + - id: rtc_int + type: int + restore_value: true + storage: rtc + initial_value: "7" + - id: flash_string + type: std::string + restore_value: true + storage: flash + max_restore_data_length: 15 diff --git a/tests/component_tests/globals/config/globals_test.yaml b/tests/component_tests/globals/config/globals_test.yaml index 1d1a9edaa62..a05ea84fd9b 100644 --- a/tests/component_tests/globals/config/globals_test.yaml +++ b/tests/component_tests/globals/config/globals_test.yaml @@ -14,3 +14,7 @@ globals: - id: my_global_bool type: bool initial_value: "true" + - id: my_restoring_int + type: int + restore_value: true + initial_value: "8" diff --git a/tests/component_tests/globals/test_storage.py b/tests/component_tests/globals/test_storage.py new file mode 100644 index 00000000000..6f1ea3aff48 --- /dev/null +++ b/tests/component_tests/globals/test_storage.py @@ -0,0 +1,103 @@ +"""Tests for the globals storage option.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components import globals as globals_component +from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32C2 +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + preference_words, +) +from esphome.const import ( + CONF_ID, + CONF_RESTORE_VALUE, + CONF_STORAGE, + CONF_TYPE, + PlatformFramework, +) +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(**extra: object) -> ConfigType: + return globals_component.CONFIG_SCHEMA( + {CONF_ID: "my_global", CONF_TYPE: "int", **extra} + ) + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +@pytest.mark.parametrize( + ("platform_framework", "platform_data"), + [ + (PlatformFramework.ESP32_IDF, {KEY_VARIANT: VARIANT_ESP32C2}), + (PlatformFramework.RP2040_ARDUINO, None), + ], +) +def test_rtc_rejected_without_rtc_memory( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + platform_data: ConfigType | None, +) -> None: + set_core_config(platform_framework, platform_data=platform_data) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate(**{CONF_RESTORE_VALUE: True, CONF_STORAGE: "rtc"}) + + +@pytest.mark.parametrize("extra", [{}, {CONF_RESTORE_VALUE: False}]) +def test_storage_rejected_without_restore_value( + set_core_config: SetCoreConfigCallable, extra: ConfigType +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + with pytest.raises(cv.Invalid, match="'storage' needs 'restore_value: true'"): + _validate(**extra, **{CONF_STORAGE: "rtc"}) + + +def test_storage_generates_template_argument( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("globals_storage_test.yaml")) + assert ( + "globals::RestoringGlobalsComponent" + in main_cpp + ) + assert ( + "globals::RestoringGlobalStringComponent" in main_cpp + ) + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +# On the ESP8266 the omitted form must stay the two-argument call so restore_from_flash keeps deciding +@pytest.mark.parametrize("config", ["globals_test.yaml", "globals_esp8266_test.yaml"]) +def test_omitted_storage_keeps_platform_default( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config: str, +) -> None: + main_cpp = generate_main(component_config_path(config)) + assert ( + "new(my_restoring_int) globals::RestoringGlobalsComponent(8)" in main_cpp + ) + assert "globals::Storage" not in main_cpp + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_restored_string_reserves_esp8266_store( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("globals_esp8266_storage_test.yaml")) + # 15 characters plus the length byte, in RTC memory + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(preference_words(16), False)] diff --git a/tests/components/globals/storage.yaml b/tests/components/globals/storage.yaml new file mode 100644 index 00000000000..40ca7f3e089 --- /dev/null +++ b/tests/components/globals/storage.yaml @@ -0,0 +1,16 @@ +globals: + - id: glob_rtc_int + type: int + restore_value: true + storage: rtc + initial_value: "0" + - id: glob_rtc_string + type: std::string + restore_value: true + storage: rtc + max_restore_data_length: 15 + - id: glob_flash_float + type: float + restore_value: true + storage: flash + initial_value: "0.0f" diff --git a/tests/components/globals/test.esp32-idf.yaml b/tests/components/globals/test.esp32-idf.yaml index dade44d145b..ae95448a794 100644 --- a/tests/components/globals/test.esp32-idf.yaml +++ b/tests/components/globals/test.esp32-idf.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + globals: !include common.yaml + storage: !include storage.yaml diff --git a/tests/components/globals/test.esp8266-ard.yaml b/tests/components/globals/test.esp8266-ard.yaml index dade44d145b..ae95448a794 100644 --- a/tests/components/globals/test.esp8266-ard.yaml +++ b/tests/components/globals/test.esp8266-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + globals: !include common.yaml + storage: !include storage.yaml diff --git a/tests/components/globals/test.rp2040-ard.yaml b/tests/components/globals/test.rp2040-ard.yaml index dade44d145b..d27f5d02d97 100644 --- a/tests/components/globals/test.rp2040-ard.yaml +++ b/tests/components/globals/test.rp2040-ard.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + globals: !include common.yaml From 603f16623f8aafb750ab0d9500dd22ffbf1158f5 Mon Sep 17 00:00:00 2001 From: ab-tools Date: Sun, 18 Oct 2026 22:44:05 +0200 Subject: [PATCH 590/651] [usb_host] Size the FIFO split on ESP32-S2/S3 from periodic packet requests (#21081) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/usb_host/__init__.py | 92 +++++++++- .../usb_host/usb_host_component.cpp | 6 + esphome/core/defines.h | 4 + tests/component_tests/usb_host/__init__.py | 0 tests/component_tests/usb_host/test_fifo.py | 172 ++++++++++++++++++ 5 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/usb_host/__init__.py create mode 100644 tests/component_tests/usb_host/test_fifo.py diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 317cec8236b..1f12112eea7 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -11,11 +11,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, + get_esp32_variant, idf_version, only_on_variant, ) import esphome.config_validation as cv -from esphome.const import CONF_DEVICES, CONF_ID +from esphome.const import CONF_DEVICES, CONF_FRAMEWORK, CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.cpp_types import Component @@ -117,12 +118,101 @@ def _final_validate(config: ConfigType) -> ConfigType: if redundant else "narrow its filter so it no longer matches that device", ) + _reconcile_fifo() return config FINAL_VALIDATE_SCHEMA = _final_validate +# S2/S3 full-speed controller: 200 usable 4-byte FIFO lines (EPINFO takes the rest of 256) shared by RX, +# non-periodic TX and periodic TX +FIFO_VARIANTS = (VARIANT_ESP32S2, VARIANT_ESP32S3) +FIFO_LINES = 200 +FIFO_LINE_BYTES = 4 +FIFO_RX_STATUS_LINES = 2 +# (RX, NPTX, PTX) lines ESP-IDF picks for each CONFIG_USB_HOST_HW_BUFFER_BIAS_* option +FIFO_BIAS_SPLITS = { + "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN": (152, 16, 32), + "CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT": (34, 16, 150), +} +FIFO_BALANCED_SPLIT = (104, 64, 32) +# Custom split: 64-byte control/bulk, at least 128 bytes each way +FIFO_NPTX_LINES = 16 +FIFO_PTX_MIN_LINES = 32 +FIFO_RX_MIN_LINES = 32 + FIFO_RX_STATUS_LINES +# usb_host_config_t::fifo_settings_custom; USB host is not reliable on older releases +FIFO_CUSTOM_MIN_IDF = cv.Version(5, 5, 0) +KEY_PERIODIC_FIFO = "periodic_fifo" + + +def request_periodic_fifo(in_bytes: int = 0, out_bytes: int = 0) -> None: + """Request FIFO space for the largest periodic IN/OUT packet. Call from CONFIG_SCHEMA validation.""" + requested = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_PERIODIC_FIFO, [0, 0]) + requested[0] = max(requested[0], in_bytes) + requested[1] = max(requested[1], out_bytes) + + +def _fifo_lines(size: int) -> int: + return -(-size // FIFO_LINE_BYTES) + + +def _bias_option() -> str | None: + framework = fv.full_config.get().get("esp32", {}).get(CONF_FRAMEWORK, {}) + options = framework.get("sdkconfig_options", {}) + return next((o for o in FIFO_BIAS_SPLITS if options.get(o) in ("y", True)), None) + + +def fifo_split() -> tuple[int, int, int] | None: + """(RX, NPTX, PTX) lines for the requests, or None when the default split fits them.""" + requested = CORE.data.get(DOMAIN, {}).get(KEY_PERIODIC_FIFO) + if requested is None or get_esp32_variant() not in FIFO_VARIANTS: + return None + in_bytes, out_bytes = requested + rx = _fifo_lines(in_bytes) + FIFO_RX_STATUS_LINES + ptx = _fifo_lines(out_bytes) + bias = _bias_option() + default_rx, _, default_ptx = FIFO_BIAS_SPLITS[bias] if bias else FIFO_BALANCED_SPLIT + if rx <= default_rx and ptx <= default_ptx: + return None + if bias: + raise cv.Invalid( + f"The requested USB packets don't fit the FIFO split {bias} selects; remove it from " + "sdkconfig_options so usb_host can size the FIFO" + ) + rx = max(FIFO_RX_MIN_LINES, rx) + ptx = max(FIFO_PTX_MIN_LINES, ptx) + spare = FIFO_LINES - FIFO_NPTX_LINES - rx - ptx + if spare < 0: + raise cv.Invalid( + f"The requested USB packets need {(rx + ptx + FIFO_NPTX_LINES) * FIFO_LINE_BYTES} bytes of USB FIFO, " + f"but the {get_esp32_variant()} has {FIFO_LINES * FIFO_LINE_BYTES} usable" + ) + # Devices may declare a larger max packet size, so the requested directions share the rest + if in_bytes and out_bytes: + rx += spare // 2 + ptx += spare - spare // 2 + elif out_bytes: + ptx += spare + else: + rx += spare + return rx, FIFO_NPTX_LINES, ptx + + +def _reconcile_fifo() -> None: + if (split := fifo_split()) is None: + return + if idf_version() < FIFO_CUSTOM_MIN_IDF: + raise cv.Invalid( + f"Sizing the USB FIFO on the {get_esp32_variant()} needs ESP-IDF {FIFO_CUSTOM_MIN_IDF} or later" + ) + rx, nptx, ptx = split + cg.add_define("USE_USB_HOST_FIFO_CONFIG") + cg.add_define("USB_HOST_FIFO_RX_LINES", rx) + cg.add_define("USB_HOST_FIFO_NPTX_LINES", nptx) + cg.add_define("USB_HOST_FIFO_PTX_LINES", ptx) + + def _set_max_packet_size(config: dict) -> dict: CORE.data.setdefault(DOMAIN, {})[CONF_MAX_PACKET_SIZE] = config[ CONF_MAX_PACKET_SIZE diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 102311348aa..c2a87eeab70 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -9,6 +9,12 @@ namespace esphome::usb_host { void USBHost::setup() { usb_host_config_t config{}; +#ifdef USE_USB_HOST_FIFO_CONFIG + // Sized in Python from component requests + config.fifo_settings_custom.rx_fifo_lines = USB_HOST_FIFO_RX_LINES; + config.fifo_settings_custom.nptx_fifo_lines = USB_HOST_FIFO_NPTX_LINES; + config.fifo_settings_custom.ptx_fifo_lines = USB_HOST_FIFO_PTX_LINES; +#endif if (usb_host_install(&config) != ESP_OK) { this->status_set_error(LOG_STR("usb_host_install failed")); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ed8f7b984a0..931ccf3c789 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -542,6 +542,10 @@ #define USE_WIFI_RUNTIME_ROAMING_SUPPRESSION #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 +#define USE_USB_HOST_FIFO_CONFIG +#define USB_HOST_FIFO_RX_LINES 93 +#define USB_HOST_FIFO_NPTX_LINES 16 +#define USB_HOST_FIFO_PTX_LINES 91 #define USB_UART_OUTPUT_CHUNK_COUNT 5 #define USE_USB_UART_CH34X #define USE_USB_UART_CP210X diff --git a/tests/component_tests/usb_host/__init__.py b/tests/component_tests/usb_host/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/usb_host/test_fifo.py b/tests/component_tests/usb_host/test_fifo.py new file mode 100644 index 00000000000..8bc12a3a183 --- /dev/null +++ b/tests/component_tests/usb_host/test_fifo.py @@ -0,0 +1,172 @@ +"""Tests for the usb_host FIFO split sized from periodic packet requests.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import usb_host +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +from esphome.config import Config +from esphome.const import ( + CONF_FRAMEWORK, + KEY_IDF_VERSION, + KEY_VARIANT, + PlatformFramework, +) +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +IDF_5_5 = cv.Version(5, 5, 6) + + +def _set_variant( + set_core_config: SetCoreConfigCallable, variant: str, idf: cv.Version = IDF_5_5 +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant, KEY_IDF_VERSION: idf}, + ) + + +def test_no_request_keeps_the_default_split( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + assert usb_host.fifo_split() is None + + +def test_in_and_out_requests_share_the_spare_lines( + set_core_config: SetCoreConfigCallable, +) -> None: + # 196-byte packets each way, such as 48 kHz 16-bit stereo audio: 49 + 51 lines, plus 84 spare. + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=196, out_bytes=196) + rx, nptx, ptx = usb_host.fifo_split() + assert (rx, nptx, ptx) == (93, 16, 91) + assert rx + nptx + ptx == 200 + assert ptx * 4 >= 196 + assert (rx - 2) * 4 >= 196 + + +def test_out_request_gets_the_rest(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S2) + usb_host.request_periodic_fifo(out_bytes=196) + assert usb_host.fifo_split() == (34, 16, 150) + + +def test_in_request_gets_the_rest(set_core_config: SetCoreConfigCallable) -> None: + # 512 bytes is more than the default 408 bytes of RX. + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=512) + assert usb_host.fifo_split() == (152, 16, 32) + + +@pytest.mark.parametrize( + ("in_bytes", "out_bytes"), [(0, 0), (196, 0), (408, 128), (0, 128)] +) +def test_requests_that_fit_the_default_keep_it( + set_core_config: SetCoreConfigCallable, in_bytes: int, out_bytes: int +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=in_bytes, out_bytes=out_bytes) + assert usb_host.fifo_split() is None + + +def test_requests_that_fit_the_default_need_no_newer_idf( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3, idf=cv.Version(5, 4, 2)) + usb_host.request_periodic_fifo(in_bytes=34, out_bytes=128) + usb_host.FINAL_VALIDATE_SCHEMA({}) + assert "USE_USB_HOST_FIFO_CONFIG" not in _defines() + + +def test_requests_keep_the_largest_packet( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(out_bytes=400) + usb_host.request_periodic_fifo(in_bytes=96, out_bytes=196) + assert CORE.data[usb_host.DOMAIN][usb_host.KEY_PERIODIC_FIFO] == [96, 400] + + +def test_only_full_speed_variants_are_sized( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32P4) + usb_host.request_periodic_fifo(out_bytes=196) + assert usb_host.fifo_split() is None + + +def test_requests_that_do_not_fit_are_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=776, out_bytes=776) + with pytest.raises(cv.Invalid, match="bytes of USB FIFO"): + usb_host.fifo_split() + + +def _defines() -> dict[str, str]: + return {define.name: str(define.value) for define in CORE.defines} + + +def test_final_validate_emits_the_split( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(out_bytes=196) + usb_host.FINAL_VALIDATE_SCHEMA({}) + defines = _defines() + assert "USE_USB_HOST_FIFO_CONFIG" in defines + assert ( + defines["USB_HOST_FIFO_RX_LINES"], + defines["USB_HOST_FIFO_NPTX_LINES"], + defines["USB_HOST_FIFO_PTX_LINES"], + ) == ("34", "16", "150") + + +def _set_bias(set_core_config: SetCoreConfigCallable, option: str) -> None: + full = Config() + full["esp32"] = {CONF_FRAMEWORK: {"sdkconfig_options": {option: "y"}}} + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3, KEY_IDF_VERSION: IDF_5_5}, + full_config=full, + ) + + +def test_request_that_fits_the_selected_bias_keeps_it( + set_core_config: SetCoreConfigCallable, +) -> None: + # The IN bias leaves RX 152 lines, enough for 512-byte IN packets. + _set_bias(set_core_config, "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN") + usb_host.request_periodic_fifo(in_bytes=512) + assert usb_host.fifo_split() is None + + +@pytest.mark.parametrize( + ("option", "in_bytes", "out_bytes"), + [ + # The IN bias reserves 152 RX lines, leaving no room for more than 128-byte OUT packets. + ("CONFIG_USB_HOST_HW_BUFFER_BIAS_IN", 0, 196), + # The periodic OUT bias reserves 150 PTX lines, leaving no room for more than 128-byte IN packets. + ("CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT", 196, 0), + ], +) +def test_request_conflicting_with_the_selected_bias_is_rejected( + set_core_config: SetCoreConfigCallable, option: str, in_bytes: int, out_bytes: int +) -> None: + _set_bias(set_core_config, option) + usb_host.request_periodic_fifo(in_bytes=in_bytes, out_bytes=out_bytes) + with pytest.raises(cv.Invalid, match=f"FIFO split {option} selects"): + usb_host.fifo_split() + + +@pytest.mark.parametrize("idf", [cv.Version(5, 3, 4), cv.Version(5, 4, 3)]) +def test_final_validate_needs_idf_5_5( + set_core_config: SetCoreConfigCallable, idf: cv.Version +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3, idf=idf) + usb_host.request_periodic_fifo(out_bytes=196) + with pytest.raises(cv.Invalid, match="needs ESP-IDF 5.5.0 or later"): + usb_host.FINAL_VALIDATE_SCHEMA({}) From 512e43793b28b0e8276dcc844cb3c344e6b85e05 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 18 Oct 2026 15:47:02 -0500 Subject: [PATCH 591/651] [api] Poll the outgoing dial every loop (#21085) Co-authored-by: J. Nick Koston --- esphome/components/api/api_outgoing_connection.cpp | 10 +--------- esphome/components/api/api_outgoing_connection.h | 8 +------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index b38976e8cd5..491a9c27a4f 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -152,10 +152,7 @@ void OutgoingConnectionManager::loop(APIServer *server) { void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { if (!network::is_connected()) { - // Flips within seconds of boot; recheck fast so a deep sleep wake - // window is not spent waiting - this->schedule_wait_(now, NETWORK_RETRY_MS); - return; + return; // still WAITING with the wait elapsed, so the next loop rechecks } struct sockaddr_storage addr; socklen_t addr_len = this->target_sockaddr_(&addr); @@ -197,7 +194,6 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { } this->state_ = DialState::DIAL_STATE_CONNECTING; this->state_ts_ = now; - this->last_poll_ = now; } void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { @@ -206,10 +202,6 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { this->schedule_retry_(now); return; } - if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) { - return; - } - this->last_poll_ = now; int err = 0; switch (socket::poll_connect(*this->dial_socket_, err)) { case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index fff11522d79..fa222e29ccf 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -61,18 +61,13 @@ class OutgoingConnectionManager { static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // A deep sleep wake window is too short to spend on the delay, so those - // builds dial out as soon as the target is gone, recheck the network on - // every loop and notice the connect completing on the loop it does + // builds dial out as soon as the target is gone #ifdef USE_DEEP_SLEEP static constexpr uint32_t BOOT_WAIT_MS = 0; static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 0; - static constexpr uint32_t NETWORK_RETRY_MS = 0; #else static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; - static constexpr uint32_t NETWORK_RETRY_MS = 500; #endif void try_dial_(APIServer *server, uint32_t now); @@ -107,7 +102,6 @@ class OutgoingConnectionManager { uint32_t backoff_{BACKOFF_MIN_MS}; uint32_t wait_{BOOT_WAIT_MS}; uint32_t state_ts_{0}; - uint32_t last_poll_{0}; // Byte-aligned types last #ifndef API_OUTGOING_CONNECTION_HOST From dff1422e3697cc558c76af1d11fc865d1e18c21c Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 13 Oct 2026 17:55:22 -0500 Subject: [PATCH 592/651] [mdns] Advertise the connected interface in the network TXT record (#20673) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/mdns/mdns_component.cpp | 53 ++++++++++++++++++- esphome/components/mdns/mdns_component.h | 22 ++++++++ esphome/components/mdns/mdns_esp32.cpp | 19 ++++++- esphome/components/mdns/mdns_rp2.cpp | 19 +++++++ .../mdns/common-dual-interface.yaml | 9 ++++ .../mdns/test-dual-interface.esp32-idf.yaml | 17 ++++++ .../mdns/test-dual-interface.rp2040-ard.yaml | 17 ++++++ 7 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/components/mdns/common-dual-interface.yaml create mode 100644 tests/components/mdns/test-dual-interface.esp32-idf.yaml create mode 100644 tests/components/mdns/test-dual-interface.rp2040-ard.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4f236153c2c..a5e1efeb08a 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -20,6 +20,10 @@ #ifdef USE_DASHBOARD_IMPORT #include "esphome/components/dashboard_import/dashboard_import.h" #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +#include "esphome/components/ethernet/ethernet_component.h" +#include "esphome/components/wifi/wifi_component.h" +#endif namespace esphome::mdns { @@ -39,6 +43,45 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); +MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet"); + +// Same order as the default route in NetworkComponent::loop() +const char *MDNSComponent::connected_network_() const { + const bool ethernet = ethernet::global_eth_component->is_connected(); + const bool wifi = wifi::global_wifi_component->is_connected(); +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + if (wifi) + return NETWORK_WIFI; +#endif + if (ethernet) + return NETWORK_ETHERNET; + if (wifi) + return NETWORK_WIFI; + return nullptr; +} + +void MDNSComponent::update_network_txt_() { + if (this->network_txt_ == nullptr) + return; + // With no interface connected, keep the last value instead of flapping to wifi + const char *network = this->connected_network_(); + if (network == nullptr || network == this->network_txt_) + return; + if (!this->publish_network_txt_(network)) { + // The _esphomelib service is missing, so a retry would fail too + this->network_txt_ = nullptr; + return; + } + ESP_LOGD(TAG, "Network TXT: %s", network); + this->network_txt_ = network; +#ifdef USE_MDNS_STORE_SERVICES + this->network_record_->value = MDNS_STR(network); +#endif +} +#endif + void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { #ifdef USE_MDNS_STORE_SERVICES auto &services = this->services_; @@ -160,7 +203,15 @@ void MDNSComponent::compile_records_(StaticVectorconnected_network_(); + this->network_txt_ = network != nullptr ? network : NETWORK_WIFI; + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(this->network_txt_)}); +#ifdef USE_MDNS_STORE_SERVICES + // services is services_ here, which never reallocates + this->network_record_ = &txt_records.back(); +#endif +#elif defined(USE_WIFI) MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); #elif defined(USE_ETHERNET) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 93d9eb1e385..f2ea7c19149 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,6 +32,12 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif +// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface. +// OpenThread hands its services to the SRP client once, so it keeps the fixed value. +#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD) +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -150,6 +156,22 @@ class MDNSComponent final : public Component void setup_buffers_and_register_(PlatformRegisterFn platform_register); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; + /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. + const char *connected_network_() const; + /// Check the connected interface and publish a changed "network" TXT value. + void update_network_txt_(); + /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. + bool publish_network_txt_(const char *network); + /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. + const char *network_txt_{nullptr}; +#ifdef USE_MDNS_STORE_SERVICES + /// The "network" record in services_, kept in step with network_txt_. + MDNSTXTRecord *network_record_{nullptr}; +#endif +#endif + #ifdef USE_MDNS_DYNAMIC_TXT /// Storage for runtime-generated TXT values from user lambdas /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 48df61326e2..bd74dd6a948 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -89,7 +89,24 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr } #endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD -void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +bool MDNSComponent::publish_network_txt_(const char *network) { + // ESP-IDF copies the value and announces the changed record + esp_err_t err = mdns_service_txt_item_set("_esphomelib", "_tcp", "network", network); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to update network TXT: %s", esp_err_to_name(err)); + return false; + } + return true; +} +#endif + +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_esp32); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif +} void MDNSComponent::on_shutdown() { #ifndef USE_OPENTHREAD diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 7eaac594fb5..603f3f0417c 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -46,7 +46,26 @@ void MDNSComponent::start_polling_window_() { } #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +ESPHOME_LOG_TAG(TAG, "mdns"); + +bool MDNSComponent::publish_network_txt_(const char *network) { + // LEAmDNS replaces the value of an existing key; announce() sends the change + if (!MDNS.addServiceTxt("esphomelib", "tcp", "network", network)) { + ESP_LOGW(TAG, "Failed to update network TXT"); + return false; + } + MDNS.announce(); + this->start_polling_window_(); + return true; +} +#endif + void MDNSComponent::setup() { +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + // No-op until the first IP-up event registers the services + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our // own IP state listener. Both WiFi and Ethernet have the same listener signature — diff --git a/tests/components/mdns/common-dual-interface.yaml b/tests/components/mdns/common-dual-interface.yaml new file mode 100644 index 00000000000..97ae545f2bc --- /dev/null +++ b/tests/components/mdns/common-dual-interface.yaml @@ -0,0 +1,9 @@ +# wifi and ethernet together: the _esphomelib "network" TXT value follows +# the connected interface, so the update path is compiled. +wifi: + ssid: MySSID + password: password1 + +api: + +mdns: diff --git a/tests/components/mdns/test-dual-interface.esp32-idf.yaml b/tests/components/mdns/test-dual-interface.esp32-idf.yaml new file mode 100644 index 00000000000..d480824494b --- /dev/null +++ b/tests/components/mdns/test-dual-interface.esp32-idf.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/mdns/test-dual-interface.rp2040-ard.yaml b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml new file mode 100644 index 00000000000..3836afb7eef --- /dev/null +++ b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + +# wifi first, so the USE_NETWORK_PRIMARY_INTERFACE_WIFI order is compiled +network: + priority: + - wifi + - ethernet From 4645d34e1a85538db5de7bc75dd6df66749fb248 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Wed, 14 Oct 2026 08:19:06 +0000 Subject: [PATCH 593/651] [web_server_idf] Keep active SSE sessions out of the LRU purge (#17383) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../web_server_idf/web_server_idf.cpp | 34 +++++++++++++++++-- .../web_server_idf/web_server_idf.h | 12 +++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index a35ad652141..d120e826726 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -955,10 +955,39 @@ void AsyncEventSourceResponse::process_close_() { } } +void AsyncEventSourceResponse::refresh_lru_() { + // httpd only advances a session's LRU counter when a request arrives, and an event stream receives one + // request ever, so the purge that frees a socket for a new client would evict the live streams first. + // The HTTPD task owns the session table, so the refresh runs there. + // A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh. + if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0) + return; + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS || + this->lru_work_queued_.load(std::memory_order_acquire)) { + return; + } + this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval + this->lru_work_queued_.store(true, std::memory_order_release); + if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) { + this->lru_work_queued_.store(false, std::memory_order_release); + } +} + +void AsyncEventSourceResponse::refresh_lru_work(void *arg) { + auto *response = static_cast(arg); + const int fd = response->fd_.load(); + if (response->owns_session_(fd)) { + httpd_sess_update_lru_counter(response->hd_, fd); + } + // Release self only after the HTTPD-task callback has finished every access. + response->lru_work_queued_.store(false, std::memory_order_release); +} + void AsyncEventSourceResponse::close_session_work(void *arg) { auto *response = static_cast(arg); const int fd = response->fd_.load(); - if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { + if (response->owns_session_(fd)) { // The HTTPD task remains the session owner. Shutting the socket down makes // its next select/recv path delete the session and invoke destroy(). shutdown(fd, SHUT_RDWR); @@ -972,7 +1001,7 @@ ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) { // httpd frees a session before closing its socket, so the fd may already be a new client's. // Treated as would-block; the stall timer ends a session that never becomes ours again. const int fd = this->fd_.load(); - if (httpd_sess_get_ctx(this->hd_, fd) != this) { + if (!this->owns_session_(fd)) { return 0; } struct msghdr msg {}; @@ -1081,6 +1110,7 @@ void AsyncEventSourceResponse::loop() { this->process_close_(); return; } + this->refresh_lru_(); drain_tail_(); process_deferred_queue_(); if (this->close_requested_) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 581ba8323b5..45128602e3a 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -329,10 +329,15 @@ class AsyncEventSourceResponse { }; void process_close_(); static void close_session_work(void *arg); + void refresh_lru_(); + static void refresh_lru_work(void *arg); + // httpd frees a session before closing its socket, so the fd may already belong to a new client + bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; } - // Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. + // Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object. bool safe_to_delete_() const { - return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); + return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) && + !this->lru_work_queued_.load(std::memory_order_acquire); } static void destroy(void *p); @@ -347,11 +352,13 @@ class AsyncEventSourceResponse { RAMUniquePtr tail_; uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. uint32_t next_close_attempt_ms_{0}; + uint32_t last_lru_refresh_ms_{0}; uint16_t tail_cap_{0}; uint16_t tail_len_{0}; // Zero means nothing pending uint16_t tail_sent_{0}; // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. std::atomic close_work_queued_{false}; + std::atomic lru_work_queued_{false}; // Main-loop only; the HTTPD task never reads or writes these flags. bool close_requested_{false}; bool close_retry_warning_logged_{false}; @@ -373,6 +380,7 @@ class AsyncEventSourceResponse { static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; + static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000; }; using AsyncEventSourceClient = AsyncEventSourceResponse; From cd6c0dd402bde0022f011fe7ed93ee6497ecbffc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:21:45 +0000 Subject: [PATCH 594/651] Bump aioesphomeapi from 46.10.0 to 46.11.0 (#20766) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a54897ef36d..486612817c2 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.10.0 +aioesphomeapi==46.11.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From ba575600dc53f21f09f306a1f8c690cb2173c933 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:13:32 -1000 Subject: [PATCH 595/651] [mqtt] Match subscription topics in a loop instead of recursing per character (#20767) --- esphome/components/mqtt/mqtt_client.cpp | 58 +----------- esphome/components/mqtt/mqtt_topic_match.h | 28 ++++++ tests/components/mqtt/test_topic_match.cpp | 103 +++++++++++++++++++++ 3 files changed, 132 insertions(+), 57 deletions(-) create mode 100644 esphome/components/mqtt/mqtt_topic_match.h create mode 100644 tests/components/mqtt/test_topic_match.cpp diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 2ecab47904a..4143f12d127 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -16,6 +16,7 @@ #include "lwip/dns.h" #include "lwip/err.h" #include "mqtt_component.h" +#include "mqtt_topic_match.h" #ifdef USE_API #include "esphome/components/api/api_server.h" @@ -581,63 +582,6 @@ void MQTTClientComponent::disable() { this->on_shutdown(); } -/** Check if the message topic matches the given subscription topic - * - * INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings. - * - * @param message The message topic that was received from the MQTT server. Note: this must not contain - * wildcard characters as mandated by the MQTT spec. - * @param subscription The subscription topic we are matching against. - * @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$". - * @param past_separator Are we past the first '/' topic separator. - * @return true if the subscription topic matches the message topic, false otherwise. - */ -static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { - // Reached end of both strings at the same time, this means we have a successful match - if (*message == '\0' && *subscription == '\0') - return true; - - // Either the message or the subscribe are at the end. This means they don't match. - if (*message == '\0' || *subscription == '\0') - return false; - - bool do_wildcards = is_normal || past_separator; - - if (*subscription == '+' && do_wildcards) { - // single level wildcard - // consume + from subscription - subscription++; - // consume everything from message until '/' found or end of string - while (*message != '\0' && *message != '/') { - message++; - } - // after this, both pointers will point to a '/' or to the end of the string - - return topic_match(message, subscription, is_normal, true); - } - - if (*subscription == '#' && do_wildcards) { - // multilevel wildcard - MQTT mandates that this must be at end of subscribe topic - return true; - } - - // this handles '/' and normal characters at the same time. - if (*message != *subscription) - return false; - - past_separator = past_separator || *subscription == '/'; - - // consume characters - subscription++; - message++; - - return topic_match(message, subscription, is_normal, past_separator); -} - -static bool topic_match(const char *message, const char *subscription) { - return topic_match(message, subscription, *message != '\0' && *message != '$', false); -} - void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) { #ifdef USE_ESP8266 // IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266. diff --git a/esphome/components/mqtt/mqtt_topic_match.h b/esphome/components/mqtt/mqtt_topic_match.h new file mode 100644 index 00000000000..84172bc805e --- /dev/null +++ b/esphome/components/mqtt/mqtt_topic_match.h @@ -0,0 +1,28 @@ +#pragma once + +namespace esphome::mqtt { + +/// True if the message topic matches the subscription, with + and # wildcards. +/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics. +inline bool topic_match(const char *message, const char *subscription) { + // First-level wildcards do not match topics that begin with '$' + bool wildcards = *message != '$'; + while (*message != '\0' && *subscription != '\0') { + if (wildcards && *subscription == '#') + return true; + if (wildcards && *subscription == '+') { + subscription++; + while (*message != '\0' && *message != '/') + message++; + continue; + } + if (*message != *subscription) + return false; + wildcards |= *subscription == '/'; + message++; + subscription++; + } + return *message == *subscription; // a match only if both ended +} + +} // namespace esphome::mqtt diff --git a/tests/components/mqtt/test_topic_match.cpp b/tests/components/mqtt/test_topic_match.cpp new file mode 100644 index 00000000000..b51f45ce478 --- /dev/null +++ b/tests/components/mqtt/test_topic_match.cpp @@ -0,0 +1,103 @@ +#include + +#include +#include + +#include "esphome/components/mqtt/mqtt_topic_match.h" + +namespace esphome::mqtt::testing { + +// The previous recursive implementation, kept to check the loop matches it +static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { + if (*message == '\0' && *subscription == '\0') + return true; + if (*message == '\0' || *subscription == '\0') + return false; + bool do_wildcards = is_normal || past_separator; + if (*subscription == '+' && do_wildcards) { + subscription++; + while (*message != '\0' && *message != '/') + message++; + return reference_match(message, subscription, is_normal, true); + } + if (*subscription == '#' && do_wildcards) + return true; + if (*message != *subscription) + return false; + past_separator = past_separator || *subscription == '/'; + return reference_match(message + 1, subscription + 1, is_normal, past_separator); +} + +static bool reference_match(const char *message, const char *subscription) { + return reference_match(message, subscription, *message != '\0' && *message != '$', false); +} + +TEST(MQTTTopicMatch, Exact) { + EXPECT_TRUE(topic_match("a/b/c", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b")); + EXPECT_FALSE(topic_match("a/b", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b/d")); +} + +TEST(MQTTTopicMatch, SingleLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/+/c")); + EXPECT_TRUE(topic_match("a/b/c", "+/+/+")); + EXPECT_TRUE(topic_match("a//c", "a/+/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/+")); + EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c")); +} + +TEST(MQTTTopicMatch, MultiLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/#")); + EXPECT_TRUE(topic_match("a/b/c", "#")); + EXPECT_TRUE(topic_match("a/b/c", "a/+/#")); + EXPECT_FALSE(topic_match("b/c", "a/#")); +} + +TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) { + EXPECT_FALSE(topic_match("$SYS/broker", "#")); + EXPECT_FALSE(topic_match("$SYS/broker", "+/broker")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+")); +} + +TEST(MQTTTopicMatch, EdgeCases) { + EXPECT_TRUE(topic_match("", "")); + EXPECT_FALSE(topic_match("a", "")); + // Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level + EXPECT_FALSE(topic_match("a/b", "a/b/#")); + EXPECT_FALSE(topic_match("a/", "a/+")); + EXPECT_TRUE(topic_match("a/$x", "a/+")); +} + +// Recursion overflowed the ESP32 loop stack at about 250 characters +TEST(MQTTTopicMatch, LongTopic) { + std::string topic(4096, 'x'); + topic[100] = '/'; + EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str())); + std::string other = topic; + other.back() = 'y'; + EXPECT_FALSE(topic_match(topic.c_str(), other.c_str())); +} + +TEST(MQTTTopicMatch, MatchesPreviousImplementation) { + // Small alphabet so wildcards, separators and '$' meet often + static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'}; + static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'}; + std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + auto random_string = [&rng](const char *chars, size_t count) { + std::string s(rng() % 8, ' '); + for (char &c : s) + c = chars[rng() % count]; + return s; + }; + for (int i = 0; i < 20000; i++) { + std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS)); + std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS)); + ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()), + reference_match(message.c_str(), subscription.c_str())) + << "message='" << message << "' subscription='" << subscription << "'"; + } +} + +} // namespace esphome::mqtt::testing From dd2582bdfd71dfad2ace33b072048232ce9ce578 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:41:12 -0400 Subject: [PATCH 596/651] [usb_uart] Expose `interface_class` and `port_number` (#20659) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 7 ++- esphome/components/api/api_pb2.cpp | 4 ++ esphome/components/api/api_pb2.h | 2 + esphome/components/api/api_pb2_dump.cpp | 2 + .../components/serial_proxy/serial_proxy.cpp | 2 + esphome/components/usb_uart/ch34x.cpp | 8 --- esphome/components/usb_uart/cp210x.cpp | 10 ++-- esphome/components/usb_uart/ft23xx.cpp | 1 + esphome/components/usb_uart/pl2303.cpp | 5 +- esphome/components/usb_uart/usb_uart.cpp | 6 ++- esphome/components/usb_uart/usb_uart.h | 21 +++++++- tests/components/serial_proxy/__init__.py | 16 ++++++ .../test_usb_device_descriptor.cpp | 51 +++++++++++++++++++ 13 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 tests/components/serial_proxy/__init__.py create mode 100644 tests/components/serial_proxy/test_usb_device_descriptor.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 77b5afe818e..6ba7bde9af6 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -3043,12 +3043,15 @@ enum SerialProxyIdentityFlag { // IDs below are empty } -// The descriptor fields of a USB device as seen by the host stack +// The descriptor fields of a USB device as seen by the host stack, and the port's index +// among those a vendor driver exposes on the interface message UsbDeviceDescriptor { uint32 vendor_id = 1; uint32 product_id = 2; uint32 bcd_device = 3; - uint32 interface_number = 4; // bInterfaceNumber the host driver binds to + uint32 interface_number = 4; // bInterfaceNumber the port is bound through + uint32 interface_class = 5; // bInterfaceClass of that interface + uint32 port_number = 6; } // The identity of the device behind a port. Identifies one physical device among others of diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b59663aeb51..a3a08b78e3d 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4175,6 +4175,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number); return pos; } uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { @@ -4184,6 +4186,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.product_id); size += ProtoSize::calc_uint32(1, msg.bcd_device); size += ProtoSize::calc_uint32(1, msg.interface_number); + size += ProtoSize::calc_uint32(1, msg.interface_class); + size += ProtoSize::calc_uint32(1, msg.port_number); return size; } uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 421c3fccb38..ef4e713c77d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -4083,6 +4083,8 @@ class UsbDeviceDescriptor final : public ProtoMessage { uint32_t product_id{0}; uint32_t bcd_device{0}; uint32_t interface_number{0}; + uint32_t interface_class{0}; + uint32_t port_number{0}; 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/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3c6f5302b55..ec224c46e29 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2942,6 +2942,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class); + dump_field(out, ESPHOME_PSTR("port_number"), this->port_number); return out.c_str(); } const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 16e0f0b984c..a34b1b5b514 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -412,6 +412,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: msg.usb.product_id = scratch.product_id; msg.usb.bcd_device = scratch.bcd_device; msg.usb.interface_number = this->usb_channel_->get_interface_number(); + msg.usb.interface_class = this->usb_channel_->get_interface_class(); + msg.usb.port_number = this->usb_channel_->get_port_number(); msg.manufacturer = StringRef(scratch.manufacturer); msg.product = StringRef(scratch.product); msg.serial_number = StringRef(scratch.serial_number); diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index ed5a034b82f..dce07a64a53 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -152,14 +152,6 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo } } -std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { - auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl); - // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare - for (auto &cdc_dev : result) { - cdc_dev.interrupt_interface_number = 0xFF; - } - return result; -} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index e920da6c267..34b76f88541 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -88,11 +88,11 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } - if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) { - cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}}); - } else { - cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}}); - } + CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN + ? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber} + : CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}; + eps.bulk_interface_class = data_desc->bInterfaceClass; + cdc_devs.push_back(eps); } return cdc_devs; } diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index e10da78aaf0..805a4adfce3 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -217,6 +217,7 @@ static optional get_uart(const usb_config_desc_t *config_desc, uint8_t i } eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; return eps; } diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 36107c093c4..0430b1eeacb 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -189,7 +189,10 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev } if (in_ep && out_ep) { - cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}; + eps.bulk_interface_class = intf->bInterfaceClass; + eps.interrupt_interface_class = intf->bInterfaceClass; + cdc_devs.push_back(eps); break; // PL2303 is single-port } } diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index f61642566cb..744b79f4e43 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -41,14 +41,17 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { eps.notify_ep = ep; eps.interrupt_interface_number = intf_desc->bInterfaceNumber; + eps.interrupt_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.in_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.out_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else { ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes); continue; @@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() { if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { if (!this->claim_comm_interface_) { - ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", - channel->cdc_dev_.interrupt_interface_number); + ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number); } else { auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number, 0); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 4e52fb60e20..9f527a9becd 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -39,6 +39,9 @@ struct CdcEps { // must remain valid even when the interface itself is not claimed. uint8_t interrupt_interface_number{0xFF}; bool interrupt_interface_claimed{false}; + // bInterfaceClass of each interface above, which decides how a host binds the port + uint8_t bulk_interface_class{0}; + uint8_t interrupt_interface_class{0}; }; enum CH34xChipType : uint8_t { @@ -186,6 +189,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedcdc_dev_.bulk_interface_number; } + /// bInterfaceClass of the interface `get_interface_number()` names + uint8_t get_interface_class() const { + return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class + : this->cdc_dev_.bulk_interface_class; + } + + /// Index of this port among the ports on its interface. Every driver here exposes one + /// port per interface; a multiplexed driver would index its channels. + uint8_t get_port_number() const { return 0; } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -317,13 +330,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: - USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} + USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) { + // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The + // comm interface is left in place unclaimed: on the CDC-style chips it is still the + // interface a host binds, and so the one the port is identified by. + this->claim_comm_interface_ = false; + } void dump_config() override; protected: bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; - std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: CH34xChipType chiptype_{CHIP_UNKNOWN}; diff --git a/tests/components/serial_proxy/__init__.py b/tests/components/serial_proxy/__init__.py new file mode 100644 index 00000000000..913e8d8b37e --- /dev/null +++ b/tests/components/serial_proxy/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness gives a multi-instance component an empty list and never calls its to_code, + # so take a single empty config instead and emit what the proxy messages and handlers + # need. No proxy is configured, so the count is 0, which keeps the capability messages as + # the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only. + manifest.multi_conf = False + + async def to_code_testing(config): + cg.add_define("USE_SERIAL_PROXY") + cg.add_define("SERIAL_PROXY_COUNT", 0) + + manifest.to_code = to_code_testing diff --git a/tests/components/serial_proxy/test_usb_device_descriptor.cpp b/tests/components/serial_proxy/test_usb_device_descriptor.cpp new file mode 100644 index 00000000000..c76757bbee6 --- /dev/null +++ b/tests/components/serial_proxy/test_usb_device_descriptor.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check. +static std::vector encode(const UsbDeviceDescriptor &msg) { + const uint32_t size = msg.calculate_size(); + APIBuffer buf; + EXPECT_TRUE(buf.resize(size == 0 ? 1 : size)); + uint8_t *pos = buf.data(); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = buf.data() + buf.size(); +#endif + uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG); + EXPECT_EQ(static_cast(end - buf.data()), size); + return {buf.data(), end}; +} + +TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) { + UsbDeviceDescriptor msg; + msg.vendor_id = 0x10C4; + msg.product_id = 0xEA60; + msg.bcd_device = 0x0100; + msg.interface_number = 1; + msg.interface_class = 0xFF; + msg.port_number = 2; + + const std::vector expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80, + 0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02}; + EXPECT_EQ(encode(msg), expected); +} + +TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) { + // The new fields are additive with zero defaults, so an old client sees the message it always did + UsbDeviceDescriptor msg; + msg.vendor_id = 0x303A; + msg.product_id = 0x1001; + msg.interface_number = 1; + + const std::vector expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01}; + EXPECT_EQ(encode(msg), expected); +} + +} // namespace esphome::api::testing From ad8cd0dd3ea1c29c16d8308debcddb23ef9dbcd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:45:54 -1000 Subject: [PATCH 597/651] [noise] Initialize the protocol id's reserved field for ESP-IDF 6.1 (#20779) --- esphome/components/noise/noise_handshake.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index bd1afa198d3..15938f3aca1 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -37,6 +37,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog .cipher_id = NOISE_CIPHER_CHACHAPOLY, .hash_id = NOISE_HASH_SHA256, .hybrid_id = NOISE_DH_NONE, + .reserved = {}, }; int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER); From 4552dbad7a9e13e195657675882ff94bddbde86d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:07 -1000 Subject: [PATCH 598/651] Bump aioesphomeapi from 46.11.0 to 46.12.0 (#20781) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 486612817c2..d81770cf647 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.11.0 +aioesphomeapi==46.12.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From f464994f859451ba7cdb77438c53a88bc21d900f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 18:56:51 -0400 Subject: [PATCH 599/651] [api][sendspin] Add the Sendspin client id to the capabilities message (#20775) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 3 +++ esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 1 + esphome/components/api/api_pb2_dump.cpp | 1 + esphome/components/sendspin/sendspin_hub.cpp | 8 ++++++++ esphome/components/sendspin/sendspin_hub.h | 6 ++++++ tests/unit_tests/components/api/test_api_proto.py | 2 +- 8 files changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 6ba7bde9af6..8e9ff00f59c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -403,6 +403,9 @@ message WizardCapabilities { message SendspinCapabilities { // Bitmask of the Sendspin features this device supports uint32 feature_flags = 1; + // The Sendspin client_id: base64url of the client's static public key, 43 characters. + // Empty until the Sendspin client has started once since boot. + string client_id = 2 [(max_data_length) = 43]; } message DeviceCapabilitiesResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 73ce4bd8569..efa0beb55e1 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2128,6 +2128,7 @@ bool APIConnection::send_device_capabilities_response_() { #endif #if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); + resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id(); #endif return this->send_message(resp); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index a3a08b78e3d..a3a2e09501f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id); return pos; } uint32_t SendspinCapabilities::calc_size_msg(const void *self) { const auto &msg = *static_cast(self); uint32_t size = 0; size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0; return size; } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index ef4e713c77d..cbfdb49ac9a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage { class SendspinCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; + StringRef client_id{nullptr, 0}; // null until set, encode only 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/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ec224c46e29..23ce6319ed1 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1119,6 +1119,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const { const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("client_id"), this->client_id); return out.c_str(); } #endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index df680202061..30bff186155 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -243,6 +243,14 @@ std::optional SendspinHub::get_pairing_token() const { return this->client_->pairing_token(); } +// THREAD CONTEXT: Main loop +StringRef SendspinHub::get_client_id() const { + if (this->client_ == nullptr) { + return StringRef(); + } + return StringRef(this->client_->client_id()); +} + // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_unpaired_access_enabled(bool enabled) { this->unpaired_access_ = enabled; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index cd8d0d0afaf..23c60d215b6 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -198,6 +198,12 @@ class SendspinHub final : public Component, /// started once; it stays available after the client stops. Main loop only. std::optional get_pairing_token() const; + /// @brief Returns the Sendspin client_id: base64url of the client's static public key. + /// + /// Empty until the client has started once; it stays set after the client stops. The returned StringRef points into + /// the client's string, which start() may rewrite, so do not keep it past the current call. Main loop only. + StringRef get_client_id() const; + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index ca3c0ce7b58..68e3cab2a21 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -71,7 +71,7 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "sendspin": 6, }, "WizardCapabilities": {"configured": 1}, - "SendspinCapabilities": {"feature_flags": 1}, + "SendspinCapabilities": {"feature_flags": 1, "client_id": 2}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, From bd5b3fa64125d7ba4bbfd69c46b7db536156e56b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 15:59:56 -1000 Subject: [PATCH 600/651] [esp32_ble_client] Value-initialize the connection parameters for ESP-IDF 6.1 (#20776) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index cfd77e84f4a..547ab6748bf 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) { esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From e09cb64756d48b61db4ae20b42707211b3ca1468 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 16:00:07 -1000 Subject: [PATCH 601/651] [bluetooth_connection] Value-initialize the connection parameters for ESP-IDF 6.1 (#20774) --- .../bluetooth_connection/bluetooth_connection_bluedroid.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index d3af02780fe..b7017bc105b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() { esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From 79823c89abb7f3c66794df286177861a48a4e867 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:05:01 -0400 Subject: [PATCH 602/651] [mipi_rgb] Rotate pure RGB panels in software (#20757) --- .../components/mipi_rgb/models/__init__.py | 6 +++- .../mipi_rgb/test_mipi_rgb_config.py | 35 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py index 9e3fe2a476f..62f8c7789d4 100644 --- a/esphome/components/mipi_rgb/models/__init__.py +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip): def transforms(self) -> set[str]: """ Return the set of transformations supported by this driver chip. - RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY. + A model with an empty init sequence has no command interface at all, so it + cannot apply any transform in hardware. """ + if self.initsequence is not None and len(self.initsequence) == 0: + return set() return super().transforms - {CONF_SWAP_XY} diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index e677577ec08..58e7ba9d959 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -93,12 +93,12 @@ def test_transform_matches_model_support( from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS - # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays # never support axis swapping). - model = MODELS["ESP32-8048S070"] + model = MODELS["ST7701S"] assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} - base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21} CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) # An unsupported axis may be explicitly disabled (a harmless no-op)... @@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x( CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) +def test_pure_rgb_model_has_no_hardware_transform( + set_core_config: SetCoreConfigCallable, +) -> None: + """A model with no init sequence has no command interface, so no transform + can be applied in hardware and every rotation must be done in software. + + Previously such models claimed mirror_x/mirror_y support, so a rotation of + 180 was handed to the (nonexistent) hardware and the display was not rotated. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["ESP32-8048S070"] + assert model.initsequence == () + assert model.transforms == set() + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + for rotation in (90, 180, 270): + config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation}) + assert not model.rotation_as_transform(config) + metadata = get_display_metadata(config["id"]) + assert metadata.rotation == rotation + assert not metadata.has_hardware_rotation + + with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + def test_metadata_records_rotation( set_core_config: SetCoreConfigCallable, ) -> None: From 235c614e68eb2085d97f0afa2b1bd8ee45a4eb37 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:55:19 +1100 Subject: [PATCH 603/651] [epaper_spi] Ensure test card gets drawn (#20804) --- esphome/components/epaper_spi/display.py | 1 + .../config/test_card_update_interval.yaml | 22 +++++++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 12 ++++++++++ 3 files changed, 35 insertions(+) create mode 100644 tests/component_tests/epaper_spi/config/test_card_update_interval.yaml diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 487e8e5ef40..32ac0b9da70 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -208,6 +208,7 @@ def _final_validate(config) -> None: else: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + config[CONF_UPDATE_INTERVAL] = update_interval("1min") elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") diff --git a/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml new file mode 100644 index 00000000000..fb6eeec8d77 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + update_interval: never + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 4b8f5788925..81e197abf81 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -580,3 +580,15 @@ def test_model_with_no_default_init_sequence_generates( # init_sequence_length, ...); a length of 0 confirms the empty init # sequence array was generated instead of raising during code generation. assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp) + + +def test_test_card_overrides_update_interval( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With nothing else to draw, update_interval is not left at never for the test card.""" + main_cpp = generate_main(component_config_path("test_card_update_interval.yaml")) + + match = re.search(r"epaper_display->set_update_interval\((\d+)\);", main_cpp) + assert match is not None + assert int(match.group(1)) != 4294967295 From 0173127239a449d3952cdee2532795f1904431d3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:11:26 -1000 Subject: [PATCH 604/651] Bump bundled esphome-device-builder to 1.24.0 (#20851) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c9aa3dba62c..658b8c06302 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.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0 RUN \ platformio settings set enable_telemetry No \ From 7ccaedfb9e14bccd320b2fa808ee4a56f90d5d1b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:49:15 -0400 Subject: [PATCH 605/651] [esp32] Bump recommended ESP-IDF to 5.5.6 (#20987) --- esphome/components/esp32/__init__.py | 9 +++++---- platformio.ini | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 73078dc19fd..9164b1e1590 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1099,7 +1099,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), - cv.Version(3, 3, 12): cv.Version(5, 5, 5), + cv.Version(3, 3, 12): cv.Version(5, 5, 6), cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), @@ -1123,9 +1123,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 5), - "latest": cv.Version(5, 5, 5), - "dev": cv.Version(5, 5, 5), + "recommended": cv.Version(5, 5, 6), + "latest": cv.Version(5, 5, 6), + "dev": cv.Version(5, 5, 6), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -1135,6 +1135,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(5, 5, 6): cv.Version(55, 3, 312), cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index 56a12d8b31c..18c410c16b2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -147,7 +147,7 @@ extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -183,7 +183,7 @@ extra_scripts = extends = common:idf platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = espidf lib_deps = From 0fbba035221341fd23d5836fc382f3f72783184a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:54 -0400 Subject: [PATCH 606/651] [debug] Log uptime in dump_config (#20989) --- esphome/components/debug/debug_component.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 97f4522c623..afd1337dca2 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -34,6 +34,15 @@ void DebugComponent::dump_config() { this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); + uint32_t uptime = static_cast(millis_64() / 1000); + uint32_t days = uptime / 86400; + uptime -= days * 86400; + uint32_t hours = uptime / 3600; + uptime -= hours * 3600; + uint32_t minutes = uptime / 60; + uptime -= minutes * 60; + ESP_LOGD(TAG, "Uptime: %" PRIu32 "d %02" PRIu32 "h %02" PRIu32 "m %02" PRIu32 "s", days, hours, minutes, uptime); + pos = get_device_info_(std::span(device_info_buffer), pos); #ifdef USE_TEXT_SENSOR From c50ee215f225ced93a3074b5b42cd18ea4855ac5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:07:42 -1000 Subject: [PATCH 607/651] [tcp_uart] Expose the connection entities as platforms (#21009) --- esphome/components/tcp_uart/__init__.py | 30 +++--------------- esphome/components/tcp_uart/binary_sensor.py | 25 +++++++++++++++ esphome/components/tcp_uart/sensor.py | 31 +++++++++++++++++++ esphome/components/tcp_uart/tcp_uart.cpp | 6 ++++ esphome/components/tcp_uart/tcp_uart.h | 7 +++++ tests/components/tcp_uart/benchmark.yaml | 5 +-- tests/components/tcp_uart/common.yaml | 8 +++++ .../tcp_uart/test-client.esp32-idf.yaml | 8 +++++ .../integration/fixtures/tcp_uart_bridge.yaml | 4 +++ 9 files changed, 97 insertions(+), 27 deletions(-) create mode 100644 esphome/components/tcp_uart/binary_sensor.py create mode 100644 esphome/components/tcp_uart/sensor.py diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 796a9fc48db..c05b9d17a32 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_DATA_BITS, CONF_HOST, CONF_PARITY, @@ -11,22 +10,16 @@ from esphome.components.const import ( CONF_STOP_BITS, ) import esphome.config_validation as cv -from esphome.const import ( - CONF_BAUD_RATE, - CONF_ID, - CONF_PORT, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, -) +from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_PORT from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DEPENDENCIES = ["network"] -AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["uart", "socket"] +DOMAIN = "tcp_uart" MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_TCP_UART_ID = "tcp_uart_id" tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) @@ -45,15 +38,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -99,7 +83,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_parity(config[CONF_PARITY])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/binary_sensor.py b/esphome/components/tcp_uart/binary_sensor.py new file mode 100644 index 00000000000..8337973c837 --- /dev/null +++ b/esphome/components/tcp_uart/binary_sensor.py @@ -0,0 +1,25 @@ +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 CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/tcp_uart/sensor.py b/esphome/components/tcp_uart/sensor.py new file mode 100644 index 00000000000..099ca8734e1 --- /dev/null +++ b/esphome/components/tcp_uart/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index efc85804e99..139b31a2203 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -17,9 +17,11 @@ void TcpUart::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -38,7 +40,9 @@ void TcpUart::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -58,9 +62,11 @@ void TcpUart::sync_link_() { // Unread bytes of the last session stay readable while down, never into the next one. this->rx_start_ = this->rx_end_ = 0; } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index 1e1d48e4c20..6ecfd1a0862 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -23,7 +26,9 @@ class TcpUart : public uart::UARTComponent, public Component { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -64,7 +69,9 @@ class TcpUart : public uart::UARTComponent, public Component { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif #ifdef USE_SENSOR sensor::Sensor *disconnects_sensor_{nullptr}; uint32_t disconnects_{0}; diff --git a/tests/components/tcp_uart/benchmark.yaml b/tests/components/tcp_uart/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/tcp_uart/benchmark.yaml +++ b/tests/components/tcp_uart/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/tcp_uart/common.yaml b/tests/components/tcp_uart/common.yaml index 97ac0247da3..97bcd24f6a7 100644 --- a/tests/components/tcp_uart/common.yaml +++ b/tests/components/tcp_uart/common.yaml @@ -10,8 +10,16 @@ tcp_uart: allowed_ips: - 192.0.2.20 - 192.0.2.0/24 + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server connected: name: TCP UART Server Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server disconnects: name: TCP UART Server Disconnects diff --git a/tests/components/tcp_uart/test-client.esp32-idf.yaml b/tests/components/tcp_uart/test-client.esp32-idf.yaml index 74bd26a98bc..921ce0cbc1a 100644 --- a/tests/components/tcp_uart/test-client.esp32-idf.yaml +++ b/tests/components/tcp_uart/test-client.esp32-idf.yaml @@ -7,7 +7,15 @@ tcp_uart: host: 192.0.2.10 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: TCP UART Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 disconnects: name: TCP UART Disconnects diff --git a/tests/integration/fixtures/tcp_uart_bridge.yaml b/tests/integration/fixtures/tcp_uart_bridge.yaml index 12791e7dd46..74a8710b19e 100644 --- a/tests/integration/fixtures/tcp_uart_bridge.yaml +++ b/tests/integration/fixtures/tcp_uart_bridge.yaml @@ -20,6 +20,10 @@ tcp_uart: host: 127.0.0.1 port: 18125 reconnect_interval: 3s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: Bridge Connected From 952623382154d4ce9e6df80318a42f17007bb9bb Mon Sep 17 00:00:00 2001 From: ab-tools Date: Sun, 18 Oct 2026 23:39:37 +0200 Subject: [PATCH 608/651] [audio] Compute frames_to_microseconds() in 64 bits (#21089) --- esphome/components/audio/audio.cpp | 3 ++- .../audio/test_audio_stream_info.cpp | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 tests/components/audio/test_audio_stream_info.cpp diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index 402e741059b..54406df2aaa 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -23,7 +23,8 @@ AudioStreamInfo::AudioStreamInfo(uint8_t bits_per_sample, uint8_t channels, uint } uint32_t AudioStreamInfo::frames_to_microseconds(uint32_t frames) const { - return (frames * 1000000 + (this->sample_rate_ >> 1)) / this->sample_rate_; + // In 64 bits: frames * 1000000 overflows 32 bits above 4294 frames, about 90 ms at 48 kHz. + return (static_cast(frames) * 1000000 + (this->sample_rate_ >> 1)) / this->sample_rate_; } uint32_t AudioStreamInfo::frames_to_milliseconds_with_remainder(uint32_t *total_frames) const { diff --git a/tests/components/audio/test_audio_stream_info.cpp b/tests/components/audio/test_audio_stream_info.cpp new file mode 100644 index 00000000000..e948bc32b2f --- /dev/null +++ b/tests/components/audio/test_audio_stream_info.cpp @@ -0,0 +1,20 @@ +#include + +#include "esphome/components/audio/audio.h" + +namespace esphome::audio::testing { + +TEST(AudioStreamInfo, FramesToMicroseconds) { + AudioStreamInfo info(16, 2, 48000); + EXPECT_EQ(info.frames_to_microseconds(48), 1000U); + EXPECT_EQ(info.frames_to_microseconds(1), 21U); // 20.83 us, rounded +} + +TEST(AudioStreamInfo, FramesToMicrosecondsAboveFourThousandFrames) { + // frames * 1000000 no longer fits 32 bits above 4294 frames, about 90 ms at 48 kHz. + AudioStreamInfo info(24, 2, 48000); + EXPECT_EQ(info.frames_to_microseconds(4800), 100000U); + EXPECT_EQ(info.frames_to_microseconds(48000), 1000000U); +} + +} // namespace esphome::audio::testing From b1c138c6129361caa0869a085457c8c6d6d14c3c Mon Sep 17 00:00:00 2001 From: ab-tools Date: Mon, 19 Oct 2026 00:12:50 +0200 Subject: [PATCH 609/651] [audio] Compute the remaining AudioStreamInfo conversions in 64 bits (#21092) --- esphome/components/audio/audio.cpp | 2 +- esphome/components/audio/audio.h | 10 +++--- .../audio/test_audio_stream_info.cpp | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index 54406df2aaa..61163b820d8 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -31,7 +31,7 @@ uint32_t AudioStreamInfo::frames_to_milliseconds_with_remainder(uint32_t *total_ uint32_t unprocessable_frames = *total_frames % (this->sample_rate_ / this->ms_sample_rate_gcd_); uint32_t frames_for_ms_calculation = *total_frames - unprocessable_frames; - uint32_t playback_ms = (frames_for_ms_calculation * 1000) / this->sample_rate_; + uint32_t playback_ms = (static_cast(frames_for_ms_calculation) * 1000) / this->sample_rate_; *total_frames = unprocessable_frames; return playback_ms; } diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h index 36780a3055b..c03c42a5a3f 100644 --- a/esphome/components/audio/audio.h +++ b/esphome/components/audio/audio.h @@ -34,7 +34,7 @@ class AudioStreamInfo { /// @return Duration in milliseconds that will store `bytes` bytes of audio. May round down for certain sample rates /// or values of `bytes`. uint32_t bytes_to_ms(size_t bytes) const { - return bytes * 1000 / (this->sample_rate_ * this->bytes_per_sample_ * this->channels_); + return static_cast(bytes) * 1000 / (this->sample_rate_ * this->bytes_per_sample_ * this->channels_); } /// @brief Convert bytes to frames. @@ -60,18 +60,20 @@ class AudioStreamInfo { /// @brief Converts duration to frames. /// @param ms Duration in milliseconds /// @return Audio frames that will store `ms` milliseconds of audio. May round down for certain sample rates. - uint32_t ms_to_frames(uint32_t ms) const { return (ms * this->sample_rate_) / 1000; } + uint32_t ms_to_frames(uint32_t ms) const { return (static_cast(ms) * this->sample_rate_) / 1000; } /// @brief Converts duration to samples. /// @param ms Duration in milliseconds /// @return Audio samples that will store `ms` milliseconds of audio. May round down for certain sample rates. - uint32_t ms_to_samples(uint32_t ms) const { return (ms * this->channels_ * this->sample_rate_) / 1000; } + uint32_t ms_to_samples(uint32_t ms) const { + return (static_cast(ms) * this->channels_ * this->sample_rate_) / 1000; + } /// @brief Converts duration to bytes. May round down for certain sample rates. /// @param ms Duration in milliseconds /// @return Bytes that will store `ms` milliseconds of audio. May round down for certain sample rates. size_t ms_to_bytes(uint32_t ms) const { - return (ms * this->bytes_per_sample_ * this->channels_ * this->sample_rate_) / 1000; + return (static_cast(ms) * this->bytes_per_sample_ * this->channels_ * this->sample_rate_) / 1000; } /// @brief Computes the duration, in microseconds, the given amount of frames represents. diff --git a/tests/components/audio/test_audio_stream_info.cpp b/tests/components/audio/test_audio_stream_info.cpp index e948bc32b2f..ce5b24b21e7 100644 --- a/tests/components/audio/test_audio_stream_info.cpp +++ b/tests/components/audio/test_audio_stream_info.cpp @@ -17,4 +17,36 @@ TEST(AudioStreamInfo, FramesToMicrosecondsAboveFourThousandFrames) { EXPECT_EQ(info.frames_to_microseconds(48000), 1000000U); } +TEST(AudioStreamInfo, MsToBytesAboveTheThirtyTwoBitProduct) { + // ms * 8 bytes * 96000 Hz no longer fits 32 bits above 5592 ms. + AudioStreamInfo info(32, 2, 96000); + EXPECT_EQ(info.ms_to_bytes(10000), 7680000U); +} + +TEST(AudioStreamInfo, MsToSamplesAboveTheThirtyTwoBitProduct) { + // ms * 2 channels * 48000 Hz no longer fits 32 bits above 44739 ms; sound_level allows 60 s. + AudioStreamInfo info(16, 2, 48000); + EXPECT_EQ(info.ms_to_samples(60000), 5760000U); +} + +TEST(AudioStreamInfo, MsToFramesAboveTheThirtyTwoBitProduct) { + // ms * 96000 Hz no longer fits 32 bits above 44739 ms. + AudioStreamInfo info(16, 2, 96000); + EXPECT_EQ(info.ms_to_frames(60000), 5760000U); +} + +TEST(AudioStreamInfo, BytesToMsAboveFourMegabytes) { + // bytes * 1000 no longer fits 32 bits above 4294967 bytes. + AudioStreamInfo info(16, 2, 48000); + EXPECT_EQ(info.bytes_to_ms(19200000), 100000U); +} + +TEST(AudioStreamInfo, FramesToMillisecondsAboveFourMillionFrames) { + // frames * 1000 no longer fits 32 bits above 4294967 frames, about 89 s at 48 kHz. + AudioStreamInfo info(16, 2, 48000); + uint32_t frames = 4800000 + 7; + EXPECT_EQ(info.frames_to_milliseconds_with_remainder(&frames), 100000U); + EXPECT_EQ(frames, 7U); +} + } // namespace esphome::audio::testing From 26064f141543d92697a575dbb5e10e7b69d77036 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:32 -1000 Subject: [PATCH 610/651] [uart_tcp] Expose the connection entities as platforms (#21008) --- esphome/components/uart_tcp/__init__.py | 23 ++------------ esphome/components/uart_tcp/binary_sensor.py | 25 +++++++++++++++ esphome/components/uart_tcp/sensor.py | 31 +++++++++++++++++++ esphome/components/uart_tcp/uart_tcp.cpp | 6 ++++ esphome/components/uart_tcp/uart_tcp.h | 7 +++++ tests/components/uart_tcp/benchmark.yaml | 5 +-- tests/components/uart_tcp/common.yaml | 8 +++++ .../uart_tcp/test-client.esp32-idf.yaml | 8 +++++ tests/components/uart_tcp/test.host.yaml | 8 +++++ .../integration/fixtures/uart_tcp_bridge.yaml | 8 +++-- 10 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 esphome/components/uart_tcp/binary_sensor.py create mode 100644 esphome/components/uart_tcp/sensor.py diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 6e7a6fef325..b5eafa9a448 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_HOST, CONF_RECONNECT_INTERVAL, CONF_ROLE, @@ -14,9 +13,6 @@ from esphome.const import ( CONF_ID, CONF_PORT, CONF_UART_ID, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, ) from esphome.core import CORE import esphome.final_validate as fv @@ -25,10 +21,10 @@ from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DOMAIN = "uart_tcp" DEPENDENCIES = ["network", "uart"] -AUTO_LOAD = ["binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["socket"] MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_UART_TCP_ID = "uart_tcp_id" uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp") UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice) @@ -42,15 +38,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -155,7 +142,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/binary_sensor.py b/esphome/components/uart_tcp/binary_sensor.py new file mode 100644 index 00000000000..c8d19319337 --- /dev/null +++ b/esphome/components/uart_tcp/binary_sensor.py @@ -0,0 +1,25 @@ +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 CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/uart_tcp/sensor.py b/esphome/components/uart_tcp/sensor.py new file mode 100644 index 00000000000..ad44b56ad09 --- /dev/null +++ b/esphome/components/uart_tcp/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index 4f241f85671..dcdc8ce49d5 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -19,9 +19,11 @@ void UartTcp::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -40,7 +42,9 @@ void UartTcp::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -60,9 +64,11 @@ void UartTcp::sync_link_() { // The driver kept whatever arrived while the link was down. this->discard_uart_(); } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h index 256e059133a..6080cc95514 100644 --- a/esphome/components/uart_tcp/uart_tcp.h +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -22,7 +25,9 @@ class UartTcp final : public Component, public uart::UARTDevice { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -53,7 +58,9 @@ class UartTcp final : public Component, public uart::UARTDevice { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif // Loop start time of the last socket-to-UART write; sizes the next paced write. uint32_t last_write_ms_{0}; #ifdef USE_SENSOR diff --git a/tests/components/uart_tcp/benchmark.yaml b/tests/components/uart_tcp/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/uart_tcp/benchmark.yaml +++ b/tests/components/uart_tcp/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/uart_tcp/common.yaml b/tests/components/uart_tcp/common.yaml index fd34757d0ee..cf86dc9da89 100644 --- a/tests/components/uart_tcp/common.yaml +++ b/tests/components/uart_tcp/common.yaml @@ -10,7 +10,15 @@ uart_tcp: allowed_ips: - 192.168.1.10 - 192.168.1.0/24 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test-client.esp32-idf.yaml b/tests/components/uart_tcp/test-client.esp32-idf.yaml index 5ba900347c4..6bce693f854 100644 --- a/tests/components/uart_tcp/test-client.esp32-idf.yaml +++ b/tests/components/uart_tcp/test-client.esp32-idf.yaml @@ -12,7 +12,15 @@ uart_tcp: host: 192.0.2.20 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test.host.yaml b/tests/components/uart_tcp/test.host.yaml index a0b82c568df..b793ae144c3 100644 --- a/tests/components/uart_tcp/test.host.yaml +++ b/tests/components/uart_tcp/test.host.yaml @@ -8,7 +8,15 @@ uart_tcp: uart_id: uart_bus host: 127.0.0.1 port: 44502 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/integration/fixtures/uart_tcp_bridge.yaml b/tests/integration/fixtures/uart_tcp_bridge.yaml index 7ad28316f75..cf6c8fad64b 100644 --- a/tests/integration/fixtures/uart_tcp_bridge.yaml +++ b/tests/integration/fixtures/uart_tcp_bridge.yaml @@ -24,11 +24,15 @@ uart_tcp: port: 18126 allowed_ips: - 127.0.0.1 - connected: - name: Bridge Connected - id: denied_bridge uart_id: denied_bus role: server port: 18127 allowed_ips: - 192.0.2.1 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge + connected: + name: Bridge Connected From f979ddcf0de00b868f7c1556bd366820ccbb4608 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:37:48 -1000 Subject: [PATCH 611/651] [bl0906] Read stale receive bytes instead of spinning on flush() (#21035) --- esphome/components/bl0906/bl0906.cpp | 4 +- .../fixtures/uart_mock_bl0906_stray_rx.yaml | 64 +++++++++++++++++++ .../test_uart_mock_bl0906_stray_rx.py | 57 +++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml create mode 100644 tests/integration/test_uart_mock_bl0906_stray_rx.py diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 9a27cffd043..4e9b1f9c5fd 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,7 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) void BL0906::loop() { while (this->available()) - this->flush(); + this->read(); if (this->current_stage_ == STAGE_IDLE) { // Woken up between cycles to drain the action queue. Go back to sleep. @@ -92,7 +92,7 @@ void BL0906::advance_stage_() { void BL0906::setup() { while (this->available()) - this->flush(); + this->read(); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); // Calibration (1: register address; 2: value before calibration; 3: value after calibration) this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 diff --git a/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml new file mode 100644 index 00000000000..d5f8a7837ce --- /dev/null +++ b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml @@ -0,0 +1,64 @@ +esphome: + name: uart-mock-bl0906-stray-rx + +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 DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +# Answers register reads; the first temperature answer has one stray trailing byte +uart_mock: + id: mock_uart + baud_rate: 19200 + on_tx: + - then: + - lambda: |- + // Command and register address arrive as separate one-byte writes + static bool read_pending = false; + static bool stray_sent = false; + if (data.size() != 1) + return; + if (!read_pending) { + read_pending = data[0] == 0x35; + return; + } + read_pending = false; + const uint8_t address = data[0]; + uint32_t raw; + switch (address) { + case 0x5E: // temperature: (300 - 64) * 12.5 / 59 - 40 = 10 C + raw = 300; + break; + case 0x16: // voltage + raw = 0x2A1B3C; + break; + default: + return; + } + const uint8_t l = raw & 0xFF, m = (raw >> 8) & 0xFF, h = (raw >> 16) & 0xFF; + std::vector reply{l, m, h, static_cast((address + l + m + h) ^ 0xFF)}; + if (address == 0x5E && !stray_sent) { + stray_sent = true; + reply.push_back(0xAA); + } + id(mock_uart).inject_to_rx_buffer(reply); + +sensor: + - platform: bl0906 + uart_id: mock_uart + update_interval: 1s + temperature: + name: Temperature + voltage: + name: Voltage diff --git a/tests/integration/test_uart_mock_bl0906_stray_rx.py b/tests/integration/test_uart_mock_bl0906_stray_rx.py new file mode 100644 index 00000000000..5f8a635c7f5 --- /dev/null +++ b/tests/integration/test_uart_mock_bl0906_stray_rx.py @@ -0,0 +1,57 @@ +"""A BL0906 that leaves a stray byte in the receive buffer keeps reading.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import EntityState, SensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_bl0906_stray_rx( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stray received byte does not stop the read cycle.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + lines: list[str] = [] + states: dict[int, EntityState] = {} + + async with ( + run_compiled(yaml_config, line_callback=lines.append), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + temperature = require_entity(entities, "temperature", SensorInfo) + voltage = require_entity(entities, "voltage", SensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity: SensorInfo) -> float | None: + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + try: + async with asyncio.timeout(10.0): + while state_of(voltage) is None: + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"No voltage reading; log: {lines[-20:]}") + + assert any("RX inject 5 bytes" in x for x in lines), "stray byte not sent" + assert state_of(temperature) == pytest.approx(10.0, abs=0.01) + uref = 1.097 * 100000 / (13162 * 100 * 1000) + assert state_of(voltage) == pytest.approx(0x2A1B3C * uref, rel=1e-5) + assert not any("Junk on wire" in x for x in lines) From ffa20abe96664634bea6cdf192450767120406df Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 18 Oct 2026 14:36:35 -0500 Subject: [PATCH 612/651] [api] Poll the outgoing dial every loop on deep sleep builds (#21077) --- esphome/components/api/api_outgoing_connection.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index 16d50c34255..fff11522d79 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -59,17 +59,20 @@ class OutgoingConnectionManager { static constexpr uint32_t BACKOFF_MIN_MS = 5000; static constexpr uint32_t BACKOFF_MAX_MS = 300000; static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; - static constexpr uint32_t NETWORK_RETRY_MS = 500; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // A deep sleep wake window is too short to spend on the delay, so those - // builds dial out as soon as the target is gone + // builds dial out as soon as the target is gone, recheck the network on + // every loop and notice the connect completing on the loop it does #ifdef USE_DEEP_SLEEP static constexpr uint32_t BOOT_WAIT_MS = 0; static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 0; + static constexpr uint32_t NETWORK_RETRY_MS = 0; #else static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; + static constexpr uint32_t NETWORK_RETRY_MS = 500; #endif void try_dial_(APIServer *server, uint32_t now); From 649f2c91f566cf75fe2ecc6e4466f669fe24bf32 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 18 Oct 2026 15:47:02 -0500 Subject: [PATCH 613/651] [api] Poll the outgoing dial every loop (#21085) Co-authored-by: J. Nick Koston --- esphome/components/api/api_outgoing_connection.cpp | 10 +--------- esphome/components/api/api_outgoing_connection.h | 8 +------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index c86107cef0b..0b2b1d52dbe 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -152,10 +152,7 @@ void OutgoingConnectionManager::loop(APIServer *server) { void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { if (!network::is_connected()) { - // Flips within seconds of boot; recheck fast so a deep sleep wake - // window is not spent waiting - this->schedule_wait_(now, NETWORK_RETRY_MS); - return; + return; // still WAITING with the wait elapsed, so the next loop rechecks } struct sockaddr_storage addr; socklen_t addr_len = this->target_sockaddr_(&addr); @@ -197,7 +194,6 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { } this->state_ = DialState::DIAL_STATE_CONNECTING; this->state_ts_ = now; - this->last_poll_ = now; } void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { @@ -206,10 +202,6 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { this->schedule_retry_(now); return; } - if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) { - return; - } - this->last_poll_ = now; int err = 0; switch (socket::poll_connect(*this->dial_socket_, err)) { case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index fff11522d79..fa222e29ccf 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -61,18 +61,13 @@ class OutgoingConnectionManager { static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // A deep sleep wake window is too short to spend on the delay, so those - // builds dial out as soon as the target is gone, recheck the network on - // every loop and notice the connect completing on the loop it does + // builds dial out as soon as the target is gone #ifdef USE_DEEP_SLEEP static constexpr uint32_t BOOT_WAIT_MS = 0; static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 0; - static constexpr uint32_t NETWORK_RETRY_MS = 0; #else static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; - static constexpr uint32_t NETWORK_RETRY_MS = 500; #endif void try_dial_(APIServer *server, uint32_t now); @@ -107,7 +102,6 @@ class OutgoingConnectionManager { uint32_t backoff_{BACKOFF_MIN_MS}; uint32_t wait_{BOOT_WAIT_MS}; uint32_t state_ts_{0}; - uint32_t last_poll_{0}; // Byte-aligned types last #ifndef API_OUTGOING_CONNECTION_HOST From a6a4b49e752817d0615b2db4231f8b73efeb00b3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:29:05 +1300 Subject: [PATCH 614/651] Bump version to 2026.10.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 614b9a6fc05..5261997847d 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.0b4 +PROJECT_NUMBER = 2026.10.0b5 # 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 3888f8f95d3..785a4de64a3 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.0b4" +__version__ = "2026.10.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From be9741c6de58fffed795115b633424cb000e87e5 Mon Sep 17 00:00:00 2001 From: Jacopo Labardi <2527836+labodj@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:02:22 +0200 Subject: [PATCH 615/651] [const] Share CONF_VALUE_TYPE across components (#21062) --- esphome/components/const/__init__.py | 1 + esphome/components/modbus_controller/const.py | 3 ++- esphome/components/modbus_server/const.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 966db92c6fd..99d7bf3f525 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -70,6 +70,7 @@ CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" +CONF_VALUE_TYPE = "value_type" CONF_VERIFY_SSL = "verify_ssl" CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" diff --git a/esphome/components/modbus_controller/const.py b/esphome/components/modbus_controller/const.py index 364a0a510e8..333196aaa0d 100644 --- a/esphome/components/modbus_controller/const.py +++ b/esphome/components/modbus_controller/const.py @@ -1,3 +1,5 @@ +from esphome.components.const import CONF_VALUE_TYPE # noqa: F401 # pylint: disable=unused-import + CONF_ALLOW_DUPLICATE_COMMANDS = "allow_duplicate_commands" CONF_BITMASK = "bitmask" CONF_BYTE_OFFSET = "byte_offset" @@ -23,5 +25,4 @@ CONF_SERVER_COURTESY_RESPONSE = "server_courtesy_response" CONF_SERVER_REGISTERS = "server_registers" CONF_SKIP_UPDATES = "skip_updates" CONF_USE_WRITE_MULTIPLE = "use_write_multiple" -CONF_VALUE_TYPE = "value_type" CONF_WRITE_LAMBDA = "write_lambda" diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index 86366c7ce02..91293818fca 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -1,6 +1,7 @@ +from esphome.components.const import CONF_VALUE_TYPE # noqa: F401 # pylint: disable=unused-import + CONF_REGISTER_LAST_ADDRESS = "register_last_address" CONF_REGISTER_VALUE = "register_value" -CONF_VALUE_TYPE = "value_type" CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" From 2dcb97774d4958a10a54fc748e9887850fd1ec48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 13:54:48 -1000 Subject: [PATCH 616/651] [tests] Add an esp32-s31-idf base file and shared i2c and i2s_audio packages (#21086) --- .../build_components_base.esp32-s31-idf.yaml | 18 ++++++++++++++++++ .../common/i2c/esp32-s31-idf.yaml | 11 +++++++++++ .../common/i2s_audio/esp32-s31-idf.yaml | 14 ++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 tests/test_build_components/build_components_base.esp32-s31-idf.yaml create mode 100644 tests/test_build_components/common/i2c/esp32-s31-idf.yaml create mode 100644 tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml diff --git a/tests/test_build_components/build_components_base.esp32-s31-idf.yaml b/tests/test_build_components/build_components_base.esp32-s31-idf.yaml new file mode 100644 index 00000000000..6dbd51db0ad --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-s31-idf.yaml @@ -0,0 +1,18 @@ +esphome: + name: componenttestesp32s31idf + friendly_name: $component_name + +esp32: + variant: esp32s31 + framework: + type: esp-idf + version: 6.1.0 + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/common/i2c/esp32-s31-idf.yaml b/tests/test_build_components/common/i2c/esp32-s31-idf.yaml new file mode 100644 index 00000000000..77eece9b3b1 --- /dev/null +++ b/tests/test_build_components/common/i2c/esp32-s31-idf.yaml @@ -0,0 +1,11 @@ +# Common I2C configuration for ESP32-S31 IDF tests + +substitutions: + scl_pin: GPIO9 + sda_pin: GPIO8 + +i2c: + - id: i2c_bus + scl: ${scl_pin} + sda: ${sda_pin} + scan: true diff --git a/tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml new file mode 100644 index 00000000000..28587958f0f --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32-S31 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} From a70d75e46996065603dd4bd797f7287802943afa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 13:59:09 -1000 Subject: [PATCH 617/651] [components] Read UART bytes with read_available and discard_available (#21084) --- esphome/components/a01nyub/a01nyub.cpp | 8 +-- esphome/components/a02yyuw/a02yyuw.cpp | 8 +-- .../adalight/adalight_light_effect.cpp | 42 +++++------ esphome/components/apc1/apc1.cpp | 14 ++-- .../apc_proteous/apc_proteous_cover.cpp | 8 +-- esphome/components/daly_bms/daly_bms.cpp | 37 +++++----- esphome/components/emontx/emontx.cpp | 13 ++-- esphome/components/gcja5/gcja5.cpp | 69 ++++++++++--------- esphome/components/he60r/he60r.cpp | 8 +-- esphome/components/hlw8032/hlw8032.cpp | 5 +- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 7 +- .../components/hydreon_rgxx/hydreon_rgxx.cpp | 8 +-- esphome/components/jsn_sr04t/jsn_sr04t.cpp | 9 +-- esphome/components/ld6002b/ld6002b.cpp | 5 +- esphome/components/nextion/nextion.cpp | 6 +- esphome/components/pipsolar/pipsolar.cpp | 20 ++---- esphome/components/pipsolar/pipsolar.h | 1 - esphome/components/pm1006/pm1006.cpp | 6 +- esphome/components/pmsx003/pmsx003.cpp | 42 +++++------ esphome/components/sds011/sds011.cpp | 37 +++++----- esphome/components/sim800l/sim800l.cpp | 7 +- esphome/components/sml/sml.cpp | 6 +- esphome/components/smt100/smt100.cpp | 19 +++-- esphome/components/sun_gtil2/sun_gtil2.cpp | 6 +- esphome/components/systa_bus/systa_bus.cpp | 4 +- .../uart/binary_sensor/uart_binary_sensor.cpp | 7 +- esphome/components/uart/event/uart_event.cpp | 6 +- .../uponor_smatrix/uponor_smatrix.cpp | 6 +- esphome/components/vbus/vbus.cpp | 30 ++++---- esphome/components/wts01/wts01.cpp | 8 +-- 30 files changed, 189 insertions(+), 263 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 8c97169cbf1..84f853c265a 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -9,15 +9,13 @@ namespace esphome::a01nyub { ESPHOME_LOG_TAG(TAG, "a01nyub.sensor"); void A01nyubComponent::loop() { - uint8_t data; - while (this->available() > 0) { - this->read_byte(&data); + this->read_available([this](uint8_t data) { if (this->buffer_.empty() && (data != 0xff)) - continue; + return; buffer_.push_back(data); if (this->buffer_.size() == 4) this->check_buffer_(); - } + }); } void A01nyubComponent::check_buffer_() { diff --git a/esphome/components/a02yyuw/a02yyuw.cpp b/esphome/components/a02yyuw/a02yyuw.cpp index 20e8f6c7b56..6b809ad396c 100644 --- a/esphome/components/a02yyuw/a02yyuw.cpp +++ b/esphome/components/a02yyuw/a02yyuw.cpp @@ -9,15 +9,13 @@ namespace esphome::a02yyuw { ESPHOME_LOG_TAG(TAG, "a02yyuw.sensor"); void A02yyuwComponent::loop() { - uint8_t data; - while (this->available() > 0) { - this->read_byte(&data); + this->read_available([this](uint8_t data) { if (this->buffer_.empty() && (data != 0xff)) - continue; + return; buffer_.push_back(data); if (this->buffer_.size() == 4) this->check_buffer_(); - } + }); } void A02yyuwComponent::check_buffer_() { diff --git a/esphome/components/adalight/adalight_light_effect.cpp b/esphome/components/adalight/adalight_light_effect.cpp index e3510d380eb..7e7dca0f2fd 100644 --- a/esphome/components/adalight/adalight_light_effect.cpp +++ b/esphome/components/adalight/adalight_light_effect.cpp @@ -68,32 +68,32 @@ void AdalightLightEffect::apply(light::AddressableLight &it, const Color ¤ blank_all_leds_(it); } - if (this->available() > 0) { - ESP_LOGV(TAG, "Frame: Available (size=%d).", this->available()); + const size_t avail = this->available(); + if (avail > 0) { + ESP_LOGV(TAG, "Frame: Available (size=%zu).", avail); } - while (this->available() != 0) { - uint8_t data; - if (!this->read_byte(&data)) - break; - this->frame_.push_back(data); - this->last_byte_ = now; + this->read_available( + [this, &it, now](uint8_t data) { + this->frame_.push_back(data); + this->last_byte_ = now; - switch (this->parse_frame_(it)) { - case INVALID: - ESP_LOGD(TAG, "Frame: Invalid (size=%zu, first=%d).", this->frame_.size(), this->frame_[0]); - reset_frame_(it); - break; + switch (this->parse_frame_(it)) { + case INVALID: + ESP_LOGD(TAG, "Frame: Invalid (size=%zu, first=%d).", this->frame_.size(), this->frame_[0]); + reset_frame_(it); + break; - case PARTIAL: - break; + case PARTIAL: + break; - case CONSUMED: - ESP_LOGV(TAG, "Frame: Consumed (size=%zu).", this->frame_.size()); - reset_frame_(it); - break; - } - } + case CONSUMED: + ESP_LOGV(TAG, "Frame: Consumed (size=%zu).", this->frame_.size()); + reset_frame_(it); + break; + } + }, + avail); } AdalightLightEffect::Frame AdalightLightEffect::parse_frame_(light::AddressableLight &it) { diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp index dee4bd8d73b..b94f9c52021 100644 --- a/esphome/components/apc1/apc1.cpp +++ b/esphome/components/apc1/apc1.cpp @@ -104,16 +104,14 @@ void APC1Component::loop() { this->rx_index_ = 0; } - while (this->available()) { + this->read_available([this, now](uint8_t byte) { 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; + return; } if (this->rx_index_ == 1) { @@ -122,12 +120,12 @@ void APC1Component::loop() { } else if (byte != APC1_HEADER_1) { this->rx_index_ = 0; } - continue; + return; } this->rx_buffer_[this->rx_index_++] = byte; if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) { - continue; + return; } // The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a @@ -136,14 +134,14 @@ void APC1Component::loop() { 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; + return; } 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) { diff --git a/esphome/components/apc_proteous/apc_proteous_cover.cpp b/esphome/components/apc_proteous/apc_proteous_cover.cpp index c17773da29c..e781cef67d5 100644 --- a/esphome/components/apc_proteous/apc_proteous_cover.cpp +++ b/esphome/components/apc_proteous/apc_proteous_cover.cpp @@ -27,11 +27,7 @@ void APCProteousCover::setup() { } void APCProteousCover::loop() { - uint8_t data; - while (this->available() > 0) { - if (!this->read_byte(&data)) { - continue; - } + this->read_available([this](uint8_t data) { const char *line = this->line_reader_.feed(data); if (this->line_reader_.overflowed()) { // Start over from this byte, so a frame right after unterminated noise is still read @@ -41,7 +37,7 @@ void APCProteousCover::loop() { ESP_LOGVV(TAG, "rx: '%s'", line); this->parse_response_(line); } - } + }); } void APCProteousCover::parse_response_(const char *line) { diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index 04ef798b69e..422f6f1350b 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -42,25 +42,26 @@ void DalyBmsComponent::loop() { this->last_transmission_ = now; this->trigger_next_ = true; } - if (available()) + const size_t avail = this->available(); + if (avail > 0) this->last_transmission_ = now; - while (available()) { - uint8_t c; - read_byte(&c); - if (!this->receiving_) { - if (c != 0xa5) - continue; - this->receiving_ = true; - } - this->data_.push_back(c); - if (this->data_.size() == 4) - this->data_count_ = c; - if ((this->data_.size() > 4) and (data_.size() == this->data_count_ + 5)) { - this->decode_data_(this->data_); - this->data_.clear(); - this->receiving_ = false; - } - } + this->read_available( + [this](uint8_t c) { + if (!this->receiving_) { + if (c != 0xa5) + return; + this->receiving_ = true; + } + this->data_.push_back(c); + if (this->data_.size() == 4) + this->data_count_ = c; + if ((this->data_.size() > 4) and (data_.size() == this->data_count_ + 5)) { + this->decode_data_(this->data_); + this->data_.clear(); + this->receiving_ = false; + } + }, + avail); if (this->trigger_next_) { this->trigger_next_ = false; diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 888b12f4bf4..1992f36d328 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -111,8 +111,7 @@ void EmonTx::loop() { // Drop what arrived during the pause and resync at the next line start this->was_paused_ = false; uint8_t dropped = 0; - while (this->available() > 0 && this->read_byte(&dropped)) { - } + this->read_available([&dropped](uint8_t byte) { dropped = byte; }); if (dropped == '\n') { this->line_reader_.reset(); } else { @@ -126,16 +125,12 @@ void EmonTx::loop() { } // Read all available data to prevent UART buffer overflow - while (this->available() > 0) { - uint8_t received; - if (!this->read_byte(&received)) - break; - + this->read_available([this](uint8_t received) { size_t len; char *line = this->line_reader_.feed(received, len); this->line_reader_.log_if_overflowed(TAG); if (line == nullptr || len == 0) { - continue; + return; } ESP_LOGD(TAG, "Received line: %s", line); this->data_callbacks_.call(StringRef(line, len)); @@ -149,7 +144,7 @@ void EmonTx::loop() { this->parse_version_line_(line, len); } #endif - } + }); } void EmonTx::parse_json_(const char *data, size_t len) { diff --git a/esphome/components/gcja5/gcja5.cpp b/esphome/components/gcja5/gcja5.cpp index fcb9e832ea8..b908fcd9e15 100644 --- a/esphome/components/gcja5/gcja5.cpp +++ b/esphome/components/gcja5/gcja5.cpp @@ -20,48 +20,49 @@ void GCJA5Component::loop() { this->rx_message_.clear(); } - if (this->available() == 0) { + const size_t avail = this->available(); + if (avail == 0) { return; } // There must now be data waiting this->last_transmission_ = now; - uint8_t val; - while (this->available() != 0) { - this->read_byte(&val); - this->rx_message_.push_back(val); + this->read_available( + [this](uint8_t val) { + this->rx_message_.push_back(val); - // check if rx_message_ has 32 bytes of data - if (this->rx_message_.size() == 32) { - this->parse_data_(); + // check if rx_message_ has 32 bytes of data + if (this->rx_message_.size() == 32) { + this->parse_data_(); - if (this->have_good_data_) { - if (this->pm_1_0_sensor_ != nullptr) - this->pm_1_0_sensor_->publish_state(get_32_bit_uint_(1)); - if (this->pm_2_5_sensor_ != nullptr) - this->pm_2_5_sensor_->publish_state(get_32_bit_uint_(5)); - if (this->pm_10_0_sensor_ != nullptr) - this->pm_10_0_sensor_->publish_state(get_32_bit_uint_(9)); - if (this->pmc_0_3_sensor_ != nullptr) - this->pmc_0_3_sensor_->publish_state(get_16_bit_uint_(13)); - if (this->pmc_0_5_sensor_ != nullptr) - this->pmc_0_5_sensor_->publish_state(get_16_bit_uint_(15)); - if (this->pmc_1_0_sensor_ != nullptr) - this->pmc_1_0_sensor_->publish_state(get_16_bit_uint_(17)); - if (this->pmc_2_5_sensor_ != nullptr) - this->pmc_2_5_sensor_->publish_state(get_16_bit_uint_(21)); - if (this->pmc_5_0_sensor_ != nullptr) - this->pmc_5_0_sensor_->publish_state(get_16_bit_uint_(23)); - if (this->pmc_10_0_sensor_ != nullptr) - this->pmc_10_0_sensor_->publish_state(get_16_bit_uint_(25)); - } else { - this->status_set_warning(); - ESP_LOGV(TAG, "Have 32 bytes but not good data. Skipping."); - } + if (this->have_good_data_) { + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(get_32_bit_uint_(1)); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(get_32_bit_uint_(5)); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(get_32_bit_uint_(9)); + if (this->pmc_0_3_sensor_ != nullptr) + this->pmc_0_3_sensor_->publish_state(get_16_bit_uint_(13)); + if (this->pmc_0_5_sensor_ != nullptr) + this->pmc_0_5_sensor_->publish_state(get_16_bit_uint_(15)); + if (this->pmc_1_0_sensor_ != nullptr) + this->pmc_1_0_sensor_->publish_state(get_16_bit_uint_(17)); + if (this->pmc_2_5_sensor_ != nullptr) + this->pmc_2_5_sensor_->publish_state(get_16_bit_uint_(21)); + if (this->pmc_5_0_sensor_ != nullptr) + this->pmc_5_0_sensor_->publish_state(get_16_bit_uint_(23)); + if (this->pmc_10_0_sensor_ != nullptr) + this->pmc_10_0_sensor_->publish_state(get_16_bit_uint_(25)); + } else { + this->status_set_warning(); + ESP_LOGV(TAG, "Have 32 bytes but not good data. Skipping."); + } - this->rx_message_.clear(); - } - } + this->rx_message_.clear(); + } + }, + avail); } bool GCJA5Component::calculate_checksum_() { diff --git a/esphome/components/he60r/he60r.cpp b/esphome/components/he60r/he60r.cpp index 7c67c5ffd3f..a15551f4c1a 100644 --- a/esphome/components/he60r/he60r.cpp +++ b/esphome/components/he60r/he60r.cpp @@ -152,13 +152,7 @@ void HE60rCover::update_() { } void HE60rCover::loop() { - uint8_t data; - - while (this->available() > 0) { - if (this->read_byte(&data)) { - this->process_rx_(data); - } - } + this->read_available([this](uint8_t data) { this->process_rx_(data); }); } void HE60rCover::control(const CoverCall &call) { diff --git a/esphome/components/hlw8032/hlw8032.cpp b/esphome/components/hlw8032/hlw8032.cpp index cbb6b6c628d..eb0a15d6f2f 100644 --- a/esphome/components/hlw8032/hlw8032.cpp +++ b/esphome/components/hlw8032/hlw8032.cpp @@ -30,8 +30,7 @@ static constexpr uint8_t STATE_REG_OVERFLOW_MASK = 0xF; static constexpr uint8_t PACKET_LENGTH = 24; void HLW8032Component::loop() { - while (this->available()) { - uint8_t data = this->read(); + this->read_available([this](uint8_t data) { if (!this->header_found_) { if ((data == STATE_REG_CORRECTION_FUNC_NORMAL) || (data == STATE_REG_CORRECTION_FUNC_FAIL) || (data & STATE_REG_CORRECTION_MASK) == STATE_REG_CORRECTION_MASK) { @@ -57,7 +56,7 @@ void HLW8032Component::loop() { memset(this->raw_data_, 0, PACKET_LENGTH); } } - } + }); } uint32_t HLW8032Component::read_uint24_(uint8_t offset) { diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 5415b7af74f..7317caf6c0d 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -20,10 +20,7 @@ ESPHOME_LOG_TAG(TAG, "hrxl.maxsonar.wr.sensor"); * The 123 means 123cm */ void HrxlMaxsonarWrComponent::loop() { - uint8_t data; - while (this->available() > 0) { - if (!this->read_byte(&data)) - continue; + this->read_available([this](uint8_t data) { size_t len; const char *line = this->line_reader_.feed(data, len); if (line != nullptr) { @@ -34,7 +31,7 @@ void HrxlMaxsonarWrComponent::loop() { ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->line_reader_.partial()); this->line_reader_.reset(); } - } + }); } void HrxlMaxsonarWrComponent::handle_line_(const char *line, size_t len) { diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 4a479874f53..c96e2a41ecb 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -106,18 +106,14 @@ void HydreonRGxxComponent::update() { } void HydreonRGxxComponent::loop() { - uint8_t data; - while (this->available() > 0) { - if (!this->read_byte(&data)) { - continue; - } + this->read_available([this](uint8_t data) { size_t len; const char *line = this->line_reader_.feed(data, len); this->line_reader_.log_if_overflowed(TAG); if (line != nullptr) { this->process_line_(StringRef(line, len)); } - } + }); } /** diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.cpp b/esphome/components/jsn_sr04t/jsn_sr04t.cpp index 0c7dd277381..502874a0022 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.cpp +++ b/esphome/components/jsn_sr04t/jsn_sr04t.cpp @@ -14,19 +14,16 @@ void Jsnsr04tComponent::update() { } void Jsnsr04tComponent::loop() { - while (this->available() > 0) { - uint8_t data; - this->read_byte(&data); - + this->read_available([this](uint8_t data) { ESP_LOGV(TAG, "Read byte from sensor: %x", data); if (this->buffer_.empty() && data != 0xFF) - continue; + return; this->buffer_.push_back(data); if (this->buffer_.size() == 4) this->check_buffer_(); - } + }); } void Jsnsr04tComponent::check_buffer_() { diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 42461228cef..3418d007ae4 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -497,10 +497,7 @@ void LD6002BComponent::dump_config() { } void LD6002BComponent::loop() { - while (this->available()) { - uint8_t byte = this->read(); - this->parse_byte_(byte); - } + this->read_available([this](uint8_t byte) { this->parse_byte_(byte); }); this->process_command_queue_(); } diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b9194b3f31c..a5ac2325369 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -180,11 +180,7 @@ void Nextion::release_queue_entry_(NextionQueue *nb) { } void Nextion::reset_(bool reset_nextion) { - uint8_t d; - - while (this->available()) { // Clear receive buffer - this->read_byte(&d); - } + this->discard_available(); for (auto *entry : this->nextion_queue_) { this->release_queue_entry_(entry); } diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index ea7ebaed0ae..b5d17cf3c15 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -11,20 +11,10 @@ void Pipsolar::setup() { this->command_start_millis_ = 0; } -void Pipsolar::empty_uart_buffer_() { - uint8_t buf[64]; - size_t avail; - while ((avail = this->available()) > 0) { - if (!this->read_array(buf, std::min(avail, sizeof(buf)))) { - break; - } - } -} - void Pipsolar::loop() { // Read message if (this->state_ == STATE_IDLE) { - this->empty_uart_buffer_(); + this->discard_available(); if (this->send_next_command_()) { // command sent @@ -111,7 +101,7 @@ void Pipsolar::loop() { // make sure data and null terminator fit in buffer if (this->read_pos_ >= PIPSOLAR_READ_BUFFER_LENGTH - 1) { this->read_pos_ = 0; - this->empty_uart_buffer_(); + this->discard_available(); ESP_LOGW(TAG, "response data too long, discarding."); done = true; break; @@ -122,7 +112,7 @@ void Pipsolar::loop() { // end of answer if (byte == 0x0D) { this->read_buffer_[this->read_pos_] = 0; - this->empty_uart_buffer_(); + this->discard_available(); if (this->state_ == STATE_POLL) { this->state_ = STATE_POLL_COMPLETE; } @@ -202,7 +192,7 @@ bool Pipsolar::send_next_command_() { } this->state_ = STATE_COMMAND; this->command_start_millis_ = millis(); - this->empty_uart_buffer_(); + this->discard_available(); this->read_pos_ = 0; crc16 = this->pipsolar_crc_(byte_command, length); this->write_str(command); @@ -231,7 +221,7 @@ bool Pipsolar::send_next_poll_() { } this->state_ = STATE_POLL; this->command_start_millis_ = millis(); - this->empty_uart_buffer_(); + this->discard_available(); this->read_pos_ = 0; crc16 = this->pipsolar_crc_(this->enabled_polling_commands_[this->last_polling_command_].command, this->enabled_polling_commands_[this->last_polling_command_].length); diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 4695e39829a..80ca0156a36 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -206,7 +206,6 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { static const size_t COMMAND_TIMEOUT = 5000; static const size_t POLLING_COMMANDS_MAX = 15; void add_polling_command_(const char *command, ENUMPollingCommand polling_command); - void empty_uart_buffer_(); uint8_t check_incoming_crc_(); uint8_t check_incoming_length_(uint8_t length); uint16_t pipsolar_crc_(uint8_t *msg, uint8_t len); diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index 2f57c028439..9247cf37bf7 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -24,8 +24,8 @@ void PM1006Component::update() { } void PM1006Component::loop() { - while (this->available() != 0) { - this->read_byte(&this->data_[this->data_index_]); + this->read_available([this](uint8_t byte) { + this->data_[this->data_index_] = byte; auto check = this->check_byte_(); if (!check.has_value()) { // finished @@ -39,7 +39,7 @@ void PM1006Component::loop() { // next byte this->data_index_++; } - } + }); } uint8_t PM1006Component::pm1006_checksum_(const uint8_t *command_data, uint8_t length) const { diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index efc80e8c650..d7e7e0e22bc 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -83,8 +83,7 @@ void PMSX003Component::loop() { if (now - this->fan_on_time_ < STABILISING_MS) return; // consume any command responses that are in the serial buffer - while (this->available()) - this->read_byte(&this->data_[0]); + this->discard_available(); // Trigger a new read this->send_command_(Command::MANUAL_MEASUREMENT, 0); this->state_ = State::WAITING; @@ -100,27 +99,30 @@ void PMSX003Component::loop() { this->data_index_ = 0; } - if (this->available() == 0) + const size_t avail = this->available(); + if (avail == 0) return; this->last_transmission_ = now; - while (this->available() != 0) { - this->read_byte(&this->data_[this->data_index_]); - auto check = this->check_byte_(); - if (!check.has_value()) { - if (this->update_interval_ > STABILISING_MS || now - this->last_update_ >= this->update_interval_) { - this->parse_data_(); - this->last_update_ = now; - } - this->data_index_ = 0; - } else if (!*check) { - // wrong data - this->data_index_ = 0; - } else { - // next byte - this->data_index_++; - } - } + this->read_available( + [this, now](uint8_t byte) { + this->data_[this->data_index_] = byte; + auto check = this->check_byte_(); + if (!check.has_value()) { + if (this->update_interval_ > STABILISING_MS || now - this->last_update_ >= this->update_interval_) { + this->parse_data_(); + this->last_update_ = now; + } + this->data_index_ = 0; + } else if (!*check) { + // wrong data + this->data_index_ = 0; + } else { + // next byte + this->data_index_++; + } + }, + avail); } optional PMSX003Component::check_byte_() { diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 147365251b5..08486e55589 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -83,27 +83,30 @@ void SDS011Component::loop() { this->data_index_ = 0; } - if (this->available() == 0) { + const size_t avail = this->available(); + if (avail == 0) { return; } this->last_transmission_ = now; - while (this->available() != 0) { - this->read_byte(&this->data_[this->data_index_]); - auto check = this->check_byte_(); - if (!check.has_value()) { - // finished - this->parse_data_(); - this->data_index_ = 0; - } else if (!*check) { - // wrong data - ESP_LOGV(TAG, "Byte %i of received data frame is invalid.", this->data_index_); - this->data_index_ = 0; - } else { - // next byte - this->data_index_++; - } - } + this->read_available( + [this](uint8_t byte) { + this->data_[this->data_index_] = byte; + auto check = this->check_byte_(); + if (!check.has_value()) { + // finished + this->parse_data_(); + this->data_index_ = 0; + } else if (!*check) { + // wrong data + ESP_LOGV(TAG, "Byte %i of received data frame is invalid.", this->data_index_); + this->data_index_ = 0; + } else { + // next byte + this->data_index_++; + } + }, + avail); } void SDS011Component::sds011_write_command_(const uint8_t *command_data) { diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index ba07bc3d6d3..1c23de43abf 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -422,10 +422,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { void Sim800LComponent::loop() { // Read message - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - + this->read_available([this](uint8_t byte) { ESP_LOGVV(TAG, "Buffer pos: %u %d", this->line_reader_.length(), byte); // NOLINT if (byte >= 0x7F) @@ -441,7 +438,7 @@ void Sim800LComponent::loop() { } else { this->line_reader_.log_if_overflowed(TAG); } - } + }); if (state_ == STATE_INIT && this->registered_ && (this->call_state_ != 6 // A call is in progress || this->send_pending_ || this->dial_pending_ || this->connect_pending_ || this->disconnect_pending_)) { diff --git a/esphome/components/sml/sml.cpp b/esphome/components/sml/sml.cpp index a4bc4373d7c..6924e2fdd9c 100644 --- a/esphome/components/sml/sml.cpp +++ b/esphome/components/sml/sml.cpp @@ -24,9 +24,7 @@ char Sml::check_start_end_bytes_(uint8_t byte) { } void Sml::loop() { - while (available()) { - const char c = read(); - + this->read_available([this](uint8_t c) { if (this->record_) this->sml_data_.emplace_back(c); @@ -57,7 +55,7 @@ void Sml::loop() { break; }; }; - } + }); } void Sml::process_sml_file_(const BytesView &sml_data) { diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 8a8f82b8504..67b3f4faecf 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -11,32 +11,31 @@ void SMT100Component::update() { } void SMT100Component::loop() { - while (this->available() != 0) { - const int readch = this->read(); - if (readch <= 0) - continue; + this->read_available([this](uint8_t readch) { + if (readch == 0) + return; char *line = this->line_reader_.feed(readch); this->line_reader_.log_if_overflowed(TAG); if (line != nullptr && line[0] != '\0') { char *token = strtok(line, ","); if (!token) - continue; + return; int counts = (int) strtol(token, nullptr, 10); token = strtok(nullptr, ","); if (!token) - continue; + return; float permittivity = (float) strtod(token, nullptr); token = strtok(nullptr, ","); if (!token) - continue; + return; float moisture = (float) strtod(token, nullptr); token = strtok(nullptr, ","); if (!token) - continue; + return; float temperature = (float) strtod(token, nullptr); token = strtok(nullptr, ","); if (!token) - continue; + return; float voltage = (float) strtod(token, nullptr); if (this->counts_sensor_ != nullptr) { @@ -59,7 +58,7 @@ void SMT100Component::loop() { voltage_sensor_->publish_state(voltage); } } - } + }); } void SMT100Component::dump_config() { diff --git a/esphome/components/sun_gtil2/sun_gtil2.cpp b/esphome/components/sun_gtil2/sun_gtil2.cpp index f89797cc106..edaac470cb7 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.cpp +++ b/esphome/components/sun_gtil2/sun_gtil2.cpp @@ -39,11 +39,7 @@ static_assert(MESSAGE_SIZE == 350, "Expected the message size to be 350 bytes"); void SunGTIL2::setup() { this->rx_message_.reserve(MESSAGE_SIZE); } void SunGTIL2::loop() { - while (this->available()) { - uint8_t c; - this->read_byte(&c); - this->handle_char_(c); - } + this->read_available([this](uint8_t c) { this->handle_char_(c); }); } const char *SunGTIL2::state_to_string_(uint8_t state, std::span buffer) { diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp index 6c780d07a92..884ad986b3a 100644 --- a/esphome/components/systa_bus/systa_bus.cpp +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -13,9 +13,7 @@ void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } static bool checksum(std::span data) { return sum_bytes(data.data(), data.size()) == 0; } void SystaBus::loop() { - uint8_t c; - while (this->available() && this->read_byte(&c)) - this->handle_byte_(c); + this->read_available([this](uint8_t c) { this->handle_byte_(c); }); } void SystaBus::handle_byte_(uint8_t c) { diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp index 9bf9e726561..67158c82de1 100644 --- a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp @@ -14,10 +14,7 @@ void UARTBinarySensorHub::dump_config() { } void UARTBinarySensorHub::loop() { - while (this->available()) { - uint8_t byte; - if (!this->read_byte(&byte)) - break; + this->read_available([this](uint8_t byte) { this->buffer_.push_overwrite(byte); for (const auto &matcher : this->matchers_) { // The byte just read has to be the pattern's last one before the rest is compared @@ -30,7 +27,7 @@ void UARTBinarySensorHub::loop() { this->buffer_.clear(); break; } - } + }); } // Whether the pattern, apart from its last byte, ends the buffer diff --git a/esphome/components/uart/event/uart_event.cpp b/esphome/components/uart/event/uart_event.cpp index 22bcee9c83f..5cbd7ed7829 100644 --- a/esphome/components/uart/event/uart_event.cpp +++ b/esphome/components/uart/event/uart_event.cpp @@ -20,9 +20,7 @@ void UARTEvent::add_event_matcher(const char *event_name, const uint8_t *match_d } void UARTEvent::read_data_() { - while (this->available()) { - uint8_t data; - this->read_byte(&data); + this->read_available([this](uint8_t data) { this->buffer_.push_back(data); bool match_found = false; @@ -42,7 +40,7 @@ void UARTEvent::read_data_() { if (!match_found && this->max_matcher_len_ > 0 && this->buffer_.size() > this->max_matcher_len_) { this->buffer_.erase(this->buffer_.begin()); } - } + }); } } // namespace esphome::uart diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index fd6eda73a0c..8db3c148958 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -47,17 +47,15 @@ void UponorSmatrixComponent::loop() { } // Read incoming data - while (this->available()) { + this->read_available([this, now](uint8_t byte) { // The controller polls devices every 10 seconds in some units or continuously in others with around 200 ms between // devices. Remember timestamps so we can send our own packets when the bus is expected to be silent. this->last_rx_ = now; - uint8_t byte; - this->read_byte(&byte); if (this->parse_byte_(byte)) { this->rx_buffer_.clear(); } - } + }); // Send packets during bus silence if (this->rx_buffer_.empty() && (now - this->last_rx_ > 50) && (now - this->last_rx_ < 100) && diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index 6b6a3393ee4..c45ae6bdaa3 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -28,24 +28,18 @@ static bool checksum(const uint8_t *data, int start, int count) { } void VBus::loop() { - if (!available()) - return; - - while (available()) { - uint8_t c; - read_byte(&c); - + this->read_available([this](uint8_t c) { if (c == 0xaa) { this->state_ = 1; this->buffer_.clear(); - continue; + return; } if (c & 0x80) { this->state_ = 0; - continue; + return; } if (this->state_ == 0) - continue; + return; if (this->state_ == 1) { this->buffer_.push_back(c); @@ -59,7 +53,7 @@ void VBus::loop() { this->state_ = 0; if (!checksum(this->buffer_.data(), 0, 15)) { ESP_LOGE(TAG, "P2 checksum failed"); - continue; + return; } septet_spread(this->buffer_.data(), 7, 6, this->buffer_[13]); uint16_t id = (this->buffer_[8] << 8) + this->buffer_[7]; @@ -70,7 +64,7 @@ void VBus::loop() { if (!checksum(this->buffer_.data(), 0, 9)) { ESP_LOGE(TAG, "P1 checksum failed"); this->state_ = 0; - continue; + return; } this->frames_ = this->buffer_[7]; if (this->frames_) { @@ -86,23 +80,23 @@ void VBus::loop() { ESP_LOGW(TAG, "Unknown protocol 0x%02x, discarding", this->protocol_); this->state_ = 0; } - continue; + return; } if (this->state_ == 2) { this->fbytes_[this->fbcount_++] = c; if (this->fbcount_ < 6) - continue; + return; this->fbcount_ = 0; if (!checksum(this->fbytes_, 0, 6)) { ESP_LOGE(TAG, "frame checksum failed"); - continue; + return; } septet_spread(this->fbytes_, 0, 4, this->fbytes_[4]); for (int i = 0; i < 4; i++) this->buffer_.push_back(this->fbytes_[i]); if (++this->cframe_ < this->frames_) - continue; + return; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_size(VBUS_MAX_LOG_BYTES)]; size_t log_bytes = std::min(this->buffer_.size(), static_cast(VBUS_MAX_LOG_BYTES)); @@ -112,9 +106,9 @@ void VBus::loop() { for (auto &listener : this->listeners_) listener->on_message(this->command_, this->source_, this->dest_, this->buffer_); this->state_ = 0; - continue; + return; } - } + }); } void VBusListener::on_message(uint16_t command, uint16_t source, uint16_t dest, std::vector &message) { diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index b7b9e5cfa99..93eb1f3227e 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -12,13 +12,7 @@ constexpr uint8_t HEADER_4 = 0x04; ESPHOME_LOG_TAG(TAG, "wts01"); void WTS01Sensor::loop() { - // Process all available data at once - while (this->available()) { - uint8_t c; - if (this->read_byte(&c)) { - this->handle_char_(c); - } - } + this->read_available([this](uint8_t c) { this->handle_char_(c); }); } void WTS01Sensor::dump_config() { LOG_SENSOR("", "WTS01 Sensor", this); } From 03bf2de7df3eacf011bc8fce61d3ff10f470a333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 13:59:19 -1000 Subject: [PATCH 618/651] [components] Decode BLE fields with the shared helpers (#21082) --- .../airthings_ble/airthings_listener.cpp | 6 ++---- .../atc_mithermometer/atc_mithermometer.cpp | 5 +++-- esphome/components/b_parasite/b_parasite.cpp | 13 +++++++------ esphome/components/ble_device_base/ble_device.cpp | 15 ++++++--------- .../bluetooth_connection/bluetooth_connection.h | 7 +++---- .../bthome_mithermometer/bthome_ble.cpp | 4 ++-- .../esp32_ble_client/ble_client_base.cpp | 7 +++---- .../pvvx_mithermometer/pvvx_mithermometer.cpp | 7 ++++--- esphome/components/ruuvi_ble/ruuvi_ble.cpp | 15 ++++++++------- .../components/thermopro_ble/thermopro_ble.cpp | 8 ++++---- esphome/components/xiaomi_ble/xiaomi_ble.cpp | 8 ++++---- .../xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp | 2 +- .../components/xiaomi_miscale/xiaomi_miscale.cpp | 7 ++++--- 13 files changed, 51 insertions(+), 53 deletions(-) diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index 5f0e2eb3ecf..c0762cb4c83 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -1,4 +1,5 @@ #include "airthings_listener.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -12,10 +13,7 @@ bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) if (it.data.size() < 4) continue; - uint32_t sn = it.data[0]; - sn |= ((uint32_t) it.data[1] << 8); - sn |= ((uint32_t) it.data[2] << 16); - sn |= ((uint32_t) it.data[3] << 24); + uint32_t sn = encode_value_le(it.data.data()); char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD(TAG, "Found AirThings device Serial:%" PRIu32 " (MAC: %s)", sn, device.address_str_to(addr_buf)); diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 95b482c8404..a495cb9dd2e 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -1,4 +1,5 @@ #include "atc_mithermometer.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::atc_mithermometer { @@ -89,7 +90,7 @@ bool ATCMiThermometer::parse_message_(const std::vector &message, Parse } // temperature, 2 bytes, 16-bit signed integer (LE), 0.1 °C - const int16_t temperature = uint16_t(data[7]) | (uint16_t(data[6]) << 8); + const int16_t temperature = encode_value(&data[6]); result.temperature = temperature / 10.0f; // humidity, 1 byte, 8-bit unsigned integer, 1.0 % @@ -99,7 +100,7 @@ bool ATCMiThermometer::parse_message_(const std::vector &message, Parse result.battery_level = data[9]; // battery, 2 bytes, 16-bit unsigned integer, 0.001 V - const int16_t battery_voltage = uint16_t(data[11]) | (uint16_t(data[10]) << 8); + const int16_t battery_voltage = encode_value(&data[10]); result.battery_voltage = battery_voltage / 1.0e3f; return true; diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index 8bed59b3dbc..972d938c47b 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -1,4 +1,5 @@ #include "b_parasite.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::b_parasite { @@ -62,29 +63,29 @@ bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) { } // Battery voltage in millivolts. - uint16_t battery_millivolt = data[2] << 8 | data[3]; + uint16_t battery_millivolt = encode_value(&data[2]); float battery_voltage = battery_millivolt / 1000.0f; // Temperature in 1000 * Celsius (protocol v1) or 100 * Celsius (protocol v2). float temp_celsius; if (protocol_version == 1) { - uint16_t temp_millicelsius = data[4] << 8 | data[5]; + uint16_t temp_millicelsius = encode_value(&data[4]); temp_celsius = temp_millicelsius / 1000.0f; } else { - int16_t temp_centicelsius = data[4] << 8 | data[5]; + int16_t temp_centicelsius = encode_value(&data[4]); temp_celsius = temp_centicelsius / 100.0f; } // Relative air humidity in the range [0, 2^16). - uint16_t humidity = data[6] << 8 | data[7]; + uint16_t humidity = encode_value(&data[6]); float humidity_percent = (100.0f * humidity) / (1 << 16); // Relative soil moisture in [0 - 2^16). - uint16_t soil_moisture = data[8] << 8 | data[9]; + uint16_t soil_moisture = encode_value(&data[8]); float moisture_percent = (100.0f * soil_moisture) / (1 << 16); // Ambient light in lux. - float illuminance = has_illuminance ? data[16] << 8 | data[17] : 0.0f; + float illuminance = has_illuminance ? encode_value(&data[16]) : 0.0f; if (battery_voltage_ != nullptr) { battery_voltage_->publish_state(battery_voltage); diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index f7fa488aac7..1cb44c8ace7 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -493,13 +493,13 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x19: // Appearance if (ad_data_len >= 2) - this->appearance_ = static_cast(ad_data[0]) | (static_cast(ad_data[1]) << 8); + this->appearance_ = encode_value_le(ad_data); break; case 0x02: // Incomplete List of 16-bit Service UUIDs case 0x03: // Complete List of 16-bit Service UUIDs for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) { - uint16_t uuid = (static_cast(ad_data[i + 1]) << 8) | ad_data[i]; + uint16_t uuid = encode_value_le(&ad_data[i]); this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid)); } break; @@ -507,9 +507,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x04: // Incomplete List of 32-bit Service UUIDs case 0x05: // Complete List of 32-bit Service UUIDs for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) { - uint32_t uuid = (static_cast(ad_data[i + 3]) << 24) | - (static_cast(ad_data[i + 2]) << 16) | (static_cast(ad_data[i + 1]) << 8) | - ad_data[i]; + uint32_t uuid = encode_value_le(&ad_data[i]); this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid)); } break; @@ -522,7 +520,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0xFF: // Manufacturer Specific Data if (ad_data_len >= 2) { - uint16_t company_id = (static_cast(ad_data[1]) << 8) | ad_data[0]; + uint16_t company_id = encode_value_le(ad_data); ServiceData sd; sd.uuid = ESPBTUUID::from_uint16(company_id); sd.data.assign(ad_data + 2, ad_data + ad_data_len); @@ -532,7 +530,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x16: // Service Data — 16-bit UUID if (ad_data_len >= 2) { - uint16_t uuid = (static_cast(ad_data[1]) << 8) | ad_data[0]; + uint16_t uuid = encode_value_le(ad_data); ServiceData sd; sd.uuid = ESPBTUUID::from_uint16(uuid); sd.data.assign(ad_data + 2, ad_data + ad_data_len); @@ -542,8 +540,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x20: // Service Data — 32-bit UUID if (ad_data_len >= 4) { - uint32_t uuid = (static_cast(ad_data[3]) << 24) | (static_cast(ad_data[2]) << 16) | - (static_cast(ad_data[1]) << 8) | ad_data[0]; + uint32_t uuid = encode_value_le(ad_data); ServiceData sd; sd.uuid = ESPBTUUID::from_uint32(uuid); sd.data.assign(ad_data + 4, ad_data + ad_data_len); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index b21d997b4fc..176a73dc33d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -4,6 +4,7 @@ #pragma once #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/components/ble_device_base/ble_client_state.h" #include "esphome/components/ble_device_base/ble_device.h" @@ -124,10 +125,8 @@ inline void fill_128bit_uuid_array(std::array &out, const ble_devic if (uuid.type() == ESPBTUUID::Type::UUID128) { const uint8_t *u = uuid.uuid128(); // out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian) - out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) | - ((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]); - out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) | - ((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]); + out[0] = encode_value_le(&u[8]); + out[1] = encode_value_le(u); return; } // 16/32-bit UUID inserted into the Bluetooth base UUID: diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index c055c430413..b37d2782d56 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -408,7 +408,7 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat } case 0x02: { // temperature if (this->temperature_ != nullptr) { - const int16_t raw = encode_uint16(value[1], value[0]); + const int16_t raw = encode_value_le(value); this->temperature_->publish_state(raw * 0.01f); reported = true; } @@ -424,7 +424,7 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat } case 0x45: { // temperature (0.1C), e.g. Shelly BLU H&T if (this->temperature_ != nullptr) { - const int16_t raw = encode_uint16(value[1], value[0]); + const int16_t raw = encode_value_le(value); this->temperature_->publish_state(raw * 0.1f); reported = true; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 6905ef82228..e627d58fc81 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -680,18 +680,17 @@ float BLEClientBase::parse_char_value(uint8_t *value, uint16_t length) { case 0xD: // int12. case 0xE: // int16. if (length > 2) { - return (float) ((int16_t) (value[1] << 8) + (int16_t) value[2]); + return (float) encode_value(&value[1]); } [[fallthrough]]; case 0xF: // int24. if (length > 3) { - return (float) ((int32_t) (value[1] << 16) + (int32_t) (value[2] << 8) + (int32_t) (value[3])); + return (float) encode_uint24(value[1], value[2], value[3]); } [[fallthrough]]; case 0x10: // int32. if (length > 4) { - return (float) ((int32_t) (value[1] << 24) + (int32_t) (value[2] << 16) + (int32_t) (value[3] << 8) + - (int32_t) (value[4])); + return (float) encode_value(&value[1]); } } ESP_LOGW(TAG, "[%d] [%s] Cannot parse characteristic value of type 0x%x length %d", this->connection_index_, diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index 0d7f440f15e..462d64f937d 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -1,4 +1,5 @@ #include "pvvx_mithermometer.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pvvx_mithermometer { @@ -96,15 +97,15 @@ bool PVVXMiThermometer::parse_message_(const std::vector &message, Pars } // int16_t temperature; // x 0.01 degree [6,7] - const int16_t temperature = int16_t(data[6]) | (int16_t(data[7]) << 8); + const int16_t temperature = encode_value_le(&data[6]); result.temperature = temperature / 1.0e2f; // uint16_t humidity; // x 0.01 % [8,9] - const int16_t humidity = uint16_t(data[8]) | (uint16_t(data[9]) << 8); + const int16_t humidity = encode_value_le(&data[8]); result.humidity = humidity / 1.0e2f; // uint16_t battery_mv; // mV [10,11] - const int16_t battery_voltage = uint16_t(data[10]) | (uint16_t(data[11]) << 8); + const int16_t battery_voltage = encode_value_le(&data[10]); result.battery_voltage = battery_voltage / 1.0e3f; // uint8_t battery_level; // 0..100 % [12] diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index c781e282fe7..415885cfe2b 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -1,4 +1,5 @@ #include "ruuvi_ble.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::ruuvi_ble { @@ -19,9 +20,9 @@ bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviPar const float humidity = data[0] * 0.5f; const float pressure = (encode_uint16(data[3], data[4]) + 50000.0f) / 100.0f; - const float acceleration_x = static_cast(encode_uint16(data[5], data[6])) / 1000.0f; - const float acceleration_y = static_cast(encode_uint16(data[7], data[8])) / 1000.0f; - const float acceleration_z = static_cast(encode_uint16(data[9], data[10])) / 1000.0f; + const float acceleration_x = encode_value(&data[5]) / 1000.0f; + const float acceleration_y = encode_value(&data[7]) / 1000.0f; + const float acceleration_z = encode_value(&data[9]) / 1000.0f; const float battery_voltage = encode_uint16(data[11], data[12]) / 1000.0f; result.humidity = humidity; @@ -40,12 +41,12 @@ bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviPar if (adv_data.size() != 24) return false; - const float temperature = static_cast(encode_uint16(data[0], data[1])) * 0.005f; + const float temperature = encode_value(data) * 0.005f; const float humidity = encode_uint16(data[2], data[3]) / 400.0f; const float pressure = (encode_uint16(data[4], data[5]) + 50000.0f) / 100.0f; - const float acceleration_x = static_cast(encode_uint16(data[6], data[7])) / 1000.0f; - const float acceleration_y = static_cast(encode_uint16(data[8], data[9])) / 1000.0f; - const float acceleration_z = static_cast(encode_uint16(data[10], data[11])) / 1000.0f; + const float acceleration_x = encode_value(&data[6]) / 1000.0f; + const float acceleration_y = encode_value(&data[8]) / 1000.0f; + const float acceleration_z = encode_value(&data[10]) / 1000.0f; const uint16_t power_info = encode_uint16(data[12], data[13]); const float battery_voltage = ((power_info >> 5) + 1600.0f) / 1000.0f; diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 38a58137aec..159e36e5554 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -1,5 +1,6 @@ #include "thermopro_ble.h" #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::thermopro_ble { @@ -113,16 +114,15 @@ void ThermoProBLE::update_device_type_(StringRef device_name) { } static inline uint16_t read_uint16(const uint8_t *data, std::size_t offset) { - return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8); + return encode_value_le(&data[offset]); } static inline int16_t read_int16(const uint8_t *data, std::size_t offset) { - return static_cast(read_uint16(data, offset)); + return encode_value_le(&data[offset]); } static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { - return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8) | - (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24); + return encode_value_le(&data[offset]); } // Battery calculation used with permission from: diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 90ea5c32094..1f752f55b10 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -38,12 +38,12 @@ bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_ } // temperature, 2 bytes, 16-bit signed integer (LE), 0.1 °C else if ((value_type == 0x1004) && (value_length == 2)) { - const int16_t temperature = encode_uint16(data[1], data[0]); + const int16_t temperature = encode_value_le(data); result.temperature = temperature / 10.0f; } // humidity, 2 bytes, 16-bit signed integer (LE), 0.1 % else if ((value_type == 0x1006) && (value_length == 2)) { - const int16_t humidity = encode_uint16(data[1], data[0]); + const int16_t humidity = encode_value_le(data); result.humidity = humidity / 10.0f; } // illuminance (+ motion), 3 bytes, 24-bit unsigned integer (LE), 1 lx @@ -69,8 +69,8 @@ bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_ } // temperature + humidity, 4 bytes, 16-bit signed integer (LE) each, 0.1 °C, 0.1 % else if ((value_type == 0x100D) && (value_length == 4)) { - const int16_t temperature = encode_uint16(data[1], data[0]); - const int16_t humidity = encode_uint16(data[3], data[2]); + const int16_t temperature = encode_value_le(data); + const int16_t humidity = encode_value_le(&data[2]); result.temperature = temperature / 10.0f; result.humidity = humidity / 10.0f; } diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index ac1541c386d..83ba611e6fb 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -35,7 +35,7 @@ bool XiaomiHHCCJCY10::parse_device(const ble_device_base::ESPBTDevice &device) { const uint8_t *data = service_data.data.data(); if (this->temperature_ != nullptr) { - const int16_t temperature = encode_uint16(data[1], data[2]); + const int16_t temperature = encode_value(&data[1]); this->temperature_->publish_state((float) temperature / 10.0f); } diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index 438b9b55298..7c44eccd411 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -1,5 +1,6 @@ #include "xiaomi_miscale.h" #include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::xiaomi_miscale { @@ -92,7 +93,7 @@ bool XiaomiMiscale::parse_message_v1_(const std::vector &message, Parse const uint8_t *data = message.data(); // weight, 2 bytes, 16-bit unsigned integer, 1 kg - const int16_t weight = uint16_t(data[1]) | (uint16_t(data[2]) << 8); + const int16_t weight = encode_value_le(&data[1]); if (data[0] == 0x22 || data[0] == 0xa2) { result.weight = weight * 0.01f / 2.0f; // unit 'kg' } else if (data[0] == 0x12 || data[0] == 0xb2) { @@ -126,7 +127,7 @@ bool XiaomiMiscale::parse_message_v2_(const std::vector &message, Parse } // weight, 2 bytes, 16-bit unsigned integer, 1 kg - const int16_t weight = uint16_t(data[11]) | (uint16_t(data[12]) << 8); + const int16_t weight = encode_value_le(&data[11]); if (data[0] == 0x02) { result.weight = weight * 0.01f / 2.0f; // unit 'kg' } else if (data[0] == 0x03) { @@ -135,7 +136,7 @@ bool XiaomiMiscale::parse_message_v2_(const std::vector &message, Parse if (has_impedance) { // impedance, 2 bytes, 16-bit - const int16_t impedance = uint16_t(data[9]) | (uint16_t(data[10]) << 8); + const int16_t impedance = encode_value_le(&data[9]); result.impedance = impedance; if (impedance == 0 || impedance >= 3000) { From d9242520e3e421144f4dec716959f6501c8ce0ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:00:48 -1000 Subject: [PATCH 619/651] [components] Decode bytes with the shared helpers (#21080) --- esphome/components/am2320/am2320.cpp | 7 +--- esphome/components/apds9960/apds9960.cpp | 12 +++--- esphome/components/bh1900nux/bh1900nux.cpp | 3 +- esphome/components/bm8563/bm8563.cpp | 32 +++++---------- esphome/components/bm8563/bm8563.h | 3 -- .../components/bme280_base/bme280_base.cpp | 4 +- esphome/components/bme680/bme680.cpp | 19 +++++---- esphome/components/bmi270/bmi270.cpp | 14 +++---- .../components/bmp280_base/bmp280_base.cpp | 5 ++- .../components/dallas_temp/dallas_temp.cpp | 3 +- esphome/components/dps310/dps310.cpp | 41 ++++++------------- esphome/components/dps310/dps310.h | 1 - esphome/components/ds3231/ds3231.cpp | 3 +- .../ecocomfort2/ecocomfort2_hub.cpp | 2 +- esphome/components/epaper_spi/epaper_spi.cpp | 2 +- esphome/components/gcja5/gcja5.cpp | 5 +-- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 6 +-- esphome/components/ina226/ina226.cpp | 14 ++----- esphome/components/ina226/ina226.h | 2 - .../components/ina2xx_base/ina2xx_base.cpp | 16 ++++---- esphome/components/it8951/it8951.cpp | 3 +- esphome/components/ld2450/ld2450.cpp | 19 +++------ esphome/components/ld2460/ld2460.cpp | 5 ++- esphome/components/ld6002b/ld6002b.cpp | 40 ++++++------------ esphome/components/ld6002b/ld6002b.h | 3 -- esphome/components/lm75b/lm75b.cpp | 3 +- esphome/components/max31888/max31888.cpp | 2 +- esphome/components/mpl3115a2/mpl3115a2.cpp | 2 +- esphome/components/nau7802/nau7802.cpp | 7 +--- esphome/components/pcf8574/pcf8574.cpp | 3 +- esphome/components/pmwcs3/pmwcs3.cpp | 20 ++++----- .../remote_base/abbwelcome_protocol.h | 8 ++-- esphome/components/rf_bridge/rf_bridge.cpp | 8 ++-- .../seeed_mr60bha2/seeed_mr60bha2.cpp | 21 +--------- esphome/components/senseair/senseair.cpp | 2 +- .../systa_bus/sensor/systa_bus_sensor.cpp | 2 +- .../components/vbus/sensor/vbus_sensor.cpp | 4 +- .../waveshare_io_ch32v003.cpp | 5 +-- esphome/components/whirlpool/whirlpool.cpp | 15 +++---- 39 files changed, 136 insertions(+), 230 deletions(-) diff --git a/esphome/components/am2320/am2320.cpp b/esphome/components/am2320/am2320.cpp index 1b3761fdac5..eccab7acfb5 100644 --- a/esphome/components/am2320/am2320.cpp +++ b/esphome/components/am2320/am2320.cpp @@ -69,12 +69,7 @@ bool AM2320Component::read_data_(uint8_t *data) { return false; } - uint16_t checksum; - - checksum = data[7] << 8; - checksum += data[6]; - - if (crc16(data, 6) != checksum) { + if (crc16(data, 6) != encode_value_le(&data[6])) { ESP_LOGW(TAG, "AM2320 Checksum invalid!"); return false; } diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index c003e7a8bc9..dc7f8c8bffd 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -162,13 +162,13 @@ void APDS9960::read_color_data_(uint8_t status) { return; } - uint8_t raw[8]; - APDS9960_WARNING_CHECK(this->read_bytes(0x94, raw, 8), "Reading color values failed."); + uint16_t raw[4]; + APDS9960_WARNING_CHECK(this->read_bytes_16_le(0x94, raw, 4), "Reading color values failed."); - uint16_t uint_clear = (uint16_t(raw[1]) << 8) | raw[0]; - uint16_t uint_red = (uint16_t(raw[3]) << 8) | raw[2]; - uint16_t uint_green = (uint16_t(raw[5]) << 8) | raw[4]; - uint16_t uint_blue = (uint16_t(raw[7]) << 8) | raw[6]; + uint16_t uint_clear = raw[0]; + uint16_t uint_red = raw[1]; + uint16_t uint_green = raw[2]; + uint16_t uint_blue = raw[3]; float clear_perc = (uint_clear / float(UINT16_MAX)) * 100.0f; float red_perc = (uint_red / float(UINT16_MAX)) * 100.0f; diff --git a/esphome/components/bh1900nux/bh1900nux.cpp b/esphome/components/bh1900nux/bh1900nux.cpp index 6b7f1a35863..dc1b930675c 100644 --- a/esphome/components/bh1900nux/bh1900nux.cpp +++ b/esphome/components/bh1900nux/bh1900nux.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "bh1900nux.h" @@ -36,7 +37,7 @@ void BH1900NUXSensor::update() { // Combined raw value, unsigned and unaligned 16 bit // Temperature is represented in just 12 bits, shift needed - int16_t raw_temperature_register_value = encode_uint16(temperature_raw[0], temperature_raw[1]); + int16_t raw_temperature_register_value = encode_value(temperature_raw); raw_temperature_register_value >>= 4; float temperature_value = raw_temperature_register_value * SENSOR_RESOLUTION; // Apply sensor resolution diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index f83b98af115..9e8db2dd6ec 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -2,6 +2,7 @@ #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bm8563 { @@ -67,28 +68,17 @@ void BM8563::read_time() { time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); } -uint8_t BM8563::bcd2_to_byte_(uint8_t value) { - const uint8_t tmp = ((value & 0xF0) >> 0x4) * 10; - return tmp + (value & 0x0F); -} - -uint8_t BM8563::byte_to_bcd2_(uint8_t value) { - const uint8_t bcdhigh = value / 10; - value -= bcdhigh * 10; - return (bcdhigh << 4) | value; -} - void BM8563::get_time_(ESPTime &time) { uint8_t buf[3] = {0}; this->read_register(TIME_FIRST_REG, buf, 3); - time.second = this->bcd2_to_byte_(buf[0] & 0x7f); - time.minute = this->bcd2_to_byte_(buf[1] & 0x7f); - time.hour = this->bcd2_to_byte_(buf[2] & 0x3f); + time.second = bcd_to_uint8(buf[0] & 0x7f); + time.minute = bcd_to_uint8(buf[1] & 0x7f); + time.hour = bcd_to_uint8(buf[2] & 0x3f); } void BM8563::set_time_(const ESPTime &time) { - uint8_t buf[3] = {this->byte_to_bcd2_(time.second), this->byte_to_bcd2_(time.minute), this->byte_to_bcd2_(time.hour)}; + uint8_t buf[3] = {uint8_to_bcd(time.second), uint8_to_bcd(time.minute), uint8_to_bcd(time.hour)}; this->write_register_(TIME_FIRST_REG, buf, 3); } @@ -96,12 +86,12 @@ void BM8563::get_date_(ESPTime &time) { uint8_t buf[4] = {0}; this->read_register(DATE_FIRST_REG, buf, sizeof(buf)); - time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f); + time.day_of_month = bcd_to_uint8(buf[0] & 0x3f); // The chip counts weekdays 0-6 from Sunday (not BCD), ESPTime counts 1-7 time.day_of_week = (buf[1] & 0x07) + 1; - time.month = this->bcd2_to_byte_(buf[2] & 0x1f); + time.month = bcd_to_uint8(buf[2] & 0x1f); - uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff); + uint8_t year_byte = bcd_to_uint8(buf[3] & 0xff); if (buf[2] & 0x80) { time.year = 1900 + year_byte; @@ -112,10 +102,10 @@ void BM8563::get_date_(ESPTime &time) { void BM8563::set_date_(const ESPTime &time) { uint8_t buf[4] = { - this->byte_to_bcd2_(time.day_of_month), + uint8_to_bcd(time.day_of_month), static_cast(time.day_of_week - 1), - this->byte_to_bcd2_(time.month), - this->byte_to_bcd2_(time.year % 100), + uint8_to_bcd(time.month), + uint8_to_bcd(time.year % 100), }; if (time.year < 2000) { diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index b9b7a2c3276..db6d6c5082a 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -29,9 +29,6 @@ class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { void write_byte_(uint8_t reg, uint8_t value); void write_register_(uint8_t reg, const uint8_t *data, size_t len); optional read_register_(uint8_t reg); - - uint8_t bcd2_to_byte_(uint8_t value); - uint8_t byte_to_bcd2_(uint8_t value); }; } // namespace esphome::bm8563 diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index f922b1942b4..00e6f9edd25 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -236,7 +236,7 @@ void BME280Component::update() { }); } float BME280Component::read_temperature_(const uint8_t *data, int32_t *t_fine) { - int32_t adc = ((data[3] & 0xFF) << 16) | ((data[4] & 0xFF) << 8) | (data[5] & 0xFF); + int32_t adc = encode_uint24(data[3], data[4], data[5]); adc >>= 4; if (adc == 0x80000) { // temperature was disabled @@ -256,7 +256,7 @@ float BME280Component::read_temperature_(const uint8_t *data, int32_t *t_fine) { } float BME280Component::read_pressure_(const uint8_t *data, int32_t t_fine) { - int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); + int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; if (adc == 0x80000) { // pressure was disabled diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 3bc1f3ec1e2..e3297d38f55 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -1,5 +1,6 @@ #include "bme680.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bme680 { @@ -92,8 +93,8 @@ bool BME680Component::setup_registers_() { return false; } - this->calibration_.t1 = coeff2[9] << 8 | coeff2[8]; - this->calibration_.t2 = coeff1[2] << 8 | coeff1[1]; + this->calibration_.t1 = encode_value_le(&coeff2[8]); + this->calibration_.t2 = encode_value_le(&coeff1[1]); this->calibration_.t3 = coeff1[3]; this->calibration_.h1 = coeff2[2] << 4 | (coeff2[1] & 0x0F); @@ -104,19 +105,19 @@ bool BME680Component::setup_registers_() { this->calibration_.h6 = coeff2[6]; this->calibration_.h7 = coeff2[7]; - this->calibration_.p1 = coeff1[6] << 8 | coeff1[5]; - this->calibration_.p2 = coeff1[8] << 8 | coeff1[7]; + this->calibration_.p1 = encode_value_le(&coeff1[5]); + this->calibration_.p2 = encode_value_le(&coeff1[7]); this->calibration_.p3 = coeff1[9]; - this->calibration_.p4 = coeff1[12] << 8 | coeff1[11]; - this->calibration_.p5 = coeff1[14] << 8 | coeff1[13]; + this->calibration_.p4 = encode_value_le(&coeff1[11]); + this->calibration_.p5 = encode_value_le(&coeff1[13]); this->calibration_.p6 = coeff1[16]; this->calibration_.p7 = coeff1[15]; - this->calibration_.p8 = coeff1[20] << 8 | coeff1[19]; - this->calibration_.p9 = coeff1[22] << 8 | coeff1[21]; + this->calibration_.p8 = encode_value_le(&coeff1[19]); + this->calibration_.p9 = encode_value_le(&coeff1[21]); this->calibration_.p10 = coeff1[23]; this->calibration_.gh1 = coeff2[14]; - this->calibration_.gh2 = coeff2[12] << 8 | coeff2[13]; + this->calibration_.gh2 = encode_value(&coeff2[12]); this->calibration_.gh3 = coeff2[15]; uint8_t temp_var = 0; diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index 0e010b03118..cc233f4978b 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -314,9 +314,9 @@ bool BMI270Component::update_data(motion::MotionData &data) { }; float scale = ACCEL_SCALE[this->accel_range_]; - data.acceleration[motion::X_AXIS] = (int16_t) ((raw_data[1] << 8) | raw_data[0]) * scale; - data.acceleration[motion::Y_AXIS] = (int16_t) ((raw_data[3] << 8) | raw_data[2]) * scale; - data.acceleration[motion::Z_AXIS] = (int16_t) ((raw_data[5] << 8) | raw_data[4]) * scale; + data.acceleration[motion::X_AXIS] = encode_value_le(raw_data) * scale; + data.acceleration[motion::Y_AXIS] = encode_value_le(&raw_data[2]) * scale; + data.acceleration[motion::Z_AXIS] = encode_value_le(&raw_data[4]) * scale; // Gyroscope: registers 0x12–0x17 (6 bytes) // Scale: full-scale range / 2^15 @@ -326,15 +326,15 @@ bool BMI270Component::update_data(motion::MotionData &data) { static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; scale = GYRO_SCALE[this->gyro_range_]; - data.angular_rate[motion::X_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 1] << 8) | raw_data[GYR_OFFS + 0]) * scale; - 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; + data.angular_rate[motion::X_AXIS] = encode_value_le(&raw_data[GYR_OFFS]) * scale; + data.angular_rate[motion::Y_AXIS] = encode_value_le(&raw_data[GYR_OFFS + 2]) * scale; + data.angular_rate[motion::Z_AXIS] = encode_value_le(&raw_data[GYR_OFFS + 4]) * scale; 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]); + int16_t raw_t = encode_value_le(&raw_data[TEMP_OFFS]); float temperature = (raw_t / 512.0f) + 23.0f; this->temperature_callback_.call(temperature); } diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 10be0585210..69c4ca5e931 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -1,5 +1,6 @@ #include "bmp280_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bmp280_base { @@ -176,7 +177,7 @@ float BMP280Component::read_temperature_(int32_t *t_fine) { if (!this->bmp_read_bytes(BMP280_REGISTER_TEMPDATA, data, 3)) return NAN; ESP_LOGV(TAG, "Read temperature data, raw: %02X %02X %02X", data[0], data[1], data[2]); - int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); + int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; if (adc == 0x80000) { // temperature was disabled @@ -199,7 +200,7 @@ float BMP280Component::read_pressure_(int32_t t_fine) { uint8_t data[3]; if (!this->bmp_read_bytes(BMP280_REGISTER_PRESSUREDATA, data, 3)) return NAN; - int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); + int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; if (adc == 0x80000) { // pressure was disabled diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 45101e4981f..4514624c75c 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -1,4 +1,5 @@ #include "dallas_temp.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::dallas_temp { @@ -136,7 +137,7 @@ bool DallasTemperatureSensor::check_scratch_pad_() { } float DallasTemperatureSensor::get_temp_c_() { - int16_t temp = (this->scratch_pad_[1] << 8) | this->scratch_pad_[0]; + int16_t temp = encode_value_le(this->scratch_pad_); if ((this->address_ & 0xff) == DALLAS_MODEL_DS18S20) { if (this->scratch_pad_[7] == 0) { return NAN; diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 5b3272b1d5e..9241a825549 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -58,26 +58,16 @@ bool DPS310Component::setup_registers_() { return false; } - this->c0_ = // we only ever use c0/2, so just divide by 2 here to save time later - DPS310Component::twos_complement( - int16_t(((uint16_t) coef_data_raw[0] << 4) | (((uint16_t) coef_data_raw[1] >> 4) & 0x0F)), 12) / - 2; - - this->c1_ = - DPS310Component::twos_complement(int16_t((((uint16_t) coef_data_raw[1] & 0x0F) << 8) | coef_data_raw[2]), 12); - - this->c00_ = ((uint32_t) coef_data_raw[3] << 12) | ((uint32_t) coef_data_raw[4] << 4) | - (((uint32_t) coef_data_raw[5] >> 4) & 0x0F); - this->c00_ = DPS310Component::twos_complement(c00_, 20); - - this->c10_ = encode_uint24(coef_data_raw[5] & 0x0F, coef_data_raw[6], coef_data_raw[7]); - this->c10_ = DPS310Component::twos_complement(c10_, 20); - - this->c01_ = int16_t(((uint16_t) coef_data_raw[8] << 8) | (uint16_t) coef_data_raw[9]); - this->c11_ = int16_t(((uint16_t) coef_data_raw[10] << 8) | (uint16_t) coef_data_raw[11]); - this->c20_ = int16_t(((uint16_t) coef_data_raw[12] << 8) | (uint16_t) coef_data_raw[13]); - this->c21_ = int16_t(((uint16_t) coef_data_raw[14] << 8) | (uint16_t) coef_data_raw[15]); - this->c30_ = int16_t(((uint16_t) coef_data_raw[16] << 8) | (uint16_t) coef_data_raw[17]); + // we only ever use c0/2, so just divide by 2 here to save time later + this->c0_ = sign_extend<12>(encode_uint16(coef_data_raw[0], coef_data_raw[1]) >> 4) / 2; + this->c1_ = sign_extend<12>(encode_uint16(coef_data_raw[1], coef_data_raw[2])); + this->c00_ = sign_extend<20>(encode_uint24(coef_data_raw[3], coef_data_raw[4], coef_data_raw[5]) >> 4); + this->c10_ = sign_extend<20>(encode_uint24(coef_data_raw[5], coef_data_raw[6], coef_data_raw[7])); + this->c01_ = encode_value(&coef_data_raw[8]); + this->c11_ = encode_value(&coef_data_raw[10]); + this->c20_ = encode_value(&coef_data_raw[12]); + this->c21_ = encode_value(&coef_data_raw[14]); + this->c30_ = encode_value(&coef_data_raw[16]); return true; } @@ -133,7 +123,7 @@ void DPS310Component::read_pressure_() { return; } this->got_pres_ = true; - this->raw_pressure_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); + this->raw_pressure_ = sign_extend<24>(encode_uint24(bytes[0], bytes[1], bytes[2])); } void DPS310Component::read_temperature_() { @@ -143,7 +133,7 @@ void DPS310Component::read_temperature_() { return; } this->got_temp_ = true; - this->raw_temperature_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); + this->raw_temperature_ = sign_extend<24>(encode_uint24(bytes[0], bytes[1], bytes[2])); } // Calculations are taken from the datasheet which can be found here: @@ -168,11 +158,4 @@ void DPS310Component::calculate_values_(int32_t raw_temperature, int32_t raw_pre } } -int32_t DPS310Component::twos_complement(int32_t val, uint8_t bits) { - if (val & ((uint32_t) 1 << (bits - 1))) { - val -= (uint32_t) 1 << bits; - } - return val; -} - } // namespace esphome::dps310 diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index d761940ca99..a05a5ec056c 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -51,7 +51,6 @@ class DPS310Component final : public PollingComponent, public i2c::I2CDevice { void read_pressure_(); void read_temperature_(); void calculate_values_(int32_t raw_temperature, int32_t raw_pressure); - static int32_t twos_complement(int32_t val, uint8_t bits); sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp index 72cae0e3832..1033ddfb454 100644 --- a/esphome/components/ds3231/ds3231.cpp +++ b/esphome/components/ds3231/ds3231.cpp @@ -1,4 +1,5 @@ #include "ds3231.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" // Datasheet: @@ -37,7 +38,7 @@ bool DS3231Component::read_temperature(float &out) { return false; } // 10-bit two's complement, 0.25 degC per LSB. - int16_t value = static_cast((raw[0] << 8) | raw[1]) >> 6; + int16_t value = encode_value(raw) >> 6; out = value * 0.25f; return true; } diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index 223648e3276..dcabf6c75c5 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -235,7 +235,7 @@ void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { 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 temperature = encode_value(&data[3]) / 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: mode=0x%02x, speed=0x%02x, %.2f °C, %.2f %%, %u ppb, direction=%u", diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index fccb5325a7f..488a068b0ff 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -397,7 +397,7 @@ void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_widt const Rgb565ToMono &to_mono) { const ptrdiff_t byte_step = SwapAxes ? step * static_cast(row_width) : 0; for (; count != 0; count--, source += 2) { - const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8); + const uint16_t value = BigEndianSource ? encode_uint16(source[0], source[1]) : encode_value_le(source); if (to_mono(value)) { *byte |= mask; } else { diff --git a/esphome/components/gcja5/gcja5.cpp b/esphome/components/gcja5/gcja5.cpp index b908fcd9e15..3a00af069ea 100644 --- a/esphome/components/gcja5/gcja5.cpp +++ b/esphome/components/gcja5/gcja5.cpp @@ -66,10 +66,7 @@ void GCJA5Component::loop() { } bool GCJA5Component::calculate_checksum_() { - uint8_t crc = 0; - - for (uint8_t i = 1; i < 30; i++) - crc = crc ^ this->rx_message_[i]; + uint8_t crc = xor_bytes(&this->rx_message_[1], 29); ESP_LOGVV(TAG, "Checksum packet was (0x%02X), calculated checksum was (0x%02X)", this->rx_message_[30], crc); diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 43311f3d968..caba0be9792 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -192,7 +192,7 @@ void HlkFm22xComponent::handle_note_(const uint8_t *data, size_t length) { int16_t info[8]; uint8_t offset = 1; for (int16_t &i : info) { - i = ((int16_t) data[offset + 1] << 8) | data[offset]; + i = encode_value_le(&data[offset]); offset += 2; } ESP_LOGV(TAG, "Face state: status: %d, left: %d, top: %d, right: %d, bottom: %d, yaw: %d, pitch: %d, roll: %d", @@ -259,7 +259,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { ESP_LOGE(TAG, "VERIFY response too short: %zu bytes", length); break; } - int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + int16_t face_id = encode_value(&data[2]); const char *name_ptr = reinterpret_cast(data + 4); ESP_LOGD(TAG, "Face verified. ID: %d, name: %.*s", face_id, (int) HLK_FM22X_NAME_SIZE, name_ptr); if (this->last_face_id_sensor_ != nullptr) { @@ -272,7 +272,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { break; } case HlkFm22xCommand::ENROLL: { - int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + int16_t face_id = encode_value(&data[2]); HlkFm22xFaceDirection direction = (HlkFm22xFaceDirection) data[4]; ESP_LOGI(TAG, "Face enrolled. ID: %d, Direction: 0x%.2X", face_id, direction); this->enrollment_done_callback_.call(face_id, (uint8_t) direction); diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index 987ce25d60b..2548f6312e7 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -1,4 +1,5 @@ #include "ina226.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include @@ -109,7 +110,7 @@ void INA226Component::update() { return; } // Convert for 2's compliment and signed value (though always positive) - float bus_voltage_v = this->twos_complement_(raw_bus_voltage, 16); + float bus_voltage_v = sign_extend<16>(raw_bus_voltage); bus_voltage_v *= 0.00125f; this->bus_voltage_sensor_->publish_state(bus_voltage_v); } @@ -121,7 +122,7 @@ void INA226Component::update() { return; } // Convert for 2's compliment and signed value - float shunt_voltage_v = this->twos_complement_(raw_shunt_voltage, 16); + float shunt_voltage_v = sign_extend<16>(raw_shunt_voltage); shunt_voltage_v *= 0.0000025f; this->shunt_voltage_sensor_->publish_state(shunt_voltage_v); } @@ -133,7 +134,7 @@ void INA226Component::update() { return; } // Convert for 2's compliment and signed value - float current_ma = this->twos_complement_(raw_current, 16); + float current_ma = sign_extend<16>(raw_current); current_ma *= (this->calibration_lsb_ / 1000.0f); this->current_sensor_->publish_state(current_ma / 1000.0f); } @@ -151,11 +152,4 @@ void INA226Component::update() { this->status_clear_warning(); } -int32_t INA226Component::twos_complement_(int32_t val, uint8_t bits) { - if (val & ((uint32_t) 1 << (bits - 1))) { - val -= (uint32_t) 1 << bits; - } - return val; -} - } // namespace esphome::ina226 diff --git a/esphome/components/ina226/ina226.h b/esphome/components/ina226/ina226.h index 00d62fad760..6fc7c20dd73 100644 --- a/esphome/components/ina226/ina226.h +++ b/esphome/components/ina226/ina226.h @@ -68,8 +68,6 @@ class INA226Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *shunt_voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; - - int32_t twos_complement_(int32_t val, uint8_t bits); }; } // namespace esphome::ina226 diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 4c36870b680..16b37568d91 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -385,10 +385,10 @@ bool INA2XX::read_shunt_voltage_mv_(float &volt_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_VSHUNT, 3, raw); raw >>= 4; - volt_reading = this->two_complement_(raw, 20); + volt_reading = sign_extend<20>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_VSHUNT, 2, raw); - volt_reading = this->two_complement_(raw, 16); + volt_reading = sign_extend<16>(raw); } if (ret) { @@ -412,10 +412,10 @@ bool INA2XX::read_bus_voltage_(float &volt_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_VBUS, 3, raw); raw >>= 4; - volt_reading = this->two_complement_(raw, 20); + volt_reading = sign_extend<20>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_VBUS, 2, raw); - volt_reading = this->two_complement_(raw, 16); + volt_reading = sign_extend<16>(raw); } if (ret) { volt_out = this->cfg_.vbus_lsb * (float) volt_reading; @@ -436,11 +436,11 @@ bool INA2XX::read_die_temp_c_(float &temp_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_DIETEMP, 2, raw); - temp_reading = this->two_complement_(raw, 16); + temp_reading = sign_extend<16>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_DIETEMP, 2, raw); raw >>= 4; - temp_reading = this->two_complement_(raw, 12); + temp_reading = sign_extend<12>(raw); } if (ret) { temp_out = this->cfg_.die_temp_lsb * (float) temp_reading; @@ -461,10 +461,10 @@ bool INA2XX::read_current_a_(float &s_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_CURRENT, 3, raw); raw >>= 4; - amps_reading = this->two_complement_(raw, 20); + amps_reading = sign_extend<20>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_CURRENT, 2, raw); - amps_reading = this->two_complement_(raw, 16); + amps_reading = sign_extend<16>(raw); } ESP_LOGV(TAG, "read_current_a_ ret=%s. current_lsb=%f. reading_lsb=%f", OKFAILED(ret), this->current_lsb_, diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index e0d6f683f0d..276f91eed59 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -982,8 +982,7 @@ void HOT IT8951Display::draw_pixels_internal(int x_start, int y_start, int w, in switch (bitness) { case COLOR_BITNESS_565: { const size_t i = source_idx * 2; - color_value = big_endian ? (static_cast(ptr[i]) << 8) | ptr[i + 1] - : ptr[i] | (static_cast(ptr[i + 1]) << 8); + color_value = big_endian ? encode_uint16(ptr[i], ptr[i + 1]) : encode_value_le(&ptr[i]); break; } case COLOR_BITNESS_888: { diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 52327d309e3..e14be5ac968 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -164,15 +164,6 @@ static inline int16_t decode_speed(uint8_t low_byte, uint8_t high_byte) { return speed * 10; // mm/s } -static inline int16_t hex_to_signed_int(const uint8_t *buffer, uint8_t offset) { - uint16_t hex_val = (buffer[offset + 1] << 8) | buffer[offset]; - int16_t dec_val = static_cast(hex_val); - if (dec_val & 0x8000) { - dec_val -= 65536; - } - return dec_val; -} - 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; } @@ -389,10 +380,10 @@ void LD2450Component::process_zone_() { uint8_t index, start; for (index = 0; index < MAX_ZONES; index++) { start = 12 + index * 8; - this->zone_config_[index].x1 = ld2450::hex_to_signed_int(this->buffer_data_, start); - this->zone_config_[index].y1 = ld2450::hex_to_signed_int(this->buffer_data_, start + 2); - this->zone_config_[index].x2 = ld2450::hex_to_signed_int(this->buffer_data_, start + 4); - this->zone_config_[index].y2 = ld2450::hex_to_signed_int(this->buffer_data_, start + 6); + this->zone_config_[index].x1 = encode_value_le(&this->buffer_data_[start]); + this->zone_config_[index].y1 = encode_value_le(&this->buffer_data_[start + 2]); + this->zone_config_[index].x2 = encode_value_le(&this->buffer_data_[start + 4]); + this->zone_config_[index].y2 = encode_value_le(&this->buffer_data_[start + 6]); #ifdef USE_NUMBER // only one null check as all coordinates are required for a single zone if (this->zone_numbers_[index].x1 != nullptr) { @@ -505,7 +496,7 @@ void LD2450Component::handle_periodic_data_() { #ifdef USE_SENSOR // RESOLUTION start = TARGET_RESOLUTION + index * 8; - res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; + res = encode_value_le(&this->buffer_data_[start]); #endif // SPEED start = TARGET_SPEED + index * 8; diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp index abeb6779cf4..c2d469575e7 100644 --- a/esphome/components/ld2460/ld2460.cpp +++ b/esphome/components/ld2460/ld2460.cpp @@ -1,6 +1,7 @@ #include "ld2460.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include #include @@ -117,8 +118,8 @@ void LD2460Component::handle_periodic_data_() { 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])); + auto raw_x = encode_value_le(p); + auto raw_y = encode_value_le(&p[2]); if (raw_x == t.last_x && raw_y == t.last_y) { continue; } diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 3418d007ae4..51682358e7c 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -203,22 +203,8 @@ static bool is_expected_control_report(uint32_t command, uint16_t type) { } #endif -uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast(data[0]) << 8) | data[1]; } - -uint32_t LD6002BComponent::read_u32_le(const uint8_t *data) { - return static_cast(data[0]) | (static_cast(data[1]) << 8) | - (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); -} - -int32_t LD6002BComponent::read_int32_le(const uint8_t *data) { - uint32_t raw = read_u32_le(data); - int32_t value; - std::memcpy(&value, &raw, sizeof(value)); - return value; -} - float LD6002BComponent::read_f32_le(const uint8_t *data) { - uint32_t raw = read_u32_le(data); + uint32_t raw = encode_value_le(data); float value; std::memcpy(&value, &raw, sizeof(value)); return value; @@ -537,9 +523,9 @@ void LD6002BComponent::parse_byte_(uint8_t byte) { this->header_xor_ ^= byte; this->header_pos_++; if (this->header_pos_ == 6) { - this->frame_id_ = read_u16_be(this->data_buf_); - this->data_len_ = read_u16_be(this->data_buf_ + 2); - this->frame_type_ = read_u16_be(this->data_buf_ + 4); + this->frame_id_ = encode_value(this->data_buf_); + this->data_len_ = encode_value(this->data_buf_ + 2); + this->frame_type_ = encode_value(this->data_buf_ + 4); // The length is only trustworthy once the header checksum has been verified, so just // remember that the frame is oversized and let the HCK state act on it. this->frame_oversize_ = this->data_len_ > this->max_data_len_; @@ -625,7 +611,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ #ifdef ESPHOME_LOG_HAS_VERBOSE const uint32_t active_control_command = (this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) - ? read_u32_le(this->active_command_.data.data()) + ? encode_value_le(this->active_command_.data.data()) : 0; if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) { ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", frame_type_name(type), type, @@ -691,7 +677,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) if (len < 4) return; - uint32_t target_num = read_u32_le(data); + uint32_t target_num = encode_value_le(data); uint16_t available = (len - 4) / TARGET_DATA_LEN; // Un-narrowed: a report of e.g. 256 targets must not truncate to 0 and read as "absent". const uint32_t reported = std::min(target_num, available); @@ -704,7 +690,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) std::array slot_wire{}; for (uint8_t i = 0; i < count; i++) { uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16; - wire_cluster[i] = static_cast(read_u32_le(data + cluster_offset)); + wire_cluster[i] = encode_value_le(data + cluster_offset); } for (uint8_t i = 0; i < count; i++) { for (uint8_t s = 0; s < MAX_TARGETS; s++) { @@ -761,7 +747,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) float x = read_f32_le(data + offset + 0); float y = read_f32_le(data + offset + 4); float z = read_f32_le(data + offset + 8); - int32_t dop_idx = read_int32_le(data + offset + 12); + int32_t dop_idx = encode_value_le(data + offset + 12); int32_t cluster_id = this->slot_cluster_[i]; TargetSensors &target = this->targets_[i]; if (target.x != nullptr) { @@ -811,7 +797,7 @@ void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { return; #ifdef USE_SENSOR - uint32_t point_num = read_u32_le(data); + uint32_t point_num = encode_value_le(data); if (this->point_count_sensor_ != nullptr) { if (point_num != this->last_point_count_) { this->point_count_sensor_->publish_state(point_num); @@ -832,7 +818,7 @@ void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) this->area_presence_any_ = false; for (uint8_t i = 0; i < AREA_COUNT; i++) { - uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + uint32_t state = encode_value_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); bool present = state != 0; this->area_presence_any_ = this->area_presence_any_ || present; #ifdef USE_BINARY_SENSOR @@ -903,7 +889,7 @@ void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { if (len < 4) return; #ifdef USE_NUMBER - uint32_t delay = read_u32_le(data); + uint32_t delay = encode_value_le(data); this->publish_number_clamped_(this->hold_delay_number_, delay); #endif } @@ -978,7 +964,7 @@ void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint1 if (len < 4) return; #ifdef USE_NUMBER - uint32_t sleep_ms = read_u32_le(data); + uint32_t sleep_ms = encode_value_le(data); this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms); #endif } @@ -1115,7 +1101,7 @@ void LD6002BComponent::process_command_queue_() { if (this->command_sent_ && now - this->last_send_ms_ >= ack_timeout) { const uint32_t active_control_command = (this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) - ? read_u32_le(this->active_command_.data.data()) + ? encode_value_le(this->active_command_.data.data()) : 0; if (this->retries_left_ > 0) { #ifdef ESPHOME_LOG_HAS_VERBOSE diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index c52f770d6e9..663c73363ef 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -329,9 +329,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void apply_area_config_(); void wake_(); - static uint16_t read_u16_be(const uint8_t *data); - static uint32_t read_u32_le(const uint8_t *data); - static int32_t read_int32_le(const uint8_t *data); static float read_f32_le(const uint8_t *data); static void write_u32_le(uint8_t *data, uint32_t value); static void write_int32_le(uint8_t *data, int32_t value); diff --git a/esphome/components/lm75b/lm75b.cpp b/esphome/components/lm75b/lm75b.cpp index 388f5dfd93a..17135652088 100644 --- a/esphome/components/lm75b/lm75b.cpp +++ b/esphome/components/lm75b/lm75b.cpp @@ -1,4 +1,5 @@ #include "lm75b.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -21,7 +22,7 @@ void LM75BComponent::update() { return; } // Obtain combined 16-bit value - int16_t raw_temperature = (buff[0] << 8) | buff[1]; + int16_t raw_temperature = encode_value(buff); // Read the 11-bit raw temperature value raw_temperature >>= 5; // Publish the temperature in °C diff --git a/esphome/components/max31888/max31888.cpp b/esphome/components/max31888/max31888.cpp index 5ff8e6f1ef0..f8787e71d50 100644 --- a/esphome/components/max31888/max31888.cpp +++ b/esphome/components/max31888/max31888.cpp @@ -88,7 +88,7 @@ bool MAX31888Sensor::read_temperature_(int16_t &raw) { this->status_set_warning(LOG_STR("checksum invalid")); return false; } - raw = static_cast(encode_uint16(frame[3], frame[4])); + raw = encode_value(&frame[3]); return true; } diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 58f346ab77a..2e0dd7bd2bc 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -61,7 +61,7 @@ void MPL3115A2Component::update() { if (this->pressure_ != nullptr) this->pressure_->publish_state(pressure); } - int16_t t = encode_uint16(buffer[3], buffer[4]); + int16_t t = encode_value(&buffer[3]); float temperature = float(t) / 256.0f; if (this->temperature_ != nullptr) this->temperature_->publish_state(temperature); diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index cc24353782e..8c0acbbe434 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -1,5 +1,6 @@ #include "nau7802.h" #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -226,11 +227,7 @@ int32_t NAU7802Sensor::read_value_(uint8_t start_reg, size_t size) { for (size_t i = 0; i < size; i++) { result |= data[i] << (size - 1 - i) * 8; } - // extend sign bit - if (result & 0x800000 && size == 3) { - result |= 0xFF000000; - } - return result; + return size == 3 ? sign_extend<24>(result) : result; } bool NAU7802Sensor::calibrate_(enum NAU7802CalibrationModes mode) { diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 15ed2f8650e..70068ea84da 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -1,4 +1,5 @@ #include "pcf8574.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pcf8574 { @@ -90,7 +91,7 @@ bool PCF8574Component::read_gpio_() { this->status_set_warning(); return false; } - this->input_mask_ = (uint16_t(data[1]) << 8) | data[0]; + this->input_mask_ = encode_value_le(data); this->status_clear_warning(); return true; } diff --git a/esphome/components/pmwcs3/pmwcs3.cpp b/esphome/components/pmwcs3/pmwcs3.cpp index 60cc4da0e4c..1eac0244469 100644 --- a/esphome/components/pmwcs3/pmwcs3.cpp +++ b/esphome/components/pmwcs3/pmwcs3.cpp @@ -72,32 +72,32 @@ void PMWCS3Component::read_data_() { // Wait for the sensor to be ready. // 80ms empirically determined (conservative). this->set_timeout(80, [this] { - uint8_t data[8]; + uint16_t data[4]; float e25, ec, temperature, vwc; - if (!this->read_bytes(PMWCS3_REG_GET_DATA, (uint8_t *) &data, 8)) { + if (!this->read_bytes_16_le(PMWCS3_REG_GET_DATA, data, 4)) { ESP_LOGVV(TAG, "Reading PMWCS3_REG_GET_DATA failed"); this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->e25_sensor_ != nullptr) { - e25 = ((data[1] << 8) | data[0]) / 100.0; + e25 = data[0] / 100.0; this->e25_sensor_->publish_state(e25); - ESP_LOGVV(TAG, "e25: data[0]=%d, data[1]=%d, result=%f", data[0], data[1], e25); + ESP_LOGVV(TAG, "e25: raw=%u, result=%f", data[0], e25); } if (this->ec_sensor_ != nullptr) { - ec = ((data[3] << 8) | data[2]) / 10.0; + ec = data[1] / 10.0; this->ec_sensor_->publish_state(ec); - ESP_LOGVV(TAG, "ec: data[2]=%d, data[3]=%d, result=%f", data[2], data[3], ec); + ESP_LOGVV(TAG, "ec: raw=%u, result=%f", data[1], ec); } if (this->temperature_sensor_ != nullptr) { - temperature = ((data[5] << 8) | data[4]) / 100.0; + temperature = data[2] / 100.0; this->temperature_sensor_->publish_state(temperature); - ESP_LOGVV(TAG, "temp: data[4]=%d, data[5]=%d, result=%f", data[4], data[5], temperature); + ESP_LOGVV(TAG, "temp: raw=%u, result=%f", data[2], temperature); } if (this->vwc_sensor_ != nullptr) { - vwc = ((data[7] << 8) | data[6]) / 10.0; + vwc = data[3] / 10.0; this->vwc_sensor_->publish_state(vwc); - ESP_LOGVV(TAG, "vwc: data[6]=%d, data[7]=%d, result=%f", data[6], data[7], vwc); + ESP_LOGVV(TAG, "vwc: raw=%u, result=%f", data[3], vwc); } }); } diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index ecd27a95a56..2228f41dc10 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -91,9 +91,9 @@ class ABBWelcomeData { } uint32_t get_destination_address() const { if (this->get_address_length() == 2) { - return (this->data_[4] << 8) + this->data_[5]; + return encode_uint16(this->data_[4], this->data_[5]); } - return (this->data_[4] << 16) + (this->data_[5] << 8) + this->data_[6]; + return encode_uint24(this->data_[4], this->data_[5], this->data_[6]); } void set_source_address(uint32_t address) { if (this->get_address_length() == 2) { @@ -107,9 +107,9 @@ class ABBWelcomeData { } uint32_t get_source_address() const { if (this->get_address_length() == 2) { - return (this->data_[6] << 8) + this->data_[7]; + return encode_uint16(this->data_[6], this->data_[7]); } - return (this->data_[7] << 16) + (this->data_[8] << 8) + this->data_[9]; + return encode_uint24(this->data_[7], this->data_[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 diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 86daaf4b08b..1eb2680e9c8 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -56,10 +56,10 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { return true; RFBridgeData data; - data.sync = (raw[2] << 8) | raw[3]; - data.low = (raw[4] << 8) | raw[5]; - data.high = (raw[6] << 8) | raw[7]; - data.code = (raw[8] << 16) | (raw[9] << 8) | raw[10]; + data.sync = encode_uint16(raw[2], raw[3]); + data.low = encode_uint16(raw[4], raw[5]); + data.high = encode_uint16(raw[6], raw[7]); + data.code = encode_uint24(raw[8], raw[9], raw[10]); if (action == RF_CODE_LEARN_OK) { ESP_LOGD(TAG, "Learning success"); diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index eec4e7afa6b..199c25d9390 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -48,25 +48,6 @@ void MR60BHA2Component::loop() { } } -/** - * @brief Calculate the checksum for a byte array. - * - * This function calculates the checksum for the provided byte array using an - * XOR-based checksum algorithm. - * - * @param data The byte array to calculate the checksum for. - * @param len The length of the byte array. - * @return The calculated checksum. - */ -static uint8_t calculate_checksum(const uint8_t *data, size_t len) { - uint8_t checksum = 0; - for (size_t i = 0; i < len; i++) { - checksum ^= data[i]; - } - checksum = ~checksum; - return checksum; -} - /** * @brief Validate the checksum of a byte array. * @@ -79,7 +60,7 @@ static uint8_t calculate_checksum(const uint8_t *data, size_t len) { * @return True if the checksum is valid, false otherwise. */ static bool validate_checksum(const uint8_t *data, size_t len, uint8_t expected_checksum) { - return calculate_checksum(data, len) == expected_checksum; + return static_cast(~xor_bytes(data, len)) == expected_checksum; } bool MR60BHA2Component::validate_message_() { diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 858510f4277..d5849ddfc0a 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -43,7 +43,7 @@ void SenseAirComponent::update() { } uint16_t calc_checksum = crc16(response, 11); - uint16_t resp_checksum = (uint16_t(response[12]) << 8) | response[11]; + uint16_t resp_checksum = encode_value_le(&response[11]); if (resp_checksum != calc_checksum) { ESP_LOGW(TAG, "SenseAir checksum doesn't match: 0x%02X!=0x%02X", resp_checksum, calc_checksum); this->status_set_warning(); diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp index 5d9890eca51..19f05e95b77 100644 --- a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp @@ -7,7 +7,7 @@ namespace esphome::systa_bus { ESPHOME_LOG_TAG(TAG, "systa_bus.sensor"); static int16_t get_i16be(std::span message, size_t start) { - return static_cast(encode_uint16(message[start], message[start + 1])); + return encode_value(&message[start]); } void SystaSolarAquaSensor::dump_config() { diff --git a/esphome/components/vbus/sensor/vbus_sensor.cpp b/esphome/components/vbus/sensor/vbus_sensor.cpp index 5c111c22942..000b69667dc 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.cpp +++ b/esphome/components/vbus/sensor/vbus_sensor.cpp @@ -7,11 +7,11 @@ namespace esphome::vbus { ESPHOME_LOG_TAG(TAG, "vbus.sensor"); static inline uint16_t get_u16(std::vector &message, int start) { - return (message[start + 1] << 8) + message[start]; + return encode_value_le(&message[start]); } static inline int16_t get_i16(std::vector &message, int start) { - return (int16_t) ((message[start + 1] << 8) + message[start]); + return encode_value_le(&message[start]); } void DeltaSolBSPlusSensor::dump_config() { diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 0347cfa083c..5e16004dc2c 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -51,12 +51,11 @@ uint16_t WaveshareIOCH32V003Component::get_adc_value() { if (this->is_failed()) return 0; - uint8_t data[2]; - if (!this->read_bytes(IO_EXTENSION_ADC_ADDR, data, 2)) { + uint16_t adc_value; + if (!this->read_byte_16_le(IO_EXTENSION_ADC_ADDR, &adc_value)) { this->status_set_warning(LOG_STR("Failed to read ADC register")); return 0; } - uint16_t adc_value = (data[1] << 8) | data[0]; this->status_clear_warning(); return adc_value; } diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index e341ce04bbc..59b48014579 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -1,4 +1,5 @@ #include "whirlpool.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::whirlpool { @@ -136,10 +137,8 @@ void WhirlpoolClimate::transmit_state() { } // Checksum - for (uint8_t i = 2; i < 13; i++) - remote_state[13] ^= remote_state[i]; - for (uint8_t i = 14; i < 20; i++) - remote_state[20] ^= remote_state[i]; + remote_state[13] = xor_bytes(&remote_state[2], 11, remote_state[13]); + remote_state[20] = xor_bytes(&remote_state[14], 6, remote_state[20]); ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X " @@ -269,13 +268,9 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum13 = 0; - uint8_t checksum20 = 0; // Calculate checksum and compare with signal value. - for (uint8_t i = 2; i < 13; i++) - checksum13 ^= remote_state[i]; - for (uint8_t i = 14; i < 20; i++) - checksum20 ^= remote_state[i]; + uint8_t checksum13 = xor_bytes(&remote_state[2], 11); + uint8_t checksum20 = xor_bytes(&remote_state[14], 6); if (checksum13 != remote_state[13] || (!skip_footer && checksum20 != remote_state[20])) { ESP_LOGVV(TAG, "Checksum fail"); From 84ce39e493220ea19c9ffe65717b914786faff9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:05:37 -1000 Subject: [PATCH 620/651] [components] Use the codegen helpers in the remaining components (#21078) --- esphome/components/airton/climate.py | 19 ++++++++++--------- esphome/components/am43/sensor/__init__.py | 5 +---- esphome/components/bedjet/__init__.py | 6 ++---- .../components/bluetooth_proxy/__init__.py | 6 ++---- esphome/components/ch422g/__init__.py | 3 +-- esphome/components/ch423/__init__.py | 3 +-- esphome/components/dlms_meter/__init__.py | 15 +++------------ esphome/components/ds1307/time.py | 6 +----- esphome/components/ds248x/one_wire.py | 3 +-- esphome/components/epaper_spi/display.py | 6 +----- esphome/components/ethernet/__init__.py | 3 +-- esphome/components/hbridge/fan/__init__.py | 11 +++-------- esphome/components/ina260/sensor.py | 6 +----- esphome/components/ld6002b/__init__.py | 4 +--- esphome/components/light/__init__.py | 4 +--- esphome/components/mcp23016/__init__.py | 3 +-- esphome/components/mcp23xxx_base/__init__.py | 3 +-- esphome/components/mcp9600/sensor.py | 6 +----- esphome/components/midea/climate.py | 3 +-- esphome/components/mipi_spi/display.py | 13 ++----------- esphome/components/mipi_spi/light/__init__.py | 4 ++-- esphome/components/modbus/__init__.py | 7 ++----- esphome/components/modbus_client/__init__.py | 3 +-- esphome/components/mt6701_i2c/__init__.py | 4 +--- esphome/components/mt6701_spi/__init__.py | 5 +---- esphome/components/nextion/display.py | 7 +------ esphome/components/pca6416a/__init__.py | 3 +-- esphome/components/pca9505/__init__.py | 3 +-- esphome/components/pca9554/__init__.py | 3 +-- esphome/components/pcf85063/time.py | 6 +----- esphome/components/pcf8563/time.py | 6 +----- esphome/components/pcf8574/__init__.py | 3 +-- esphome/components/pcm5122/audio_dac.py | 4 +--- esphome/components/pi4ioe5v6408/__init__.py | 3 +-- esphome/components/rx8025t/time.py | 6 +----- esphome/components/seeed_mr24hpc1/__init__.py | 10 +--------- esphome/components/speed/fan/__init__.py | 13 +++---------- esphome/components/tca9555/__init__.py | 3 +-- .../components/template/climate/__init__.py | 7 ++----- esphome/components/uart/__init__.py | 15 ++++----------- esphome/components/zigbee/__init__.py | 4 +--- esphome/components/zigbee/zigbee_zephyr.py | 3 +-- 42 files changed, 66 insertions(+), 184 deletions(-) diff --git a/esphome/components/airton/climate.py b/esphome/components/airton/climate.py index fdfef45aaa4..bde8eca0c04 100644 --- a/esphome/components/airton/climate.py +++ b/esphome/components/airton/climate.py @@ -16,15 +16,16 @@ CONF_VERTICAL_DIRECTION = "vertical_direction" VerticalDirection = airton_ns.enum("VerticalDirection", is_class=True) # In the order of the enum, so the option index is the direction value -VERTICAL_DIRECTIONS = { - "off": VerticalDirection.VERTICAL_DIRECTION_OFF, - "swing": VerticalDirection.VERTICAL_DIRECTION_SWING, - "up": VerticalDirection.VERTICAL_DIRECTION_UP, - "middle-up": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_UP, - "middle": VerticalDirection.VERTICAL_DIRECTION_MIDDLE, - "middle-down": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_DOWN, - "down": VerticalDirection.VERTICAL_DIRECTION_DOWN, -} +VERTICAL_DIRECTIONS = VerticalDirection.options( + "off", + "swing", + "up", + "middle-up", + "middle", + "middle-down", + "down", + prefix="VERTICAL_DIRECTION_", +) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(AirtonClimate).extend( { diff --git a/esphome/components/am43/sensor/__init__.py b/esphome/components/am43/sensor/__init__.py index ef746f3e52f..d988c6dbec6 100644 --- a/esphome/components/am43/sensor/__init__.py +++ b/esphome/components/am43/sensor/__init__.py @@ -3,7 +3,6 @@ from esphome.components import ble_client, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, - CONF_ID, CONF_ILLUMINANCE, DEVICE_CLASS_BATTERY, ENTITY_CATEGORY_DIAGNOSTIC, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( 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) + var = await ble_client.new_ble_node_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BATTERY_LEVEL, var.set_battery) diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index 406955084ea..2bcbf557649 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import ble_client, time import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_TIME_ID +from esphome.const import CONF_RECEIVE_TIMEOUT, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -41,9 +41,7 @@ async def register_bedjet_child(var: MockObj, config: ConfigType) -> None: 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) + var = await ble_client.new_ble_node_component(config) await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID)) if (receive_timeout := config.get(CONF_RECEIVE_TIMEOUT)) is not None: cg.add(var.set_status_timeout(receive_timeout)) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 6332ea6dc12..5c7dff0cd17 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -372,8 +372,7 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) - cg.add(var.set_ble_hub(tracker)) + await cg.add_variable(var.set_ble_hub, config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) # Compiles the scanner-state push slot into the tracker and the matching # registration into the proxy; the other hubs are polled instead. @@ -390,8 +389,7 @@ async def _to_code_ble_hub(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) - cg.add(var.set_ble_hub(hub)) + await cg.add_variable(var.set_ble_hub, config[ble_device_base.CONF_BLE_HUB_ID]) await _connections_to_code(var, config) diff --git a/esphome/components/ch422g/__init__.py b/esphome/components/ch422g/__init__.py index 5110503bf84..60801e0c8dd 100644 --- a/esphome/components/ch422g/__init__.py +++ b/esphome/components/ch422g/__init__.py @@ -42,8 +42,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) # Can't use register_i2c_device because there is no CONF_ADDRESS - parent = await cg.get_variable(config[CONF_I2C_ID]) - cg.add(var.set_i2c_bus(parent)) + await cg.add_variable(var.set_i2c_bus, config[CONF_I2C_ID]) # This is used as a final validation step so that modes have been fully transformed. diff --git a/esphome/components/ch423/__init__.py b/esphome/components/ch423/__init__.py index 3997f9581f0..42dc8bb2841 100644 --- a/esphome/components/ch423/__init__.py +++ b/esphome/components/ch423/__init__.py @@ -43,8 +43,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) # Can't use register_i2c_device because there is no CONF_ADDRESS - parent = await cg.get_variable(config[CONF_I2C_ID]) - cg.add(var.set_i2c_bus(parent)) + await cg.add_variable(var.set_i2c_bus, config[CONF_I2C_ID]) # This is used as a final validation step so that modes have been fully transformed. diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index eed702b6f1a..46ccc2e44e6 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -4,13 +4,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_NAME, - CONF_PATTERN, - CONF_PRIORITY, - CONF_RECEIVE_TIMEOUT, -) +from esphome.const import CONF_NAME, CONF_PATTERN, CONF_PRIORITY, CONF_RECEIVE_TIMEOUT from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -146,8 +140,8 @@ async def to_code(config: ConfigType) -> None: for pattern in config.get(CONF_CUSTOM_PATTERNS, []) ] - var = cg.new_Pvariable( - config[CONF_ID], + await uart.new_uart_component( + config, config[CONF_RECEIVE_TIMEOUT], config[CONF_SKIP_CRC], config.get(CONF_DECRYPTION_KEY, cg.nullptr), @@ -155,9 +149,6 @@ async def to_code(config: ConfigType) -> None: cg.ArrayInitializer(*custom_patterns, multiline=True), ) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - if CORE.is_esp32: esp32.add_idf_component(name="esphome/dlms_parser", ref=DLMS_PARSER_VERSION) else: diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 7a15283f3b7..962bd3605fb 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@badbadc0ffee"] @@ -40,8 +39,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py index b0289581329..1b142b41690 100644 --- a/esphome/components/ds248x/one_wire.py +++ b/esphome/components/ds248x/one_wire.py @@ -52,6 +52,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_DS248X_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_DS248X_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 75a3426f162..35bb99ed872 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -245,11 +245,7 @@ async def to_code(config): await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) await cg.add_pin(var.set_busy_pin, config.get(CONF_BUSY_PIN)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 3fa71632f02..1a50dbebfc3 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -715,8 +715,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: if config[CONF_TYPE] in SPI_ETHERNET_TYPES: if (spi_id := config.get(CONF_SPI_ID)) is not None: # Pins and host come from the shared spi bus. - spi_parent = await cg.get_variable(spi_id) - cg.add(var.set_spi_parent(spi_parent)) + await cg.add_variable(var.set_spi_parent, spi_id) else: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index f6d935c300d..01713ba34f4 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -56,14 +56,9 @@ async def to_code(config: ConfigType) -> None: config[CONF_DECAY_MODE], ) await cg.register_component(var, config) - pin_a_ = await cg.get_variable(config[CONF_PIN_A]) - cg.add(var.set_pin_a(pin_a_)) - pin_b_ = await cg.get_variable(config[CONF_PIN_B]) - cg.add(var.set_pin_b(pin_b_)) - - if CONF_ENABLE_PIN in config: - enable_pin = await cg.get_variable(config[CONF_ENABLE_PIN]) - cg.add(var.set_enable_pin(enable_pin)) + await cg.add_variable(var.set_pin_a, config[CONF_PIN_A]) + await cg.add_variable(var.set_pin_b, config[CONF_PIN_B]) + await cg.add_variable(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) if CONF_PRESET_MODES in config: cg.add(var.set_preset_modes(config[CONF_PRESET_MODES])) diff --git a/esphome/components/ina260/sensor.py b/esphome/components/ina260/sensor.py index 20cf1fee45f..985defc06a8 100644 --- a/esphome/components/ina260/sensor.py +++ b/esphome/components/ina260/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_POWER, DEVICE_CLASS_CURRENT, DEVICE_CLASS_POWER, @@ -54,10 +53,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index 48017a8f00e..5dccf0d725f 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -64,9 +64,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = await uart.new_uart_component(config) - if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): - pin = await cg.gpio_pin_expression(wakeup_pin_config) - cg.add(var.set_wakeup_pin(pin)) + await cg.add_pin(var.set_wakeup_pin, config.get(CONF_WAKEUP_PIN)) cg.add(var.set_wakeup_pulse_ms(config[CONF_WAKEUP_PULSE].total_milliseconds)) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 056ec7c44f9..dcd88a80ea4 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -633,9 +633,7 @@ async def setup_light_core_(light_var, config, output_var): if (color_correct := config.get(CONF_COLOR_CORRECT)) is not None: cg.add(output_var.set_correction(*color_correct)) - if (power_supply_id := config.get(CONF_POWER_SUPPLY)) is not None: - var_ = await cg.get_variable(power_supply_id) - cg.add(output_var.set_power_supply(var_)) + await cg.add_variable(output_var.set_power_supply, config.get(CONF_POWER_SUPPLY)) if (power_estimation := config.get(CONF_POWER_ESTIMATION)) is not None: cg.add_define("USE_LIGHT_POWER_ESTIMATION") diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index ab77bc5a5b3..67b302d1c51 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -38,8 +38,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 942a975813a..c7aae9d10ff 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -47,8 +47,7 @@ async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: await cg.register_component(var, config) CORE.data.setdefault(CONF_MCP23XXX, {})[id.id] = num_pins cg.add(var.set_open_drain_ints(config[CONF_OPEN_DRAIN_INTERRUPT])) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) return var diff --git a/esphome/components/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 9a5afe64c28..9953f450145 100644 --- a/esphome/components/mcp9600/sensor.py +++ b/esphome/components/mcp9600/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_THERMOCOUPLE_TYPE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -57,10 +56,7 @@ FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_thermocouple_type(config[CONF_THERMOCOUPLE_TYPE])) diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index cecbf9836ef..f5c1ed71b84 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -221,8 +221,7 @@ async def to_code(config): if CONF_TRANSMITTER_ID in config: remote_base.request_protocol("midea") # ir_transmitter.h uses it from C++ cg.add_define("USE_REMOTE_TRANSMITTER") - transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter_)) + await cg.add_variable(var.set_transmitter, config[CONF_TRANSMITTER_ID]) cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) cg.add(var.set_autoconf(config[CONF_AUTOCONF])) cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL])) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 380c3a4dfd6..fffd1aacf44 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -45,7 +45,6 @@ from esphome.const import ( CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -227,11 +226,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema: ) if model.get_default(CONF_BRIGHTNESS, None) is not None or model.name == "CUSTOM": schema = schema.extend( - { - model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.int_range( - 0, 0xFF, min_included=True, max_included=True - ), - } + {model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.uint8_t} ) if bus_mode != TYPE_SINGLE: return cv.All(schema, cv.only_on_esp32) @@ -413,10 +408,6 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_INVERT_COLORS): cg.add(var.set_invert_colors(True)) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/mipi_spi/light/__init__.py b/esphome/components/mipi_spi/light/__init__.py index aec5d9b4fdf..2fe4c872dfe 100644 --- a/esphome/components/mipi_spi/light/__init__.py +++ b/esphome/components/mipi_spi/light/__init__.py @@ -32,8 +32,8 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MipiSpiLight), cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(MipiSpi), cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, - cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.int_range(0, 255), - cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.int_range(0, 255), + cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.uint8_t, + cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.uint8_t, } ), _validate_brightness_range, diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index a1f15aab452..258ecd51bac 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -9,7 +9,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ROLE import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType, TemplateArgsType @@ -301,10 +301,7 @@ CONFIG_SCHEMA = cv.typed_schema( async def to_code(config: ConfigType) -> None: cg.add_global(modbus_ns.using) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 67c3cd75bdc..25f4b230dbb 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -186,8 +186,7 @@ async def register_client_action( response_args are the on_response handler's arguments, which differ per action. """ - parent = await cg.get_variable(config[modbus.CONF_MODBUS_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[modbus.CONF_MODBUS_ID]) cg.add( var.set_target_address( await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) diff --git a/esphome/components/mt6701_i2c/__init__.py b/esphome/components/mt6701_i2c/__init__.py index 17734e00459..79c73eebe46 100644 --- a/esphome/components/mt6701_i2c/__init__.py +++ b/esphome/components/mt6701_i2c/__init__.py @@ -155,9 +155,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if (direction := config.get(CONF_DIRECTION)) is not None: cg.add(var.set_direction(direction)) diff --git a/esphome/components/mt6701_spi/__init__.py b/esphome/components/mt6701_spi/__init__.py index 6f45e347b7d..3498dde0e8a 100644 --- a/esphome/components/mt6701_spi/__init__.py +++ b/esphome/components/mt6701_spi/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType from ..mt6701 import MT6701Component @@ -42,6 +41,4 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 969a6c8e8c9..92255a6eda2 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BRIGHTNESS, CONF_ID, - CONF_LAMBDA, CONF_ON_TOUCH, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -232,11 +231,7 @@ async def to_code(config): if CONF_BRIGHTNESS in config: cg.add(var.set_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(nextion_ref, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, nextion_ref) if CONF_TFT_URL in config: cg.add_define("USE_NEXTION_TFT_UPLOAD") diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 33f8af9d528..144088d48f1 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -42,8 +42,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index 4e3acb94e6e..91521820b30 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -42,8 +42,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index cba5656e88d..6e9478b1fd4 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -48,8 +48,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pin_count(config[CONF_PIN_COUNT])) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index 3f993c17e19..e3e7daeb49a 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@brogon"] @@ -42,8 +41,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 3f677280c5b..7f78549ba8e 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@KoenBreeman"] @@ -38,8 +37,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index f1db7c29748..b491dae5047 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -48,8 +48,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pcf8575(config[CONF_PCF8575])) if (reset := config[CONF_RESET]) != DEFAULT_RESET: cg.add(var.set_reset(reset)) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index cb50b101c13..fbfc998bb9f 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -140,6 +140,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_channel_mix(config[CONF_CHANNEL_MIX])) cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) - if enable_pin_config := config.get(CONF_ENABLE_PIN): - enable_pin = await cg.gpio_pin_expression(enable_pin_config) - cg.add(var.set_enable_pin(enable_pin)) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 4128114b3cc..458ea1f2728 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -49,8 +49,7 @@ async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) cg.add(var.set_reset(config[CONF_RESET])) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/rx8025t/time.py b/esphome/components/rx8025t/time.py index e8ffe772ab7..fcc6c0b72fa 100644 --- a/esphome/components/rx8025t/time.py +++ b/esphome/components/rx8025t/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@remcom"] @@ -35,8 +34,5 @@ for _name, _call in ( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/seeed_mr24hpc1/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index 4ec01e46c67..63b5373cffc 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -1,7 +1,6 @@ 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 DEPENDENCIES = ["uart"] @@ -43,12 +42,5 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -# The async def keyword is used to define a concurrent function. -# Concurrent functions are special functions designed to work with Python's asyncio library to support asynchronous I/O operations. async def to_code(config: ConfigType) -> None: - # This line of code creates a new Pvariable (a Python object representing a C++ variable) with the variable's ID taken from the configuration. - var = cg.new_Pvariable(config[CONF_ID]) - # This line of code registers the newly created Pvariable as a component so that ESPHome can manage it at runtime. - await cg.register_component(var, config) - # This line of code registers the newly created Pvariable as a device. - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/speed/fan/__init__.py b/esphome/components/speed/fan/__init__.py index 9a9290e78ee..cd94796f081 100644 --- a/esphome/components/speed/fan/__init__.py +++ b/esphome/components/speed/fan/__init__.py @@ -38,16 +38,9 @@ async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config, config[CONF_SPEED_COUNT]) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) - - if CONF_OSCILLATION_OUTPUT in config: - oscillation_output = await cg.get_variable(config[CONF_OSCILLATION_OUTPUT]) - cg.add(var.set_oscillating(oscillation_output)) - - if CONF_DIRECTION_OUTPUT in config: - direction_output = await cg.get_variable(config[CONF_DIRECTION_OUTPUT]) - cg.add(var.set_direction(direction_output)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) + await cg.add_variable(var.set_oscillating, config.get(CONF_OSCILLATION_OUTPUT)) + await cg.add_variable(var.set_direction, config.get(CONF_DIRECTION_OUTPUT)) if CONF_PRESET_MODES in config: cg.add(var.set_preset_modes(config[CONF_PRESET_MODES])) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 0554f9a569d..a3992b7c88e 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -41,8 +41,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 30155de28aa..54cd6df45e6 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -295,11 +295,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await climate.register_climate(var, config) - if (sens := config.get(CONF_SENSOR)) is not None: - cg.add(var.set_sensor(await cg.get_variable(sens))) - - if (sens := config.get(CONF_HUMIDITY_SENSOR)) is not None: - cg.add(var.set_humidity_sensor(await cg.get_variable(sens))) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) for key, flag in ( (CONF_SUPPORTS_ACTION, climate_ns.CLIMATE_SUPPORTS_ACTION), diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index dd6ae47bbfa..c70f0b6c693 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -370,15 +370,9 @@ async def to_code(config): cg.add(var.set_baud_rate(config[CONF_BAUD_RATE])) - if CONF_TX_PIN in config: - tx_pin = await cg.gpio_pin_expression(config[CONF_TX_PIN]) - cg.add(var.set_tx_pin(tx_pin)) - if CONF_RX_PIN in config: - rx_pin = await cg.gpio_pin_expression(config[CONF_RX_PIN]) - cg.add(var.set_rx_pin(rx_pin)) - if CONF_FLOW_CONTROL_PIN in config: - flow_control_pin = await cg.gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) - cg.add(var.set_flow_control_pin(flow_control_pin)) + await cg.add_pin(var.set_tx_pin, config.get(CONF_TX_PIN)) + await cg.add_pin(var.set_rx_pin, config.get(CONF_RX_PIN)) + await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) if CONF_PORT in config: cg.add(var.set_name(config[CONF_PORT])) cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE])) @@ -589,8 +583,7 @@ async def register_uart_device(var, config): This is a coroutine, you need to await it with an 'await' expression! """ - parent = await cg.get_variable(config[CONF_UART_ID]) - cg.add(var.set_uart_parent(parent)) + await cg.add_variable(var.set_uart_parent, config[CONF_UART_ID]) def new_uart_component( diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 44f83696f25..c0b0ce453b8 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -176,9 +176,7 @@ CONFIG_SCHEMA = cv.All( cv.Length(max=31), _validate_ascii, ), - cv.Optional(CONF_VERSION, default=0): cv.All( - cv.int_range(min=0x00, max=0xFF), - ), + cv.Optional(CONF_VERSION, default=0): cv.uint8_t, cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), cv.Optional(CONF_ON_START): automation.validate_automation({}), diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index f7da147b88f..ad2bb4f869e 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -392,8 +392,7 @@ async def _add_zigbee_ep( cg.add(var.set_endpoint(slot_index + 1)) cg.add(var.set_cluster_attributes(attrs)) - hub = await cg.get_variable(config[CONF_ZIGBEE_ID]) - cg.add(var.set_parent(hub)) + await cg.register_parented(var, config[CONF_ZIGBEE_ID]) async def add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: From e6ad995c8e4932c15eaed31526da38d8e61c5f84 Mon Sep 17 00:00:00 2001 From: cosmicDustOfLightLength <71566296+cosmicDustOfLightLength@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:07:52 +0200 Subject: [PATCH 621/651] [i2s_audio] Add ESP32-S31 support (#20115) Co-authored-by: cosmicDustOfLightLength Co-authored-by: J. Nick Koston --- esphome/components/i2s_audio/__init__.py | 2 + .../i2s_audio/microphone/__init__.py | 7 +- .../i2s_audio/test_variants.py | 68 +++++++++++++++++++ .../i2s_audio/test.esp32-s31-idf.yaml | 7 ++ .../microphone/test.esp32-s31-idf.yaml | 7 ++ .../speaker/test.esp32-s31-idf.yaml | 7 ++ 6 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/i2s_audio/test_variants.py create mode 100644 tests/components/i2s_audio/test.esp32-s31-idf.yaml create mode 100644 tests/components/microphone/test.esp32-s31-idf.yaml create mode 100644 tests/components/speaker/test.esp32-s31-idf.yaml diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 3d5ec97a633..4abc807ee3d 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -17,6 +17,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ) from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN from esphome.config_helpers import filter_source_files_from_defines @@ -98,6 +99,7 @@ I2S_PORTS = { VARIANT_ESP32P4: 3, VARIANT_ESP32S2: 1, VARIANT_ESP32S3: 2, + VARIANT_ESP32S31: 2, } i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index c2173172372..2ff709dbd1c 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -38,7 +38,12 @@ I2SAudioMicrophone = i2s_audio_ns.class_( ) INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] -PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] +PDM_VARIANTS = [ + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S3, + esp32.VARIANT_ESP32P4, + esp32.VARIANT_ESP32S31, +] i2s_pdm_dsr_t = cg.global_ns.enum("i2s_pdm_dsr_t") I2S_PDM_DSR = { diff --git a/tests/component_tests/i2s_audio/test_variants.py b/tests/component_tests/i2s_audio/test_variants.py new file mode 100644 index 00000000000..3c5245565fa --- /dev/null +++ b/tests/component_tests/i2s_audio/test_variants.py @@ -0,0 +1,68 @@ +"""Tests for the per-variant limits of i2s_audio: port count and PDM support.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import i2s_audio +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import ( + VARIANT_ESP32C3, + VARIANT_ESP32S2, + VARIANT_ESP32S31, +) +from esphome.components.i2s_audio import microphone +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONF_FULL_DUPLEX = i2s_audio.CONF_FULL_DUPLEX + + +def _set_variant( + set_core_config: SetCoreConfigCallable, variant: str, buses: int +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant}, + full_config={ + i2s_audio.DOMAIN: [ + {CONF_ID: ID(f"bus_{n}"), CONF_FULL_DUPLEX: False} for n in range(buses) + ] + }, + ) + + +def test_esp32s31_has_two_i2s_ports(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S31, buses=2) + i2s_audio._final_validate({}) + + +def test_esp32s31_rejects_a_third_bus(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S31, buses=3) + with pytest.raises(cv.Invalid, match="Only 2 I2S audio ports"): + i2s_audio._final_validate({}) + + +def _pdm_microphone(pdm: bool) -> ConfigType: + return { + microphone.CONF_ADC_TYPE: "external", + microphone.CONF_PDM: pdm, + } + + +def test_esp32s31_supports_pdm_microphones( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S31, buses=1) + microphone._validate_esp32_variant(_pdm_microphone(True)) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32S2, VARIANT_ESP32C3]) +def test_pdm_rejected_where_the_soc_has_none( + set_core_config: SetCoreConfigCallable, variant: str +) -> None: + _set_variant(set_core_config, variant, buses=1) + with pytest.raises(cv.Invalid, match="does not support PDM"): + microphone._validate_esp32_variant(_pdm_microphone(True)) + microphone._validate_esp32_variant(_pdm_microphone(False)) diff --git a/tests/components/i2s_audio/test.esp32-s31-idf.yaml b/tests/components/i2s_audio/test.esp32-s31-idf.yaml new file mode 100644 index 00000000000..6457440bb4e --- /dev/null +++ b/tests/components/i2s_audio/test.esp32-s31-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +packages: + i2s_audio: !include common.yaml diff --git a/tests/components/microphone/test.esp32-s31-idf.yaml b/tests/components/microphone/test.esp32-s31-idf.yaml new file mode 100644 index 00000000000..00f535b1692 --- /dev/null +++ b/tests/components/microphone/test.esp32-s31-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + i2s_din_pin1: GPIO6 + i2s_din_pin2: GPIO7 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s31-idf.yaml + microphone: !include common-pdm.yaml diff --git a/tests/components/speaker/test.esp32-s31-idf.yaml b/tests/components/speaker/test.esp32-s31-idf.yaml new file mode 100644 index 00000000000..00a41ac969b --- /dev/null +++ b/tests/components/speaker/test.esp32-s31-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + i2s_dout_pin: GPIO10 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-s31-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s31-idf.yaml + speaker: !include common.yaml From 704dd4b445052a5029a264a8f751ddb3f9040edd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:17:57 -1000 Subject: [PATCH 622/651] [components] Report communication failures with status_comm_result in the remaining components (#21079) --- esphome/components/atm90e32/atm90e32.cpp | 5 +---- esphome/components/bme680/bme680.cpp | 4 +--- esphome/components/bme680_bsec/bme680_bsec.cpp | 6 +----- esphome/components/bme68x_bsec2/bme68x_bsec2.cpp | 6 +----- esphome/components/cm1106/cm1106.cpp | 6 ++---- .../cst226/touchscreen/cst226_touchscreen.cpp | 4 +--- .../cst9220/touchscreen/cst9220_touchscreen.cpp | 4 +--- .../dfrobot_sen0465_base/dfrobot_sen0465_base.cpp | 6 +----- .../components/ds3231/sensor/ds3231_temperature.cpp | 5 +---- .../components/esp32_rmt_led_strip/led_strip.cpp | 5 +---- esphome/components/hc8/hc8.cpp | 5 +---- esphome/components/hdc302x/hdc302x.cpp | 7 ++----- .../hoermann_hcp/cover/hoermann_hcp_cover.cpp | 4 +--- .../hoermann_hcp/light/hoermann_hcp_light.cpp | 7 ++----- esphome/components/husb238/husb238.cpp | 5 +---- esphome/components/hx711/hx711.cpp | 5 +---- esphome/components/ina2xx_base/ina2xx_base.cpp | 6 +----- esphome/components/lis2dw12/lis2dw12.cpp | 13 +++---------- esphome/components/lp5562/lp5562_output.cpp | 6 +----- .../binary_sensor/m5stack_8angle_binary_sensor.cpp | 5 +---- .../m5stack_8angle/sensor/m5stack_8angle_sensor.cpp | 5 +---- esphome/components/max6675/max6675.cpp | 6 ++---- esphome/components/mcp3221/mcp3221_sensor.cpp | 4 +--- esphome/components/mcp9808/mcp9808.cpp | 5 +---- esphome/components/mhz19/mhz19.cpp | 4 +--- esphome/components/mics_4514/mics_4514.cpp | 5 +---- esphome/components/mpr121/mpr121.cpp | 8 +------- esphome/components/nau7802/nau7802.cpp | 5 +---- esphome/components/opt3001/opt3001.cpp | 4 +--- esphome/components/pca6416a/pca6416a.cpp | 10 ++-------- esphome/components/pca9554/pca9554.cpp | 9 ++------- esphome/components/pcf8574/pcf8574.cpp | 13 ++----------- esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp | 12 ++++-------- esphome/components/pn532/pn532.cpp | 13 ++++++------- esphome/components/qmp6988/qmp6988.cpp | 4 +--- .../remote_transmitter/remote_transmitter_rmt.cpp | 4 +--- esphome/components/rx8025t/rx8025t.cpp | 4 +--- esphome/components/rx8111/rx8111.cpp | 6 ++---- esphome/components/selec_meter/selec_meter.cpp | 6 +----- esphome/components/sen21231/sen21231.cpp | 4 +--- esphome/components/sen6x/sen6x.cpp | 4 +--- esphome/components/senseair/senseair.cpp | 4 +--- esphome/components/sht4x/sht4x.cpp | 5 +---- esphome/components/spd2010/spd2010_touchscreen.cpp | 8 +++----- .../st7123/touchscreen/st7123_touchscreen.cpp | 4 +--- esphome/components/sts3x/sts3x.cpp | 5 +---- esphome/components/sy6970/sy6970.cpp | 5 +---- esphome/components/t6615/t6615.cpp | 5 +---- esphome/components/tca8418/tca8418.cpp | 5 +---- esphome/components/tmc22xx/tmc22xx.cpp | 9 +++------ esphome/components/tmp1075/tmp1075.cpp | 4 +--- esphome/components/tsl2561/tsl2561.cpp | 5 +---- esphome/components/ttp229_lsf/ttp229_lsf.cpp | 5 +---- esphome/components/veml3235/veml3235.cpp | 5 +---- 54 files changed, 76 insertions(+), 242 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 0cdf9918db9..9fb32e2f021 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -117,12 +117,9 @@ void ATM90E32Component::loop() { } void ATM90E32Component::update() { - if (this->read16_(ATM90E32_REGISTER_METEREN) != 1) { - this->status_set_warning(); + if (!this->status_comm_result(this->read16_(ATM90E32_REGISTER_METEREN) == 1, nullptr)) return; - } this->set_publish_interval_flag_(true); - this->status_clear_warning(); #ifdef USE_TEXT_SENSOR this->check_phase_status(); diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index e3297d38f55..ac5ce3287d8 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -284,7 +284,7 @@ uint8_t BME680Component::calc_heater_duration_(uint16_t duration) { } void BME680Component::read_data_() { uint8_t data[15]; - if (!this->read_bytes(BME680_REGISTER_FIELD0, data, 15)) { + if (!this->status_comm_result(this->read_bytes(BME680_REGISTER_FIELD0, data, 15))) { if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(NAN); if (this->pressure_sensor_ != nullptr) @@ -294,10 +294,8 @@ void BME680Component::read_data_() { if (this->gas_resistance_sensor_ != nullptr) this->gas_resistance_sensor_->publish_state(NAN); ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); return; } - this->status_clear_warning(); uint32_t raw_temperature = (uint32_t(data[5]) << 12) | (uint32_t(data[6]) << 4) | (uint32_t(data[7]) >> 4); uint32_t raw_pressure = (uint32_t(data[2]) << 12) | (uint32_t(data[3]) << 4) | (uint32_t(data[4]) >> 4); diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 3f0082e1af7..fe24ba88754 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -191,11 +191,7 @@ void BME680BSECComponent::loop() { } else { this->status_clear_error(); } - if (this->bsec_status_ > BSEC_OK || this->bme680_status_ > BME680_OK) { - this->status_set_warning(); - } else { - this->status_clear_warning(); - } + this->status_comm_result(this->bsec_status_ <= BSEC_OK && this->bme680_status_ <= BME680_OK, nullptr); // Process a single action from the queue. These are primarily sensor state publishes // that in totality take too long to send in a single call. diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index c563e120842..1be5c3835e4 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -126,11 +126,7 @@ void BME68xBSEC2Component::check_status_() { this->status_clear_error(); } const bool has_bme68x_warning = this->bme68x_status_ > BME68X_OK && !is_no_new_data_warning(this->bme68x_status_); - if (this->bsec_status_ > BSEC_OK || has_bme68x_warning) { - this->status_set_warning(); - } else { - this->status_clear_warning(); - } + this->status_comm_result(this->bsec_status_ <= BSEC_OK && !has_bme68x_warning, nullptr); } void BME68xBSEC2Component::loop() { diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index 8739e6c0805..1b51030b162 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -101,15 +101,13 @@ bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t co uint8_t response[4] = {0}; if (!this->cm1106_write_command_(command, command_len, response, sizeof(response))) { ESP_LOGW(TAG, "Command %02X failed", command[2]); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } - if (memcmp(response, ack, sizeof(response)) != 0) { + if (!this->status_comm_result(memcmp(response, ack, sizeof(response)) == 0)) { ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); - this->status_set_warning(); return false; } - this->status_clear_warning(); return true; } diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp index 849cff4a49e..7f98051d2bc 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp @@ -20,12 +20,10 @@ void CST226Touchscreen::setup() { void CST226Touchscreen::update_touches() { uint8_t data[28]; - if (!this->read_bytes(CST226_REG_STATUS, data, sizeof data)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_bytes(CST226_REG_STATUS, data, sizeof data))) { this->skip_update_ = true; return; } - this->status_clear_warning(); if (data[0] == 0x83 && data[1] == 0x17 && data[5] == 0x80) { this->update_button_state_(true); return; diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp index 72803804335..6d869ec5ea7 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -87,12 +87,10 @@ void CST9220Touchscreen::update_touches() { // Only an actual I2C failure should skip the update; a successful read with no // touches is a real "all fingers lifted" state that must flow through so the // base class can generate the release event. - if (this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) == i2c::ERROR_OK)) { this->skip_update_ = true; return; } - this->status_clear_warning(); // Acknowledge the report so the controller can prepare the next one. uint8_t ack = TOUCH_ACK; diff --git a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp index 78267d5ff9e..5fffd71fe91 100644 --- a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp +++ b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp @@ -255,11 +255,7 @@ void DFRobotSEN0465Component::handle_data_(const uint8_t *frame) { if (this->concentration_sensor_ != nullptr) this->concentration_sensor_->publish_state(concentration); - if (std::isnan(concentration) || std::isnan(temperature) || this->setup_incomplete_) { - this->status_set_warning(); - } else { - this->status_clear_warning(); - } + this->status_comm_result(!std::isnan(concentration) && !std::isnan(temperature) && !this->setup_incomplete_, nullptr); } #ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.cpp b/esphome/components/ds3231/sensor/ds3231_temperature.cpp index 8d7e1156be4..569f24cab5e 100644 --- a/esphome/components/ds3231/sensor/ds3231_temperature.cpp +++ b/esphome/components/ds3231/sensor/ds3231_temperature.cpp @@ -7,11 +7,8 @@ ESPHOME_LOG_TAG(TAG, "ds3231.sensor"); void DS3231TemperatureSensor::update() { float temperature; - if (!this->parent_->read_temperature(temperature)) { - this->status_set_warning(); + if (!this->status_comm_result(this->parent_->read_temperature(temperature))) return; - } - this->status_clear_warning(); this->publish_state(temperature); } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 420ff64ddc6..24f3a77a7ce 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -212,12 +212,9 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { #else error = rmt_transmit(this->channel_, this->encoder_, this->rmt_buf_, len * sizeof(rmt_symbol_word_t), &config); #endif - if (error != ESP_OK) { + if (!this->status_comm_result(error == ESP_OK, nullptr)) { ESP_LOGE(TAG, "RMT TX error"); - this->status_set_warning(); - return; } - this->status_clear_warning(); } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 2197a086c36..734e133336f 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -55,14 +55,11 @@ void HC8Component::update() { return; } - if (crc16(response.data(), 12) != encode_uint16(response[13], response[12])) { + if (!this->status_comm_result(crc16(response.data(), 12) == encode_uint16(response[13], response[12]))) { ESP_LOGW(TAG, "HC8 Checksum mismatch"); - this->status_set_warning(); return; } - this->status_clear_warning(); - const uint16_t ppm = encode_uint16(response[5], response[4]); ESP_LOGD(TAG, "HC8 Received CO₂=%uppm", ppm); if (this->co2_sensor_ != nullptr) diff --git a/esphome/components/hdc302x/hdc302x.cpp b/esphome/components/hdc302x/hdc302x.cpp index d5428486579..ffe03190b3e 100644 --- a/esphome/components/hdc302x/hdc302x.cpp +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -133,12 +133,9 @@ void HDC302XComponent::read_data_() { } // Check checksums - if (crc8(buf, 2, 0xff, 0x31, true) != buf[2] || crc8(buf + 3, 2, 0xff, 0x31, true) != buf[5]) { - this->status_set_warning(LOG_STR("Read data: invalid CRC")); + const bool crc_ok = crc8(buf, 2, 0xff, 0x31, true) == buf[2] && crc8(buf + 3, 2, 0xff, 0x31, true) == buf[5]; + if (!this->status_comm_result(crc_ok, LOG_STR("Read data: invalid CRC"))) return; - } - - this->status_clear_warning(); if (this->temp_sensor_ != nullptr) { uint16_t raw_t = encode_uint16(buf[0], buf[1]); diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp index ab2afc74d93..d4fc2e9b689 100644 --- a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -39,8 +39,7 @@ void HoermannHcpCover::control(const cover::CoverCall &call) { } void HoermannHcpCover::update_from_state_() { - if (!this->parent_->is_valid()) { - this->status_set_warning(); + if (!this->status_comm_result(this->parent_->is_valid(), LOG_STR("bus controller not responding"))) { // The door can now move unheard, so drop the baseline a direction would be inferred from and stop // reporting motion instead of leaving the cover travelling until the controller returns. this->previous_position_ = NAN; @@ -50,7 +49,6 @@ void HoermannHcpCover::update_from_state_() { } return; } - this->status_clear_warning(); const auto previous_operation = this->current_operation; const float current_position = this->parent_->get_current_position(); diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp index 6195c5d63d8..6a3028ad2fe 100644 --- a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -53,12 +53,9 @@ void HoermannHcpLight::update_from_state_() { this->status_set_warning(LOG_STR("bus controller not responding")); return; } - if (!this->parent_->is_light_known()) { - // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. - this->status_set_warning(LOG_STR("door has not reported the lamp")); + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + if (!this->status_comm_result(this->parent_->is_light_known(), LOG_STR("door has not reported the lamp"))) return; - } - this->status_clear_warning(); const bool heading_on = this->parent_->is_light_heading_on(); if (this->light_state_->remote_values.is_on() != heading_on) this->publish_lamp_state_(heading_on); diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp index d0f9973b2a4..3d39b948b38 100644 --- a/esphome/components/husb238/husb238.cpp +++ b/esphome/components/husb238/husb238.cpp @@ -14,11 +14,8 @@ void Husb238Component::setup() { void Husb238Component::update() { uint8_t status; - if (!this->read_byte(REG_PD_STATUS1, &status)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_byte(REG_PD_STATUS1, &status))) return; - } - this->status_clear_warning(); #ifdef USE_BINARY_SENSOR if (this->attached_binary_sensor_ != nullptr) { diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index 9a81befcc45..c6bf03b72e1 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -59,14 +59,11 @@ bool HX711Sensor::read_sensor_(uint32_t *result) { final_dout = this->dout_pin_->digital_read(); } - if (!final_dout) { + if (!this->status_comm_result(final_dout)) { ESP_LOGW(TAG, "HX711 DOUT pin not high after reading (data 0x%" PRIx32 ")!", data); - this->status_set_warning(); return false; } - this->status_clear_warning(); - data = static_cast(sign_extend<24>(data)); if (result != nullptr) diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 16b37568d91..97f7f211370 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -172,11 +172,7 @@ void INA2XX::loop() { break; case State::DATA_COLLECTION_8: - if (this->full_loop_is_okay_) { - this->status_clear_warning(); - } else { - this->status_set_warning(); - } + this->status_comm_result(this->full_loop_is_okay_); this->state_ = State::IDLE; break; diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp index 7af60be87a4..949ebe14509 100644 --- a/esphome/components/lis2dw12/lis2dw12.cpp +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -291,11 +291,8 @@ void LIS2DW12Component::loop() { // 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(); + if (!this->status_comm_result(this->read_bytes(static_cast(RegisterMap::SIXD_SRC), regs, sizeof(regs)))) return; - } - this->status_clear_warning(); RegSixdSrc sixd; sixd.raw = regs[0]; RegAllIntSrc src; @@ -315,11 +312,9 @@ void LIS2DW12Component::loop() { } void LIS2DW12Component::update() { - float x, y, z; - if (!this->read_acceleration_data_(x, y, z)) { - this->status_set_warning(); + float x{}, y{}, z{}; + if (!this->status_comm_result(this->read_acceleration_data_(x, y, z))) return; - } ESP_LOGV(TAG, "Acceleration: {x = %+1.3f m/s², y = %+1.3f m/s², z = %+1.3f m/s²}", x, y, z); @@ -331,8 +326,6 @@ void LIS2DW12Component::update() { 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) { diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp index da392ea5c04..2010f93f0c3 100644 --- a/esphome/components/lp5562/lp5562_output.cpp +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -73,11 +73,7 @@ void LP5562Output::dump_config() { 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(); + this->status_comm_result(this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)); } void LP5562Channel::write_state(float state) { diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp index 680b3bcd9e7..0eda7d9864d 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp @@ -4,12 +4,9 @@ namespace esphome::m5stack_8angle { void M5Stack8AngleSwitchBinarySensor::update() { int8_t out = this->parent_->read_switch(); - if (out == -1) { - this->status_set_warning(LOG_STR("Could not read binary sensor state from M5Stack 8Angle.")); + if (!this->status_comm_result(out != -1, LOG_STR("Could not read binary sensor state from M5Stack 8Angle."))) return; - } this->publish_state(out != 0); - this->status_clear_warning(); } } // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp index b05e1e68163..24a40dd9d4d 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp @@ -5,17 +5,14 @@ namespace esphome::m5stack_8angle { void M5Stack8AngleKnobSensor::update() { if (this->parent_ != nullptr) { int32_t raw_pos = this->parent_->read_knob_pos_raw(this->channel_, this->bits_); - if (raw_pos == -1) { - this->status_set_warning(LOG_STR("Could not read knob position from M5Stack 8Angle.")); + if (!this->status_comm_result(raw_pos != -1, LOG_STR("Could not read knob position from M5Stack 8Angle."))) return; - } if (this->raw_) { this->publish_state(raw_pos); } else { float knob_pos = (float) raw_pos / ((1 << this->bits_) - 1); this->publish_state(knob_pos); } - this->status_clear_warning(); }; } diff --git a/esphome/components/max6675/max6675.cpp b/esphome/components/max6675/max6675.cpp index 2fc8dbb73d7..14eaca89164 100644 --- a/esphome/components/max6675/max6675.cpp +++ b/esphome/components/max6675/max6675.cpp @@ -31,17 +31,15 @@ void MAX6675Sensor::read_data_() { uint16_t val = data[1] | (uint16_t(data[0]) << 8); this->disable(); - if ((val & 0x04) != 0) { - // Thermocouple open + // Bit 2 set means the thermocouple is open + if (!this->status_comm_result((val & 0x04) == 0, nullptr)) { ESP_LOGW(TAG, "Got invalid value from MAX6675Sensor (0x%04X)", val); - this->status_set_warning(); return; } float temperature = float(val >> 3) / 4.0f; ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", this->name_.c_str(), temperature); this->publish_state(temperature); - this->status_clear_warning(); } } // namespace esphome::max6675 diff --git a/esphome/components/mcp3221/mcp3221_sensor.cpp b/esphome/components/mcp3221/mcp3221_sensor.cpp index d38883c3421..94e7174baa7 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.cpp +++ b/esphome/components/mcp3221/mcp3221_sensor.cpp @@ -8,12 +8,10 @@ ESPHOME_LOG_TAG(TAG, "mcp3221"); float MCP3221Sensor::sample() { uint8_t data[2]; - if (this->read(data, 2) != i2c::ERROR_OK) { + if (!this->status_comm_result(this->read(data, 2) == i2c::ERROR_OK)) { ESP_LOGW(TAG, "Read failed"); - this->status_set_warning(); return NAN; } - this->status_clear_warning(); uint16_t value = encode_uint16(data[0], data[1]); float voltage = value * this->reference_voltage_ / 4096.0f; diff --git a/esphome/components/mcp9808/mcp9808.cpp b/esphome/components/mcp9808/mcp9808.cpp index 309651970ca..2c96b4119d3 100644 --- a/esphome/components/mcp9808/mcp9808.cpp +++ b/esphome/components/mcp9808/mcp9808.cpp @@ -68,14 +68,11 @@ void MCP9808Sensor::update() { temp = (uint16_t) (msb) *16 + lsb / 16.0f; } - if (std::isnan(temp)) { - this->status_set_warning(); + if (!this->status_comm_result(!std::isnan(temp), nullptr)) return; - } ESP_LOGD(TAG, "%s: Got temperature=%.4f°C", this->name_.c_str(), temp); this->publish_state(temp); - this->status_clear_warning(); } } // namespace esphome::mcp9808 diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index eda4b4d8e78..3eb71c1adad 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -66,13 +66,11 @@ void MHZ19Component::update() { } uint8_t checksum = mhz19_checksum(response); - if (response[8] != checksum) { + if (!this->status_comm_result(response[8] == checksum)) { ESP_LOGW(TAG, "MHZ19 Checksum doesn't match: 0x%02X!=0x%02X", response[8], checksum); - this->status_set_warning(); return; } - this->status_clear_warning(); const uint16_t ppm = (uint16_t(response[2]) << 8) | response[3]; const int temp = int(response[4]) - 40; const uint8_t status = response[5]; diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index 957cdf90ef2..c42ca7ab465 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -43,11 +43,8 @@ void MICS4514Component::update() { return; } uint8_t data[6]; - if (this->read_register(SENSOR_REGISTER, data, 6) != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_register(SENSOR_REGISTER, data, 6) == i2c::ERROR_OK)) return; - } - this->status_clear_warning(); ESP_LOGV(TAG, "Got data: %02X %02X %02X %02X %02X %02X", data[0], data[1], data[2], data[3], data[4], data[5]); uint16_t ox = encode_uint16(data[0], data[1]); uint16_t red = encode_uint16(data[2], data[3]); diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index bbcee223e8c..781028e7f5c 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -112,13 +112,7 @@ bool MPR121Component::flush_gpio_() { this->write_byte(MPR121_GPIOEN, this->gpio_enable_); this->write_byte(MPR121_GPIODIR, this->gpio_direction_); - if (!this->write_byte(MPR121_GPIODATA, this->gpio_output_)) { - this->status_set_warning(); - return false; - } - - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte(MPR121_GPIODATA, this->gpio_output_)); } void MPR121GPIOPin::setup() { this->pin_mode(this->flags_); } diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 8c0acbbe434..813321fac9e 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -289,14 +289,11 @@ void NAU7802Sensor::loop() { } void NAU7802Sensor::update() { - if (!this->is_data_ready_()) { + if (!this->status_comm_result(this->is_data_ready_(), nullptr)) { ESP_LOGW(TAG, "No measurements ready!"); - this->status_set_warning(); return; } - this->status_clear_warning(); - // Get the most recent sample to publish int32_t result = this->read_value_(ADCO_B2_REG, 3); diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index 374fd707561..caf7decde94 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -104,13 +104,11 @@ void OPT3001Sensor::update() { this->read_lx_([this](float val) { this->updating_ = false; - if (std::isnan(val)) { - this->status_set_warning(); + if (!this->status_comm_result(!std::isnan(val))) { this->publish_state(NAN); return; } ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), val); - this->status_clear_warning(); this->publish_state(val); }); } diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 0825d6eac98..5f5b4ea4a10 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -138,13 +138,10 @@ bool PCA6416AComponent::read_register_(uint8_t reg, uint8_t *value) { } this->last_error_ = this->read_register(reg, value, 1); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - - this->status_clear_warning(); return true; } @@ -155,13 +152,10 @@ bool PCA6416AComponent::write_register_(uint8_t reg, uint8_t value) { } this->last_error_ = this->write_register(reg, &value, 1); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - - this->status_clear_warning(); return true; } diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index a12d291f6b2..4f44143c547 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -110,12 +110,10 @@ bool PCA9554Component::read_inputs_() { } this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - this->status_clear_warning(); this->input_mask_ = inputs[0]; if (this->reg_width_ == 2) { this->input_mask_ |= inputs[1] << 8; @@ -128,13 +126,10 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { outputs[0] = (uint8_t) value; outputs[1] = (uint8_t) (value >> 8); this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - - this->status_clear_warning(); return true; } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 70068ea84da..5674b6a53e8 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -87,12 +87,9 @@ bool PCF8574Component::read_gpio_() { if (this->is_failed()) return false; uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. - if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1))) return false; - } this->input_mask_ = encode_value_le(data); - this->status_clear_warning(); return true; } bool PCF8574Component::write_gpio_() { @@ -108,13 +105,7 @@ bool PCF8574Component::write_gpio_() { uint8_t data[2]; data[0] = value; data[1] = value >> 8; - if (this->write(data, this->pcf8575_ ? 2 : 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return false; - } - - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write(data, this->pcf8575_ ? 2 : 1) == i2c::ERROR_OK); } float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 0b5af94e696..28f98325ea4 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -105,15 +105,13 @@ bool PI4IOE5V6408Component::read_gpio_modes_() { return false; uint8_t data; - if (!this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data)) { - this->status_set_warning(LOG_STR("Failed to read GPIO modes")); + if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data), + LOG_STR("Failed to read GPIO modes"))) return false; - } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Read GPIO modes: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(data)); #endif this->mode_mask_ = data; - this->status_clear_warning(); return true; } @@ -140,14 +138,12 @@ void PI4IOE5V6408Component::digital_write_hw(uint8_t pin, bool value) { } else { this->output_mask_ &= ~(1 << pin); } - if (!this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_)) { - this->status_set_warning(LOG_STR("Failed to write output register")); + if (!this->status_comm_result(this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_), + LOG_STR("Failed to write output register"))) return; - } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Wrote GPIO output: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(this->output_mask_)); #endif - this->status_clear_warning(); } bool PI4IOE5V6408Component::write_gpio_modes_() { diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index fd38edf136a..c83726949d9 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -106,16 +106,15 @@ void PN532::update() { obj->on_scan_end(); #endif - if (!this->write_command_({ - PN532_COMMAND_INLISTPASSIVETARGET, - 0x01, // max 1 card - 0x00, // baud rate ISO14443A (106 kbit/s) - })) { + const bool ok = this->write_command_({ + PN532_COMMAND_INLISTPASSIVETARGET, + 0x01, // max 1 card + 0x00, // baud rate ISO14443A (106 kbit/s) + }); + if (!this->status_comm_result(ok)) { ESP_LOGW(TAG, "Requesting tag read failed!"); - this->status_set_warning(); return; } - this->status_clear_warning(); this->requested_read_ = true; } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 9dce402fe02..83344de479d 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -288,12 +288,10 @@ bool QMP6988Component::calculate_pressure_() { int32_t t_int, p_int; err = this->read_register(QMP6988_PRESSURE_MSB_REG, a_data_uint8_tr, 6); - if (err != i2c::ERROR_OK) { + if (!this->status_comm_result(err == i2c::ERROR_OK)) { ESP_LOGE(TAG, "Error reading raw pressure/temp values"); - this->status_set_warning(); return false; } - this->status_clear_warning(); p_read = encode_uint24(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[2]); p_raw = (int32_t) (p_read - SUBTRACTOR); diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 22a63ca4fd3..18ad08ab6d2 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -284,14 +284,12 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen this->store_.index = offset; esp_err_t error = rmt_transmit(this->channel_, this->encoder_, this->rmt_temp_.data(), this->rmt_temp_.size() * sizeof(rmt_symbol_half_t), &config); - if (error != ESP_OK) { + if (!this->status_comm_result(error == ESP_OK, nullptr)) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); - this->status_set_warning(); // nothing was queued, so there is no frame to wait for this->fire_complete_(false); return; } - this->status_clear_warning(); if (this->non_blocking_) { this->set_timeout(COMPLETE_TIMEOUT_ID, total_duration / 1000, [this]() { this->wait_for_rmt_(); }); diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index c94c64f8611..f33d39b8d0c 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -52,12 +52,10 @@ void RX8025TComponent::read_time() { } uint8_t date[7]; - if (!this->read_bytes(RX8025T_REG_SEC, date, sizeof(date))) { + if (!this->status_comm_result(this->read_bytes(RX8025T_REG_SEC, date, sizeof(date)))) { ESP_LOGE(TAG, "Can't read I2C data."); - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - this->status_clear_warning(); if (flags & RX8025T_FLAG_VLF) { ESP_LOGW(TAG, "VLF flag is set - time data may be invalid, not syncing to system clock."); diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp index b3f26cb2611..531c4233e47 100644 --- a/esphome/components/rx8111/rx8111.cpp +++ b/esphome/components/rx8111/rx8111.cpp @@ -81,13 +81,11 @@ void RX8111Component::read_time() { this->status_set_warning(LOG_STR("Invalid RTC time")); return; } - if (ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week != weekday) { + if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == weekday, + LOG_STR("Invalid RTC time"))) { ESP_LOGW(TAG, "RTC weekday does not match calendar date"); - this->status_set_warning(LOG_STR("Invalid RTC time")); return; } - - this->status_clear_warning(); ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second); time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index 9a895b0e0e9..db9eca8288c 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -133,11 +133,7 @@ void SelecMeter::on_read_input_registers(uint16_t start_address, std::spandecode_em2m_(registers); #endif - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(); - } + this->status_comm_result(ok); break; } #if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) diff --git a/esphome/components/sen21231/sen21231.cpp b/esphome/components/sen21231/sen21231.cpp index d45b247a437..b05e21eaacb 100644 --- a/esphome/components/sen21231/sen21231.cpp +++ b/esphome/components/sen21231/sen21231.cpp @@ -15,12 +15,10 @@ void Sen21231Sensor::dump_config() { void Sen21231Sensor::read_data_() { person_sensor_results_t results; - if (!this->read_bytes(PERSON_SENSOR_I2C_ADDRESS, (uint8_t *) &results, sizeof(results))) { + if (!this->status_comm_result(this->read_bytes(PERSON_SENSOR_I2C_ADDRESS, (uint8_t *) &results, sizeof(results)))) { ESP_LOGW(TAG, "Failed to read data from SEN21231"); - this->status_set_warning(); return; } - this->status_clear_warning(); ESP_LOGD(TAG, "SEN21231: %d faces detected", results.num_faces); this->publish_state(results.num_faces); if (results.num_faces == 1) { diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 9f95ed192c0..4e8a79124c6 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -678,8 +678,7 @@ void SEN6XComponent::parse_and_publish_number_concentration_() { this->pmc_4_0_sensor_, this->pmc_10_0_sensor_}; uint16_t measurements[std::size(sensors)]; - if (!this->read_data(measurements, std::size(measurements))) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_data(measurements, std::size(measurements)))) { ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); return; } @@ -688,7 +687,6 @@ void SEN6XComponent::parse_and_publish_number_concentration_() { if (sensors[i] != nullptr) sensors[i]->publish_state(measurements[i] == 0xFFFF ? NAN : measurements[i] / 10.0f); } - this->status_clear_warning(); } #endif diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index d5849ddfc0a..7b40d0d8409 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -44,13 +44,11 @@ void SenseAirComponent::update() { uint16_t calc_checksum = crc16(response, 11); uint16_t resp_checksum = encode_value_le(&response[11]); - if (resp_checksum != calc_checksum) { + if (!this->status_comm_result(resp_checksum == calc_checksum)) { ESP_LOGW(TAG, "SenseAir checksum doesn't match: 0x%02X!=0x%02X", resp_checksum, calc_checksum); - this->status_set_warning(); return; } - this->status_clear_warning(); const uint8_t length = response[2]; const uint16_t status = encode_uint16(response[3], response[4]); const uint16_t ppm = encode_uint16(response[length + 1], response[length + 2]); diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index d46020b2d9a..5e6b8849dd1 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -87,15 +87,12 @@ void SHT4XComponent::update() { uint16_t buffer[2]; // Read measurement - if (!this->read_data(buffer, 2)) { + if (!this->status_comm_result(this->read_data(buffer, 2))) { // Using ESP_LOGW to force the warning to be printed ESP_LOGW(TAG, "Sensor read failed"); - this->status_set_warning(); return; } - this->status_clear_warning(); - // Evaluate and publish measurements if (this->temp_sensor_ != nullptr) { // Temp is contained in the first result word diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp index 55fa177d18e..b70fe1acad9 100644 --- a/esphome/components/spd2010/spd2010_touchscreen.cpp +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -147,11 +147,9 @@ bool SPD2010Touchscreen::read_data_() { return true; } if (!(status_low & (STATUS_POINT | STATUS_GESTURE))) { - if ((status_high & STATUS_RUNNING) && (status_low & STATUS_AUX) && !this->clear_interrupt_()) { - this->status_set_warning(LOG_STR("Failed to clear interrupt")); - return false; - } - this->status_clear_warning(); + const bool needs_clear = (status_high & STATUS_RUNNING) && (status_low & STATUS_AUX); + const bool cleared = !needs_clear || this->clear_interrupt_(); + this->status_comm_result(cleared, LOG_STR("Failed to clear interrupt")); return false; } if (length < REPORT_HEADER_LENGTH || length > MAX_REPORT_LENGTH || diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp index 5cd986f6976..2f21de3f6d3 100644 --- a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp @@ -73,12 +73,10 @@ void ST7123Touchscreen::update_touches() { // Reading from this register also clears the INT pin so the controller can report the next frame. uint8_t data[(ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + ST7123_MAX_TOUCHES * ST7123_TOUCH_STRIDE]; const size_t len = (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + this->max_touches_ * ST7123_TOUCH_STRIDE; - if (this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) != i2c::ERROR_OK) { + if (!this->status_comm_result(this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) == i2c::ERROR_OK)) { this->skip_update_ = true; - this->status_set_warning(); return; } - this->status_clear_warning(); const uint8_t *points = data + (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO); for (uint8_t i = 0; i != this->max_touches_; i++) { diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index fd9569b951d..9e224a024c6 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -50,15 +50,12 @@ void STS3XComponent::update() { this->set_timeout(50, [this]() { uint16_t raw_data[1]; - if (!this->read_data(raw_data, 1)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_data(raw_data, 1))) return; - } float temperature = 175.0f * float(raw_data[0]) / 65535.0f - 45.0f; ESP_LOGD(TAG, "Got temperature=%.2f°C", temperature); this->publish_state(temperature); - this->status_clear_warning(); }); } diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 754cfb14ca8..3efc4d8a3f9 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -123,14 +123,11 @@ void SY6970Component::update() { } // Read all registers in one transaction - if (!this->read_all_registers_()) { + if (!this->status_comm_result(this->read_all_registers_())) { ESP_LOGW(TAG, "Failed to read registers during update"); - this->status_set_warning(); return; } - this->status_clear_warning(); - // Notify all listeners with the new data for (auto *listener : this->listeners_) { listener->on_data(this->data_); diff --git a/esphome/components/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index 6fb58345358..e06d3277842 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -44,18 +44,15 @@ void T6615Component::loop() { this->read_array(response_buffer, 5); // Read header - if (response_buffer[0] != T6615_MAGIC || response_buffer[1] != T6615_ADDR_HOST) { + if (!this->status_comm_result(response_buffer[0] == T6615_MAGIC && response_buffer[1] == T6615_ADDR_HOST)) { ESP_LOGW(TAG, "Got bad data from T6615! Magic was %02X and address was %02X", response_buffer[0], response_buffer[1]); this->discard_available(); /* try again to read the sensor */ this->send_ppm_command_(); - this->status_set_warning(); return; } - this->status_clear_warning(); - switch (this->command_) { case T6615Command::GET_PPM: { const uint16_t ppm = encode_uint16(response_buffer[3], response_buffer[4]); diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp index 204bc18c1e5..27e3e0337e5 100644 --- a/esphome/components/tca8418/tca8418.cpp +++ b/esphome/components/tca8418/tca8418.cpp @@ -86,11 +86,8 @@ void TCA8418Component::loop() { return; this->last_poll_ = now; - if (!this->drain_events_(true)) { - this->status_set_warning(); + if (!this->status_comm_result(this->drain_events_(true))) 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()) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 6ee4c9e3ee6..e050325436c 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -451,12 +451,10 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { 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) { + if (!this->status_comm_result(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; @@ -483,12 +481,11 @@ optional TMC22XXStepper::read_register(uint8_t 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)) { + if (!this->status_comm_result(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]); } diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 0cb2456cbd3..58858b9b386 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -29,12 +29,10 @@ void TMP1075Sensor::setup() { void TMP1075Sensor::update() { uint16_t regvalue; - if (!read_byte_16(REG_TEMP, ®value)) { + if (!this->status_comm_result(this->read_byte_16(REG_TEMP, ®value), LOG_STR("can't read"))) { ESP_LOGW(TAG, "'%s' - unable to read temperature register", this->name_.c_str()); - this->status_set_warning(LOG_STR("can't read")); return; } - this->status_clear_warning(); const float temp = regvalue2temp(regvalue); this->publish_state(temp); diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 0ae96c9fd1c..33f33bda104 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -119,15 +119,12 @@ void TSL2561Sensor::read_data_() { return; } // Power off - if (!this->tsl2561_write_byte(TSL2561_REGISTER_CONTROL, 0b00000000)) { - this->status_set_warning(); + if (!this->status_comm_result(this->tsl2561_write_byte(TSL2561_REGISTER_CONTROL, 0b00000000))) return; - } float lx = this->calculate_lx_(data0, data1); ESP_LOGD(TAG, "Got illuminance=%.1flx", lx); this->publish_state(lx); - this->status_clear_warning(); } float TSL2561Sensor::get_integration_time_ms_() { switch (this->integration_time_) { diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index bf5c3f67284..f50e5392915 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -18,12 +18,9 @@ void TTP229LSFComponent::dump_config() { } void TTP229LSFComponent::loop() { uint16_t touched = 0; - if (this->read(reinterpret_cast(&touched), 2) != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->read(reinterpret_cast(&touched), 2) == i2c::ERROR_OK)) return; - } touched = i2c::i2ctohs(touched); - this->status_clear_warning(); touched = reverse_bits(touched); for (auto *channel : this->channels_) { channel->process(touched); diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index ffaaf9eb2a3..1b8984247a4 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -54,15 +54,12 @@ void VEML3235Sensor::update() { void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { uint8_t als_regs[] = {0, 0}; - if ((this->read_register(ALS_REG, als_regs, sizeof als_regs) != i2c::ERROR_OK)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_register(ALS_REG, als_regs, sizeof als_regs) == i2c::ERROR_OK)) { this->publish_state(NAN); this->measurement_in_progress_ = false; return; } - this->status_clear_warning(); - uint16_t als_counts = encode_value_le(als_regs); if (this->auto_gain_ && adjustments_left > 0) { From 5575dd8928e0fd09a5fa02308fd72e1fc803d5e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:46:57 -1000 Subject: [PATCH 623/651] [core] Add final_validate.get_declaring_config (#20912) --- esphome/final_validate.py | 17 ++++++++- tests/unit_tests/test_final_validate.py | 47 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/test_final_validate.py diff --git a/esphome/final_validate.py b/esphome/final_validate.py index cebd2f1cdad..46be8f0b3f5 100644 --- a/esphome/final_validate.py +++ b/esphome/final_validate.py @@ -3,7 +3,7 @@ import contextvars from typing import Any import esphome.config_validation as cv -from esphome.types import ID, ConfigFragmentType, ConfigPathType +from esphome.types import ID, ConfigFragmentType, ConfigPathType, ConfigType class FinalValidateConfig(ABC): @@ -39,6 +39,21 @@ full_config: contextvars.ContextVar[FinalValidateConfig] = contextvars.ContextVa ) +def get_declaring_config( + id_: ID, config: FinalValidateConfig | None = None +) -> ConfigType: + """Return the config block that declares id_, such as a hub's own config. + + Uses the full config being validated unless config is given. + Raises KeyError if the id was not declared, TypeError if its parent is not a dict. + """ + fconf = full_config.get() if config is None else config + declaring = fconf.get_config_for_path(fconf.get_path_for_id(id_)[:-1]) + if not isinstance(declaring, dict): + raise TypeError(f"ID {id_} is not declared in a config block") + return declaring + + def id_declaration_match_schema(schema): """A final-validation schema function that applies a schema to the outer config fragment of an ID declaration. diff --git a/tests/unit_tests/test_final_validate.py b/tests/unit_tests/test_final_validate.py new file mode 100644 index 00000000000..834dc92ffbd --- /dev/null +++ b/tests/unit_tests/test_final_validate.py @@ -0,0 +1,47 @@ +"""Tests for esphome.final_validate helpers.""" + +import pytest + +from esphome import final_validate as fv +from esphome.config import Config +from esphome.const import CONF_ID +from esphome.core import ID + + +def _config() -> tuple[Config, ID, dict]: + hub_id = ID("my_hub", is_declaration=True) + hub = {CONF_ID: hub_id, "mode": "fast"} + config = Config() + config["other"] = {"x": 1} + config["my_comp"] = [{"y": 2}, hub] + config.declare_ids.append((hub_id, ["my_comp", 1, CONF_ID])) + return config, hub_id, hub + + +def test_returns_the_declaring_block_from_the_full_config() -> None: + config, hub_id, hub = _config() + token = fv.full_config.set(config) + try: + assert fv.get_declaring_config(hub_id) is hub + finally: + fv.full_config.reset(token) + + +def test_uses_the_given_config() -> None: + config, hub_id, hub = _config() + assert fv.get_declaring_config(ID("my_hub"), config) is hub + + +def test_unknown_id_raises_key_error() -> None: + config, _, _ = _config() + with pytest.raises(KeyError): + fv.get_declaring_config(ID("missing"), config) + + +def test_non_dict_parent_raises_type_error() -> None: + config, _, _ = _config() + list_id = ID("in_list", is_declaration=True) + config["listed"] = [list_id] + config.declare_ids.append((list_id, ["listed", 0])) + with pytest.raises(TypeError): + fv.get_declaring_config(ID("in_list"), config) From 1767e4b0be55984be42d172a5771f20deb836acf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:47:19 -1000 Subject: [PATCH 624/651] [sensor] Add SensorWithDedup for child sensors that skip repeated values (#20925) --- esphome/components/ld24xx/ld24xx.h | 38 +---------- esphome/components/sensor/sensor_with_dedup.h | 49 +++++++++++++++ .../sensor/sensor_with_dedup_test.cpp | 63 +++++++++++++++++++ 3 files changed, 114 insertions(+), 36 deletions(-) create mode 100644 esphome/components/sensor/sensor_with_dedup.h create mode 100644 tests/components/sensor/sensor_with_dedup_test.cpp diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index deac04e86f9..b7dddb92fad 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -7,14 +7,7 @@ #include #ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" - -#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ - protected: \ - ld24xx::SensorWithDedup name##_sensor_{}; \ -\ - public: \ - void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } +#include "esphome/components/sensor/sensor_with_dedup.h" #endif #define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \ @@ -61,33 +54,6 @@ inline void format_version_str(const uint8_t *version, std::span buffe } #ifdef USE_SENSOR -/// Sensor with deduplication — sensor may be null, null check is internal. -/// Stored inline, no heap allocation. Does nothing when no sensor is set. -template class SensorWithDedup { - public: - void set_sensor(sensor::Sensor *sens) { - this->sens_ = sens; - this->dedup_ = {}; - } - - void publish_state_if_not_dup(T state) { - if (this->sens_ != nullptr && this->dedup_.next(state)) { - this->sens_->publish_state(static_cast(state)); - } - } - - void publish_state_unknown() { - if (this->sens_ != nullptr && this->dedup_.next_unknown()) { - this->sens_->publish_state(NAN); - } - } - - bool has_sensor() const { return this->sens_ != nullptr; } - sensor::Sensor *get_sensor() const { return this->sens_; } - - protected: - sensor::Sensor *sens_{nullptr}; - Deduplicator dedup_; -}; +template using SensorWithDedup = sensor::SensorWithDedup; #endif } // namespace esphome::ld24xx diff --git a/esphome/components/sensor/sensor_with_dedup.h b/esphome/components/sensor/sensor_with_dedup.h new file mode 100644 index 00000000000..6fab40f1e68 --- /dev/null +++ b/esphome/components/sensor/sensor_with_dedup.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::sensor { + +/// Declare an optional child sensor that skips publishing a value equal to the last one. +#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ + protected: \ + sensor::SensorWithDedup name##_sensor_{}; \ +\ + public: \ + void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } + +#define LOG_SENSOR_WITH_DEDUP(prefix, type, s) LOG_SENSOR(prefix, type, (s).get_sensor()) + +/// A sensor pointer that may be null plus the last value it published, stored inline. +/// Publishing does nothing when no sensor is set, and skips a value equal to the last one. +template class SensorWithDedup { + public: + void set_sensor(Sensor *sens) { + this->sens_ = sens; + this->dedup_ = {}; + } + + void publish_state_if_not_dup(T state) { + if (this->sens_ != nullptr && this->dedup_.next(state)) { + this->sens_->publish_state(static_cast(state)); + } + } + + void publish_state_unknown() { + if (this->sens_ != nullptr && this->dedup_.next_unknown()) { + this->sens_->publish_state(NAN); + } + } + + bool has_sensor() const { return this->sens_ != nullptr; } + Sensor *get_sensor() const { return this->sens_; } + + protected: + Sensor *sens_{nullptr}; + Deduplicator dedup_; +}; + +} // namespace esphome::sensor diff --git a/tests/components/sensor/sensor_with_dedup_test.cpp b/tests/components/sensor/sensor_with_dedup_test.cpp new file mode 100644 index 00000000000..1bff52193b0 --- /dev/null +++ b/tests/components/sensor/sensor_with_dedup_test.cpp @@ -0,0 +1,63 @@ +#include +#include + +#include "esphome/components/sensor/sensor_with_dedup.h" + +namespace esphome::sensor::testing { + +class SensorWithDedupTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.add_on_state_callback([this](float value) { this->published_.push_back(value); }); + } + + Sensor sensor_; + std::vector published_; +}; + +TEST_F(SensorWithDedupTest, NoSensorDoesNothing) { + SensorWithDedup dedup; + EXPECT_FALSE(dedup.has_sensor()); + EXPECT_EQ(dedup.get_sensor(), nullptr); + dedup.publish_state_if_not_dup(5); + dedup.publish_state_unknown(); + EXPECT_TRUE(this->published_.empty()); +} + +TEST_F(SensorWithDedupTest, SkipsRepeatedValues) { + SensorWithDedup dedup; + dedup.set_sensor(&this->sensor_); + EXPECT_TRUE(dedup.has_sensor()); + EXPECT_EQ(dedup.get_sensor(), &this->sensor_); + dedup.publish_state_if_not_dup(-3); + dedup.publish_state_if_not_dup(-3); + dedup.publish_state_if_not_dup(7); + dedup.publish_state_if_not_dup(7); + ASSERT_EQ(this->published_.size(), 2u); + EXPECT_EQ(this->published_[0], -3.0f); + EXPECT_EQ(this->published_[1], 7.0f); +} + +TEST_F(SensorWithDedupTest, UnknownPublishedOnceThenValueAgain) { + SensorWithDedup dedup; + dedup.set_sensor(&this->sensor_); + dedup.publish_state_if_not_dup(1.5f); + dedup.publish_state_unknown(); + dedup.publish_state_unknown(); + dedup.publish_state_if_not_dup(1.5f); + ASSERT_EQ(this->published_.size(), 3u); + EXPECT_EQ(this->published_[0], 1.5f); + EXPECT_TRUE(std::isnan(this->published_[1])); + EXPECT_EQ(this->published_[2], 1.5f); +} + +TEST_F(SensorWithDedupTest, SetSensorResetsHistory) { + SensorWithDedup dedup; + dedup.set_sensor(&this->sensor_); + dedup.publish_state_if_not_dup(100); + dedup.set_sensor(&this->sensor_); + dedup.publish_state_if_not_dup(100); + EXPECT_EQ(this->published_.size(), 2u); +} + +} // namespace esphome::sensor::testing From 1753c45e5358b4cea9113b911b6cde4384f9dae0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:47:37 +1100 Subject: [PATCH 625/651] [core] Move CONF_PROVIDER and CONF_REMOTE_ID to components/const (#21096) Co-authored-by: Claude Opus 5.5 --- esphome/components/const/__init__.py | 2 ++ esphome/components/dlms_meter/__init__.py | 2 +- esphome/components/packet_transport/__init__.py | 3 +-- esphome/components/packet_transport/binary_sensor.py | 3 +-- esphome/components/packet_transport/sensor.py | 8 ++------ esphome/components/remote_base/__init__.py | 2 +- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 99d7bf3f525..c2ae1a1a87b 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -55,10 +55,12 @@ CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" +CONF_PROVIDER = "provider" CONF_QUERY_PARAMS = "query_params" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_RED_CHANNEL = "red_channel" +CONF_REMOTE_ID = "remote_id" CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 46ccc2e44e6..4520d4e212d 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -3,6 +3,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart +from esphome.components.const import CONF_PROVIDER import esphome.config_validation as cv from esphome.const import CONF_NAME, CONF_PATTERN, CONF_PRIORITY, CONF_RECEIVE_TIMEOUT from esphome.core import CORE @@ -20,7 +21,6 @@ CONF_OBIS_CODE = "obis_code" CONF_CUSTOM_PATTERNS = "custom_patterns" CONF_SKIP_CRC = "skip_crc" CONF_DEFAULT_OBIS = "default_obis" -CONF_PROVIDER = "provider" DLMS_PARSER_VERSION = "2.2.0" diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index c36d421a355..e63782c26e4 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -7,6 +7,7 @@ from typing import Any import esphome.codegen as cg from esphome.components.binary_sensor import BinarySensor +from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID from esphome.components.sensor import Sensor import esphome.config_validation as cv from esphome.const import ( @@ -34,9 +35,7 @@ IS_PLATFORM_COMPONENT = True DOMAIN = "packet_transport" CONF_BROADCAST = "broadcast" CONF_BROADCAST_ID = "broadcast_id" -CONF_PROVIDER = "provider" CONF_PROVIDERS = "providers" -CONF_REMOTE_ID = "remote_id" CONF_PING_PONG_ENABLE = "ping_pong_enable" CONF_PING_PONG_RECYCLE_TIME = "ping_pong_recycle_time" CONF_ROLLING_CODE_ENABLE = "rolling_code_enable" diff --git a/esphome/components/packet_transport/binary_sensor.py b/esphome/components/packet_transport/binary_sensor.py index 37c46882423..c670190af05 100644 --- a/esphome/components/packet_transport/binary_sensor.py +++ b/esphome/components/packet_transport/binary_sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID import esphome.config_validation as cv from esphome.const import ( CONF_DATA, @@ -16,9 +17,7 @@ from esphome.types import ConfigType from . import ( CONF_ENCRYPTION, CONF_PING_PONG_ENABLE, - CONF_PROVIDER, CONF_PROVIDERS, - CONF_REMOTE_ID, CONF_TRANSPORT_ID, PacketTransport, packet_transport_sensor_schema, diff --git a/esphome/components/packet_transport/sensor.py b/esphome/components/packet_transport/sensor.py index 018f1c3a9b0..7eb48f6214b 100644 --- a/esphome/components/packet_transport/sensor.py +++ b/esphome/components/packet_transport/sensor.py @@ -1,14 +1,10 @@ import esphome.codegen as cg +from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID from esphome.components.sensor import new_sensor, sensor_schema from esphome.const import CONF_ID from esphome.types import ConfigType -from . import ( - CONF_PROVIDER, - CONF_REMOTE_ID, - CONF_TRANSPORT_ID, - packet_transport_sensor_schema, -) +from . import CONF_TRANSPORT_ID, packet_transport_sensor_schema CONFIG_SCHEMA = packet_transport_sensor_schema(sensor_schema()) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index b01b0f3645e..3d5053ca7e0 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -5,6 +5,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.const import CONF_REMOTE_ID from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -72,7 +73,6 @@ CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" CONF_EXTENSION = "extension" -CONF_REMOTE_ID = "remote_id" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") From c793ab30a74cad51e9990f3284d268927463ba76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:48:35 -1000 Subject: [PATCH 626/651] [core][gpio_expander] Add a shared expander pin class and pin codegen helpers (#20866) --- .../gpio_expander/expander_gpio_pin.h | 43 ++++++++++ esphome/pins.py | 36 ++++++++- .../gpio_expander/test_expander_gpio_pin.cpp | 78 +++++++++++++++++++ tests/unit_tests/test_pins.py | 58 ++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 esphome/components/gpio_expander/expander_gpio_pin.h create mode 100644 tests/components/gpio_expander/test_expander_gpio_pin.cpp diff --git a/esphome/components/gpio_expander/expander_gpio_pin.h b/esphome/components/gpio_expander/expander_gpio_pin.h new file mode 100644 index 00000000000..5dbb2883551 --- /dev/null +++ b/esphome/components/gpio_expander/expander_gpio_pin.h @@ -0,0 +1,43 @@ +#pragma once + +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +namespace esphome::gpio_expander { + +/// Pin of a GPIO expander. Parent provides pin_mode(pin, flags), digital_read(pin) and digital_write(pin, value). +/// dump_summary() is specialized per expander with the macros below, outside the template so that its format +/// string stays in flash on ESP8266; a missing macro is an undefined reference to +/// ExpanderGPIOPin::dump_summary. Built by pins.new_parented_pin(). +template class ExpanderGPIOPin final : public GPIOPin, public Parented { + public: + // User provided, not "= default": `new(p) ExpanderGPIOPin()` would zero-fill .bss that is already zero. + ExpanderGPIOPin() {} + + void setup() override { this->pin_mode(this->flags_); } + void pin_mode(gpio::Flags flags) override { this->parent_->pin_mode(this->pin_, flags); } + bool digital_read() override { return this->parent_->digital_read(this->pin_) != this->inverted_; } + void digital_write(bool value) override { this->parent_->digital_write(this->pin_, value != this->inverted_); } + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::gpio_expander + +/// In the expander's header, at file scope: declares the pin summary its .cpp defines. +#define ESPHOME_EXPANDER_PIN_SUMMARY_DECL(Parent) \ + template<> size_t esphome::gpio_expander::ExpanderGPIOPin::dump_summary(char *buffer, size_t len) const + +/// In the expander's .cpp, at file scope: the pin summary, a printf format taking the pin number. +#define ESPHOME_EXPANDER_PIN_SUMMARY(Parent, format) \ + ESPHOME_EXPANDER_PIN_SUMMARY_DECL(Parent) { return buf_append_printf(buffer, len, 0, format, this->pin_); } diff --git a/esphome/pins.py b/esphome/pins.py index d42cc489a1b..f18cd25a4aa 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, CONF_ALLOW_OTHER_USES, + CONF_ID, CONF_IGNORE_STRAPPING_WARNING, CONF_INPUT, CONF_INVERTED, @@ -19,8 +20,9 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE, ID -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass from esphome.schema_extractors import schema_extractor +from esphome.types import ConfigType, SafeExpType class PinRegistry(dict): @@ -348,6 +350,38 @@ def gpio_validate_modes(value): return value +def gpio_validate_input_xor_output(value: dict[str, bool]) -> dict[str, bool]: + """Mode validator for pins that are either an input or an output, never both.""" + if value[CONF_INPUT] == value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +def gpio_validate_input_xor_output_pullup(value: dict[str, bool]) -> dict[str, bool]: + """``gpio_validate_input_xor_output`` for chips whose pull-up only works on inputs.""" + gpio_validate_input_xor_output(value) + if value[CONF_PULLUP] and not value[CONF_INPUT]: + raise cv.Invalid("Pullup only available with input") + return value + + +async def new_parented_pin( + config: ConfigType, parent_key: str, *args: SafeExpType +) -> MockObj: + """Create a pin parented to ``config[parent_key]`` and set its number, inversion and mode flags. + + ``args`` go to ``cg.new_Pvariable``, e.g. ``cg.TemplateArguments`` for a templated pin class. + """ + import esphome.codegen as cg + + var = cg.new_Pvariable(config[CONF_ID], *args) + await cg.register_parented(var, config[parent_key]) + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(gpio_flags_expr(config[CONF_MODE]))) + return var + + def gpio_base_schema( pin_type: MockObjClass, number_validator: Callable[[Any], Any], diff --git a/tests/components/gpio_expander/test_expander_gpio_pin.cpp b/tests/components/gpio_expander/test_expander_gpio_pin.cpp new file mode 100644 index 00000000000..38b3823aa6e --- /dev/null +++ b/tests/components/gpio_expander/test_expander_gpio_pin.cpp @@ -0,0 +1,78 @@ +#include + +#include "esphome/components/gpio_expander/expander_gpio_pin.h" + +namespace esphome::gpio_expander::testing { + +class FakeExpander { + public: + void pin_mode(uint8_t pin, gpio::Flags flags) { + this->mode_pin = pin; + this->mode_flags = flags; + } + bool digital_read(uint8_t pin) { return this->level; } + void digital_write(uint8_t pin, bool value) { + this->write_pin = pin; + this->written = value; + } + + uint8_t mode_pin{0xFF}; + gpio::Flags mode_flags{gpio::FLAG_NONE}; + bool level{false}; + uint8_t write_pin{0xFF}; + bool written{false}; +}; + +using FakeExpanderPin = ExpanderGPIOPin; + +} // namespace esphome::gpio_expander::testing + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::gpio_expander::testing::FakeExpander); +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::gpio_expander::testing::FakeExpander, "%u via FAKE") + +namespace esphome::gpio_expander::testing { + +static FakeExpanderPin make_pin(FakeExpander *parent, uint8_t pin, bool inverted, gpio::Flags flags) { + FakeExpanderPin gpio_pin; + gpio_pin.set_parent(parent); + gpio_pin.set_pin(pin); + gpio_pin.set_inverted(inverted); + gpio_pin.set_flags(flags); + return gpio_pin; +} + +TEST(ExpanderGPIOPin, SetupAppliesConfiguredFlags) { + FakeExpander parent; + auto pin = make_pin(&parent, 3, false, gpio::FLAG_INPUT | gpio::FLAG_PULLUP); + pin.setup(); + EXPECT_EQ(parent.mode_pin, 3); + EXPECT_EQ(parent.mode_flags, gpio::FLAG_INPUT | gpio::FLAG_PULLUP); + EXPECT_EQ(pin.get_flags(), gpio::FLAG_INPUT | gpio::FLAG_PULLUP); +} + +TEST(ExpanderGPIOPin, ReadAndWriteHonourInversion) { + FakeExpander parent; + auto plain = make_pin(&parent, 1, false, gpio::FLAG_OUTPUT); + auto inverted = make_pin(&parent, 2, true, gpio::FLAG_OUTPUT); + + parent.level = true; + EXPECT_TRUE(plain.digital_read()); + EXPECT_FALSE(inverted.digital_read()); + + inverted.digital_write(true); + EXPECT_EQ(parent.write_pin, 2); + EXPECT_FALSE(parent.written); + plain.digital_write(true); + EXPECT_EQ(parent.write_pin, 1); + EXPECT_TRUE(parent.written); +} + +TEST(ExpanderGPIOPin, SummaryUsesTheExpanderFormat) { + FakeExpander parent; + auto pin = make_pin(&parent, 7, false, gpio::FLAG_INPUT); + char buffer[32]; + pin.dump_summary(buffer, sizeof(buffer)); + EXPECT_STREQ(buffer, "7 via FAKE"); +} + +} // namespace esphome::gpio_expander::testing diff --git a/tests/unit_tests/test_pins.py b/tests/unit_tests/test_pins.py index 18dc95a5c6c..cd4a71d2c0b 100644 --- a/tests/unit_tests/test_pins.py +++ b/tests/unit_tests/test_pins.py @@ -1,5 +1,7 @@ """Unit tests for esphome/pins.py.""" +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from esphome import config_validation as cv, pins @@ -108,3 +110,59 @@ def test_use_id_or_address_registers_as_use_id_schema(monkeypatch) -> None: validator = pins.use_id_or_address(SomeHub) assert schema_extractors.hidden_schemas.get(repr(validator)) == "use_id" + + +def _mode(input_: bool, output: bool, pullup: bool = False) -> dict[str, bool]: + return {"input": input_, "output": output, "pullup": pullup} + + +@pytest.mark.parametrize(("input_", "output"), [(True, False), (False, True)]) +def test_input_xor_output_accepts_one(input_: bool, output: bool) -> None: + mode = _mode(input_, output) + assert pins.gpio_validate_input_xor_output(mode) is mode + + +@pytest.mark.parametrize(("input_", "output"), [(True, True), (False, False)]) +def test_input_xor_output_rejects_both_or_neither(input_: bool, output: bool) -> None: + with pytest.raises(cv.Invalid, match="either input or output"): + pins.gpio_validate_input_xor_output(_mode(input_, output)) + + +def test_input_xor_output_pullup_allows_input_pullup() -> None: + mode = _mode(True, False, pullup=True) + assert pins.gpio_validate_input_xor_output_pullup(mode) is mode + + +def test_input_xor_output_pullup_rejects_output_pullup() -> None: + with pytest.raises(cv.Invalid, match="Pullup only available with input"): + pins.gpio_validate_input_xor_output_pullup(_mode(False, True, pullup=True)) + + +def test_input_xor_output_pullup_still_requires_one_mode() -> None: + with pytest.raises(cv.Invalid, match="either input or output"): + pins.gpio_validate_input_xor_output_pullup(_mode(True, True)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("args", [(), ("templ",)]) +async def test_new_parented_pin(args: tuple[str, ...]) -> None: + config = { + "id": "pin_id", + "hub": "hub_id", + "number": 5, + "inverted": True, + "mode": {"input": True, "output": False}, + } + var = MagicMock() + with ( + patch("esphome.codegen.new_Pvariable", return_value=var) as new_pvariable, + patch("esphome.codegen.register_parented", new=AsyncMock()) as parented, + patch("esphome.codegen.add") as add, + ): + assert await pins.new_parented_pin(config, "hub", *args) is var + new_pvariable.assert_called_once_with("pin_id", *args) + parented.assert_awaited_once_with(var, "hub_id") + var.set_pin.assert_called_once_with(5) + var.set_inverted.assert_called_once_with(True) + var.set_flags.assert_called_once() + assert add.call_count == 3 From 490d4723ca1ea23bccb557fb371f7356c12a4be6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:08:46 -1000 Subject: [PATCH 627/651] [ota] Log why the upload switched to UDP (#20996) --- esphome/espota2.py | 33 ++++++++++++++++----------- tests/unit_tests/test_espota2.py | 39 ++++++++++++++++++-------------- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index aaef178753a..340722206ce 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -918,29 +918,36 @@ def _tcp_retransmits(sock: socket.socket) -> int | None: return struct.unpack_from(fmt, info, offset)[0] -def _link_lossy( +def _loss_reason( tcp_sock: socket.socket, slowest_exchange: float, prefer_udp: bool -) -> bool: - """Whether the handshake showed loss, so the data phase should use UDP.""" - return ( - prefer_udp - or slowest_exchange > UDP_SLOW_EXCHANGE - or bool(_tcp_retransmits(tcp_sock)) - ) +) -> str | None: + """Why the link looks lossy, so the data phase should use UDP; None if clean.""" + if prefer_udp: + return "an earlier attempt failed" + if slowest_exchange > UDP_SLOW_EXCHANGE: + return f"a handshake reply took {slowest_exchange:.1f}s" + if _tcp_retransmits(tcp_sock): + return "TCP resent data during the handshake" + return None def _start_udp( - sock: socket.socket | NoiseSocketWrapper, tcp_sock: socket.socket, lossy: bool + sock: socket.socket | NoiseSocketWrapper, + tcp_sock: socket.socket, + loss_reason: str | None, ) -> UdpChannel | None: """Move the data phase to UDP; None keeps it on TCP.""" # A clean link asks to stay on TCP - send_check(sock, UDP_REQUEST if lossy else UDP_FALLBACK, "UDP request") + send_check( + sock, UDP_FALLBACK if loss_reason is None else UDP_REQUEST, "UDP request" + ) token = receive_exactly(sock, 4, "UDP token", None, decode=False) - if not lossy: + if loss_reason is None: _LOGGER.debug("Link looks clean, staying on TCP") return None + _LOGGER.info("Link is lossy (%s), trying UDP", loss_reason) if token == bytes(4): - _LOGGER.debug("Device declined UDP") + _LOGGER.info("Device declined UDP, using TCP") return None udp_sock = socket.socket(tcp_sock.family, socket.SOCK_DGRAM) try: @@ -1192,7 +1199,7 @@ def perform_ota( # Still under SETUP_TIMEOUT, which the device's commit wait is sized for if extended_proto and features & SERVER_FEATURE_SUPPORTS_UDP: udp = _start_udp( - sock, tcp_sock, _link_lossy(tcp_sock, slowest_exchange, prefer_udp) + sock, tcp_sock, _loss_reason(tcp_sock, slowest_exchange, prefer_udp) ) if udp is not None: # The device drops TCP once it hears the committed channel diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 04b09b4d542..c28944024bc 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -1695,45 +1695,50 @@ def test_start_udp_falls_back_when_blocked(mock_socket: Mock) -> None: mock_socket.getpeername.return_value = device.sock.getsockname() mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"] with patch.object(espota2, "UDP_PROBE_TIMEOUT", 0.3): - assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert espota2._start_udp(mock_socket, mock_socket, "lossy") is None assert mock_socket.sendall.call_args_list == [ call(espota2.UDP_REQUEST), call(espota2.UDP_FALLBACK), # raw on the TCP socket ] -def test_start_udp_declined(mock_socket: Mock) -> None: +def test_start_udp_declined( + mock_socket: Mock, caplog: pytest.LogCaptureFixture +) -> None: """A zero token means the device could not open UDP; nothing is probed.""" mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"] - assert espota2._start_udp(mock_socket, mock_socket, True) is None + with caplog.at_level(logging.INFO, logger=espota2.__name__): + assert espota2._start_udp(mock_socket, mock_socket, "lossy") is None assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] + assert "Link is lossy (lossy), trying UDP" in caplog.text + assert "Device declined UDP, using TCP" in caplog.text def test_start_udp_clean_link_stays_on_tcp(mock_socket: Mock) -> None: """A clean handshake asks to stay on TCP and opens no UDP socket.""" mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"] with patch.object(espota2.socket, "socket") as udp_socket: - assert espota2._start_udp(mock_socket, mock_socket, False) is None + assert espota2._start_udp(mock_socket, mock_socket, None) is None udp_socket.assert_not_called() assert mock_socket.sendall.call_args_list == [call(espota2.UDP_FALLBACK)] @pytest.mark.parametrize( - ("slowest", "prefer_udp", "retransmits", "lossy"), + ("slowest", "prefer_udp", "retransmits", "reason"), [ - (0.01, False, 0, False), - (0.01, False, None, False), - (espota2.UDP_SLOW_EXCHANGE + 0.1, False, 0, True), - (0.01, True, 0, True), - (0.01, False, 2, True), + (0.01, False, 0, None), + (0.01, False, None, None), + (1.5, False, 0, "a handshake reply took 1.5s"), + (0.01, True, 0, "an earlier attempt failed"), + (0.01, False, 2, "TCP resent data during the handshake"), ], ) -def test_link_lossy( - slowest: float, prefer_udp: bool, retransmits: int | None, lossy: bool +def test_loss_reason( + slowest: float, prefer_udp: bool, retransmits: int | None, reason: str | None ) -> None: - """A slow exchange, a retransmit or an earlier failure means UDP.""" + """A slow exchange, a retransmit or an earlier failure means UDP, with the reason.""" with patch.object(espota2, "_tcp_retransmits", return_value=retransmits): - assert espota2._link_lossy(Mock(), slowest, prefer_udp) is lossy + assert espota2._loss_reason(Mock(), slowest, prefer_udp) == reason @pytest.mark.parametrize( @@ -1825,7 +1830,7 @@ def test_perform_ota_over_udp(mock_socket: Mock) -> None: espota2.perform_ota( mock_socket, None, io.BytesIO(bytes(20000)), "test.bin", prefer_udp=True ) - assert start.call_args.args[2] is True # an earlier failure means lossy + assert start.call_args.args[2] == "an earlier attempt failed" assert udp.recv.call_count == 4 # no chunk acks udp.flush.assert_called_once() udp.close.assert_called_once() @@ -1887,7 +1892,7 @@ def test_start_udp_probe_succeeds(mock_socket: Mock) -> None: mock_socket.family = socket.AF_INET mock_socket.getpeername.return_value = device.sock.getsockname() mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x09"] - channel = espota2._start_udp(mock_socket, mock_socket, True) + channel = espota2._start_udp(mock_socket, mock_socket, "lossy") assert channel is not None channel.close() assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] @@ -1971,7 +1976,7 @@ def test_start_udp_socket_error_falls_back(mock_socket: Mock) -> None: mock_socket.family = socket.AF_INET mock_socket.getpeername.side_effect = OSError("not connected") mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"] - assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert espota2._start_udp(mock_socket, mock_socket, "lossy") is None assert mock_socket.sendall.call_args_list == [ call(espota2.UDP_REQUEST), call(espota2.UDP_FALLBACK), From 73ba726838e082c8b7e33e62384f52664abdaa2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:09:01 -1000 Subject: [PATCH 628/651] [ci] Extend the hardware-test skill with the flows learned since it was added (#21011) --- .agents/skills/hardware-test/SKILL.md | 43 ++- .../skills/hardware-test/reference/boards.md | 56 ++- .../skills/hardware-test/reference/recipes.md | 117 ------ .../hardware-test/reference/recipes/README.md | 26 ++ .../hardware-test/reference/recipes/api.md | 7 + .../reference/recipes/captive_portal.md | 21 ++ .../reference/recipes/cse7766.md | 11 + .../hardware-test/reference/recipes/espnow.md | 7 + .../reference/recipes/hdmi_cec.md | 9 + .../hardware-test/reference/recipes/ld2450.md | 7 + .../reference/recipes/libretiny.md | 58 +++ .../hardware-test/reference/recipes/mqtt.md | 12 + .../hardware-test/reference/recipes/nrf52.md | 6 + .../reference/recipes/online_image.md | 20 + .../reference/recipes/openthread.md | 5 + .../hardware-test/reference/recipes/ota.md | 8 + .../reference/recipes/rtsp_server.md | 18 + .../reference/recipes/scheduler.md | 8 + .../hardware-test/reference/recipes/udp.md | 11 + .../reference/recipes/usb_cdc_acm.md | 20 + .../hardware-test/reference/recipes/wifi.md | 25 ++ .../hardware-test/reference/recipes/zephyr.md | 10 + .../reference/recipes/zephyr_coredump.md | 17 + .../reference/recipes/zwave_proxy.md | 23 ++ .../hardware-test/scripts/cse7766/observe.py | 71 ++++ .../hardware-test/scripts/cse7766/run_obs.sh | 27 ++ .../scripts/libretiny_crash/bkprobe.hpp | 116 ++++++ .../scripts/libretiny_crash/decode_capture.py | 25 ++ .../scripts/libretiny_prefs/ltprefs.hpp | 155 ++++++++ .../scripts/online_image/drive.py | 179 +++++++++ .../scripts/online_image/make_images.py | 38 ++ .../scripts/online_image/oi_server.py | 142 +++++++ .../scripts/rtsp_server/cam_api.py | 33 ++ .../scripts/rtsp_server/measure.sh | 32 ++ .../scripts/rtsp_server/rawrate2.py | 83 +++++ .../scripts/rtsp_server/rtsp_client.py | 345 ++++++++++++++++++ .../scripts/usb_cdc_acm/cdc_probe.hpp | 13 + .../scripts/usb_cdc_acm/drive.py | 203 +++++++++++ .../scripts/zephyr_coredump/decode.sh | 39 ++ .../scripts/zephyr_coredump/flow.sh | 29 ++ .../scripts/zwave_proxy/devinfo.py | 24 ++ .../scripts/zwave_proxy/verify_checksums.py | 45 +++ .../templates/bk72xx-crash-probe-dut.yaml | 44 +++ .../templates/bk72xx-rtc-prefs-dut.yaml | 38 ++ .../dut-ap-only-wipe-once-bk72xx.yaml | 34 ++ .../templates/nrf52-coredump-crash-dut.yaml | 50 +++ .../templates/nrf52-preferences-dut.yaml | 31 ++ .../templates/online-image-dut.yaml | 84 +++++ .../templates/rtsp-waveshare-s3-eth-dut.yaml | 105 ++++++ .../templates/s31-cse7766-dut.yaml | 107 ++++++ .../templates/usb-cdc-acm-dongle-dut.yaml | 112 ++++++ .../templates/waveshare-s3-eth-camdiag.yaml | 83 +++++ 52 files changed, 2703 insertions(+), 129 deletions(-) delete mode 100644 .agents/skills/hardware-test/reference/recipes.md create mode 100644 .agents/skills/hardware-test/reference/recipes/README.md create mode 100644 .agents/skills/hardware-test/reference/recipes/api.md create mode 100644 .agents/skills/hardware-test/reference/recipes/captive_portal.md create mode 100644 .agents/skills/hardware-test/reference/recipes/cse7766.md create mode 100644 .agents/skills/hardware-test/reference/recipes/espnow.md create mode 100644 .agents/skills/hardware-test/reference/recipes/hdmi_cec.md create mode 100644 .agents/skills/hardware-test/reference/recipes/ld2450.md create mode 100644 .agents/skills/hardware-test/reference/recipes/libretiny.md create mode 100644 .agents/skills/hardware-test/reference/recipes/mqtt.md create mode 100644 .agents/skills/hardware-test/reference/recipes/nrf52.md create mode 100644 .agents/skills/hardware-test/reference/recipes/online_image.md create mode 100644 .agents/skills/hardware-test/reference/recipes/openthread.md create mode 100644 .agents/skills/hardware-test/reference/recipes/ota.md create mode 100644 .agents/skills/hardware-test/reference/recipes/rtsp_server.md create mode 100644 .agents/skills/hardware-test/reference/recipes/scheduler.md create mode 100644 .agents/skills/hardware-test/reference/recipes/udp.md create mode 100644 .agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md create mode 100644 .agents/skills/hardware-test/reference/recipes/wifi.md create mode 100644 .agents/skills/hardware-test/reference/recipes/zephyr.md create mode 100644 .agents/skills/hardware-test/reference/recipes/zephyr_coredump.md create mode 100644 .agents/skills/hardware-test/reference/recipes/zwave_proxy.md create mode 100644 .agents/skills/hardware-test/scripts/cse7766/observe.py create mode 100755 .agents/skills/hardware-test/scripts/cse7766/run_obs.sh create mode 100644 .agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp create mode 100644 .agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py create mode 100644 .agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp create mode 100644 .agents/skills/hardware-test/scripts/online_image/drive.py create mode 100644 .agents/skills/hardware-test/scripts/online_image/make_images.py create mode 100644 .agents/skills/hardware-test/scripts/online_image/oi_server.py create mode 100644 .agents/skills/hardware-test/scripts/rtsp_server/cam_api.py create mode 100755 .agents/skills/hardware-test/scripts/rtsp_server/measure.sh create mode 100644 .agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py create mode 100644 .agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py create mode 100644 .agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp create mode 100644 .agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py create mode 100755 .agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh create mode 100755 .agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh create mode 100644 .agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py create mode 100644 .agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py create mode 100644 .agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml create mode 100644 .agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/online-image-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/s31-cse7766-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md index 8ad7cd5d847..d5e994a82f0 100644 --- a/.agents/skills/hardware-test/SKILL.md +++ b/.agents/skills/hardware-test/SKILL.md @@ -15,19 +15,26 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul 1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py "`, naming the port so a capture on another board survives; the bracket keeps the pattern from matching - the shell that runs it. Never run `esphome logs` in a - `while true` loop: the loop respawns it and holds the port. + the shell that runs it. When more than one agent session shares the bench, run a copy of the script + under a session-specific name (`cap.py`) so another session's `pkill` cannot take your capture + down. Never run `esphome logs` in a `while true` loop: the loop respawns it and holds the port. 2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with - `!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs. + `!secret`. A copy of a device's own config keeps using the secrets file next to that config: two + checkouts can hold different networks, and the wrong one leaves the device unreachable after the OTA. Never paste a config with credentials into a PR, never commit scratch configs or logs. 3. **Build from the tree under test.** `python -m esphome` puts the current directory first on `sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep - the build's `src/` for the change before trusting any number. + the build's `src/` for the change before trusting any number. Tool calls sent together share one + shell, so a `cd` in one can land another's compile in the wrong tree: pass `PYTHONPATH=` on + every compile and upload command and print `esphome.__file__` into the compile log. 4. **PR build and baseline build get different `name:` values.** Builds share `.esphome/build/`, so the same name means `esphome upload` can flash the wrong binary. Saved - Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh - name every run. + Wi-Fi credentials are keyed by the config hash when the config has `networks:`, so a test that must + start unprovisioned gets a fresh name every run. A config with `ap:` only and no `networks:` uses one + fixed key shared by every such config on the board, so a new name does not help there: wipe once at + boot with an `on_boot` lambda that resets the preferences and saves a marker key + (`templates/dut-ap-only-wipe-once-bk72xx.yaml`). 5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD` checked out in a throwaway worktree), with the baseline commit named in the table. When a delta looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it. @@ -73,7 +80,7 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul | ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device .local` | | Pico W / Pico 2 W | `esphome upload --device ` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture | | BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d /.pioenvs//*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` | -| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d ` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test | +| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d ` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. `serial_capture.py --reset` gives a chip-enable reset; a plain open may or may not reset it, check for the bootloader banner | | LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash | | nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second | | Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port | @@ -91,7 +98,8 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA deadline. OUT is truncated unless `--append` is given, so counts over it cover one run. - `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not. -- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. +- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now + and then; retry up to three times. - `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`: the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0), @@ -100,6 +108,13 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA (exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run) or exits 1 when a build, flash or port step failed. +Per-topic scripts, each described in the matching `reference/recipes/.md`: `online_image/` +(server, image generator, API driver), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/` +(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set, +API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the +Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and +capture decoder). + ## Templates in `$SKILL/templates` - `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and @@ -107,17 +122,25 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA - `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a plain provisioning check. +- Per-topic device configs, each described in `reference/recipes/.md`: `online-image-dut.yaml`, + `usb-cdc-acm-dongle-dut.yaml`, `nrf52-preferences-dut.yaml`, `rtsp-waveshare-s3-eth-dut.yaml`, + `waveshare-s3-eth-camdiag.yaml`, `s31-cse7766-dut.yaml`, `nrf52-coredump-crash-dut.yaml`, + `bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`. ## When something looks wrong - A capture stopped early or a line is missing: check the timestamps before trusting it. - The client never joins the AP: another board may still run an AP with the same SSID, or the device booted with saved credentials because the name was reused. One AP device at a time on the bench. -- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). +- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). The reverse + too: two binaries that differ only in the build timestamp and in `__LINE__` values of log calls below + the changed code are the same build; map the offsets with `objdump -h` and `addr2line` first. +- A boot the host cannot capture (a power cut, an OTA-only board): keep the boot report in a flash + preference and repeat it in a periodic alive line, as `scripts/libretiny_prefs/ltprefs.hpp` does. - Multicast from the host never arrives on some access points: test it device to device. Broadcast is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate. - `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for soak tests. -`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven +`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes/` has the proven flows. Add new findings there. diff --git a/.agents/skills/hardware-test/reference/boards.md b/.agents/skills/hardware-test/reference/boards.md index fd2cd448785..805643b59f1 100644 --- a/.agents/skills/hardware-test/reference/boards.md +++ b/.agents/skills/hardware-test/reference/boards.md @@ -88,8 +88,28 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset reaches the downloader; no software reboot does. - ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. -- Opening the port resets the chip (bootloader banner on every open, even with DTR/RTS low), so keep one - capture open for the whole test and never reopen mid-phase. +- Opening the port may reset the chip (bootloader banner on open) or may not, depending on the adapter and + what pyserial does with DTR and RTS; `serial_capture.py --reset` pulses RTS on CEN and always does, and + `lt_get_reboot_reason()` reports that as Power-On. Check for the banner at the start of a capture before + assuming either. +- `flash_bk72xx.py` misses the link now and then (`Timed out attempting to link with chip`); retry up to 3 + times before suspecting the board. +- Crashes: `__builtin_trap()` and a call into unmapped memory both end as an undefined-instruction trap + (`bk_misc_get_start_type()` 6, `lt_get_reboot_reason()` Crash); reads, unaligned loads and stores to + unmapped addresses never fault on the ARM968. Without Wi-Fi the SDK trap handler spins with interrupts off + for 10 s and the hardware watchdog resets the chip. With Wi-Fi connected a dev build hangs forever after a + crash, and never resets from a plain `portDISABLE_INTERRUPTS(); while (1) {}` either: the boot-time + watchdog is dead once Wi-Fi is up, for any lockup. The crash handler fix (#21012) re-arms the watchdog + from its wrapper, after which the reboot comes within a second (BK7238 and BK7231N). +- The SDK prints nothing on UART1 under `sdk_silent: all`; the bootloader then prints the banked + `cpsr/spsr/r13/r14` of SVC, IRQ, FIQ and SYS plus `ST:` (the retention register) before `go os_addr`. + A CEN or power reset wipes ITCM (reads `0xaaaaaaaa`), so only a watchdog reset keeps a record there. What + survives that reboot on the BK7238: ITCM (`0x3F0000..0x3F4000`) and the top 96 KB of SRAM + (`0x428000..0x440000`, the heap); the bootloader zeroes SRAM up to about `0x420000` and rewrites + `0x400024..0x4000FF` with its own register dump every boot, so neither a `.noinit` after `.bss` nor the + SDK's saved register banks are usable. Code is compiled Thumb, the SDK assembly is ARM; in an exception + handler only r0 to r3 are shared by all modes. BK7231N ITCM is `0x3FEE00..0x400000` (4.5 KB); a `.noinit` + placed there after `.itcm.code` works for a crash record. - Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms; radio enable 160 to 278 ms, disable about 200 ms. @@ -123,6 +143,38 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever - Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume, adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24), button P0.18. +- Adafruit ItsyBitsy nRF52840 Express (UF2 bootloader with S140, bootloader port `239a:0051` plus an + `ITSY840BOOT` volume): the board DTS already has `cdc_acm_uart0`, so the logger port is the first CDC + port and a `usb_cdc_acm` interface 1 the second. A `dfu: true` build enters the bootloader on the + 1200 bps touch (an app without it needs a double tap on RESET); `adafruit-nrfutil` over the bootloader + serial port can fail, copying `.pioenvs//zephyr/zephyr.uf2` to the volume works (the copy reports + an I/O error as the board reboots, which is normal). Reboot a running `dfu: true` app with a 2001 bps touch. +- The PCA10059 board DTS already defines a `cdc_acm_uart` node, so every ESPHome CDC port is one USB port + later than its number (log on `cdc_acm_uart0` is the second port). A third CDC instance needs + `CONFIG_USB_MAX_NUM_TRANSFERS` above 4 or it never moves data (see `recipes/usb_cdc_acm.md`). +- USB CDC boot output: the Zephyr legacy stack's `cdc_acm` TX ring is 1 KB and `uart_poll_out` drops when it + is full, so anything printed in one burst beyond about 1 KB (a coredump, a long table) is lost even with + the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead. +- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs + a never-saved value. + +## Waveshare ESP32-S3-ETH with the OV5640 camera module + +- Native USB console (`303a:1001`), ESP32-S3R8, 16 MB flash, 8 MB octal PSRAM, W5500 on SPI (MOSI 11, + MISO 12, SCLK 13, CS 14, INT 10, RST 9), TF card on 4..7, WS2812 on 21. +- Camera header (vendor schematic): XCLK 3, SIOD 48, SIOC 47, VSYNC 1, HREF 2, PCLK 39, D0..D7 = 41, 45, + 46, 42, 40, 38, 15, 18. PWDN is tied to GND and RESET is an RC to 3.3 V on the board, so no reset or + power-down GPIO exists. +- GPIO8 switches the camera 3.3 V rail through a P-channel MOSFET: LOW = camera powered, gate pulled up so + the camera is off by default. `power_down_pin: GPIO8` only pulls it low 10 ms before the probe, which is + not enough; use `switch: gpio GPIO8 restore_mode: ALWAYS_OFF setup_priority: 800` plus `on_boot: + priority: 700` with `delay(1200)` and `esp32_camera: setup_priority: 200` + (`templates/rtsp-waveshare-s3-eth-dut.yaml`). `esphome config` rejects a config that uses GPIO8 for both the switch + and `power_down_pin` (pin reuse), so it is one or the other. +- The sensor at SCCB 0x3C answers only with XCLK running, so the ESPHome i2c scan at boot (before XCLK) + reporting no devices on 48/47 is expected. If the SCCB ID register (0x3100 = 0x78) reads fine but every + 0x30xx register reads 0x00 (`Detected camera not supported`, 0x106), reseat the ribbon before blaming + software; `templates/waveshare-s3-eth-camdiag.yaml` runs that probe with LEDC XCLK and raw SCCB reads. ## ESP Thread Border Router board v1.2 (S3 host + H2 RCP) diff --git a/.agents/skills/hardware-test/reference/recipes.md b/.agents/skills/hardware-test/reference/recipes.md deleted file mode 100644 index 32944ce75c4..00000000000 --- a/.agents/skills/hardware-test/reference/recipes.md +++ /dev/null @@ -1,117 +0,0 @@ -# Proven test flows - -Each flow names the pull requests it was used on, so their result tables show what to post. - -## Captive portal `/wifisave` hammer (#20802, #20784) - -Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board. -Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`, -`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the -device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown -is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on -ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md). - -Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash -markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`. -A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters -the adapter restart), no panic, and the real credentials connect the device within about 10 s. - -Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in -`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag -(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive -portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection -refused proves the portal's server is gone. - -## `reboot_timeout` with a fallback AP (#9958) - -Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network, -`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and -ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes: -`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles -are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot -with no station joined, no reboot for minutes while one is, then a reboot within a second of the last -disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way. - -## MQTT (#20433, #17935, #20762, #20767, #20768) - -A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives -each subscription's QoS, the device address (`New client connected from as -`) and exact -subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription. -Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right -after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a -leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish. -`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a -paho script publishes and polls the serial capture file. - -## UDP, multicast, sockets (#20556 to #20559, #20585, #20590) - -A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device -multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock -listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate -(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi -reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect` -(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action -rejects `%.*s`; use a lambda. - -## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037) - -All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release -after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A -refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot. -`pkill -f "a\|b"` does not alternate; use two pkills. - -## DHCP lease cache (#17767) - -Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the -`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep -sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use -`upload` then `logs`. - -## HDMI-CEC two-board wire (#9189) - -Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:` -filter on the reply handler, template buttons for each target plus lambdas calling -`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms, -round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep -lags the press). - -## LD2450 polygon zones (#20154) - -To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and -switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after. -Writing those numbers blocks the ld2450 loop about 50 ms. - -## Scheduler and loop wake A/B (#20356) - -An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266; -the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout -logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus -`button_command`). Build the same yaml twice with different names, one per tree. - -## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233) - -Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over -two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with -`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml. - -## ESP-NOW on the ESP8266 (#14407) - -The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is -up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only -mode the device transmits from the soft-AP MAC. - -## Wi-Fi Easy Connect (DPP) enrollee (#17934) - -Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's -QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG. -`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the -supplicant state machine. - -## Thread border router (#18104) - -See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR. - -## nRF52840 dongle boards (#19009, #20814) - -Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a -10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first. diff --git a/.agents/skills/hardware-test/reference/recipes/README.md b/.agents/skills/hardware-test/reference/recipes/README.md new file mode 100644 index 00000000000..7c7871f45ce --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/README.md @@ -0,0 +1,26 @@ +# Proven test flows + +Each flow names the pull requests it was used on, so their result tables show what to post. + +One file per component, named after it: + +- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266 +- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer +- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API +- [`espnow.md`](espnow.md): ESP8266 broadcast receive +- [`hdmi_cec.md`](hdmi_cec.md): two-board wire +- [`ld2450.md`](ld2450.md): polygon zones +- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot +- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT +- [`nrf52.md`](nrf52.md): dongle board support +- [`online_image.md`](online_image.md): stalled and malformed downloads +- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP +- [`ota.md`](ota.md): overlap and lock, timeouts, deflate +- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH +- [`scheduler.md`](scheduler.md): loop wake A/B +- [`udp.md`](udp.md): datagrams, multicast, sockets +- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver +- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP) +- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle +- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards +- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy diff --git a/.agents/skills/hardware-test/reference/recipes/api.md b/.agents/skills/hardware-test/reference/recipes/api.md new file mode 100644 index 00000000000..fa6733a72c6 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/api.md @@ -0,0 +1,7 @@ +# api + +## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233) + +Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over +two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with +`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml. diff --git a/.agents/skills/hardware-test/reference/recipes/captive_portal.md b/.agents/skills/hardware-test/reference/recipes/captive_portal.md new file mode 100644 index 00000000000..6b9710e531b --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/captive_portal.md @@ -0,0 +1,21 @@ +# captive_portal + +## Captive portal `/wifisave` hammer (#20802, #20784) + +Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board. +Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`, +`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the +device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown +is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on +ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md). + +Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash +markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`. +A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters +the adapter restart), no panic, and the real credentials connect the device within about 10 s. + +Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in +`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag +(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive +portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection +refused proves the portal's server is gone. diff --git a/.agents/skills/hardware-test/reference/recipes/cse7766.md b/.agents/skills/hardware-test/reference/recipes/cse7766.md new file mode 100644 index 00000000000..369b3636941 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/cse7766.md @@ -0,0 +1,11 @@ +# cse7766 + +## cse7766 on a Sonoff S31 (#20948) + +`templates/s31-cse7766-dut.yaml`: `logger: baud_rate: 0` because the CSE7766 owns the UART, VERBOSE logger +with everything but cse7766 quieted, 2 s throttles, and a `globals` frame counter fed by the voltage sensor's +`on_raw_value` and logged every 10 s (about 96 frames per 10 s). OTA only, every reflash reboots the relay. +`scripts/cse7766/run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER]` subscribes to states and logs over the +API with a concurrent ping and prints the frame counter, API gaps and warnings. API state gaps of 10 to 20 s +can appear on a weak Wi-Fi path while the device counter stays flat, so judge a UART parser by the +device-side counter, not by the API cadence. With nothing plugged in, current and power read 0. diff --git a/.agents/skills/hardware-test/reference/recipes/espnow.md b/.agents/skills/hardware-test/reference/recipes/espnow.md new file mode 100644 index 00000000000..3fee12981f9 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/espnow.md @@ -0,0 +1,7 @@ +# espnow + +## ESP-NOW on the ESP8266 (#14407) + +The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is +up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only +mode the device transmits from the soft-AP MAC. diff --git a/.agents/skills/hardware-test/reference/recipes/hdmi_cec.md b/.agents/skills/hardware-test/reference/recipes/hdmi_cec.md new file mode 100644 index 00000000000..6582626167e --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/hdmi_cec.md @@ -0,0 +1,9 @@ +# hdmi_cec + +## HDMI-CEC two-board wire (#9189) + +Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:` +filter on the reply handler, template buttons for each target plus lambdas calling +`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms, +round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep +lags the press). diff --git a/.agents/skills/hardware-test/reference/recipes/ld2450.md b/.agents/skills/hardware-test/reference/recipes/ld2450.md new file mode 100644 index 00000000000..9d1a8d8f32b --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/ld2450.md @@ -0,0 +1,7 @@ +# ld2450 + +## LD2450 polygon zones (#20154) + +To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and +switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after. +Writing those numbers blocks the ld2450 loop about 50 ms. diff --git a/.agents/skills/hardware-test/reference/recipes/libretiny.md b/.agents/skills/hardware-test/reference/recipes/libretiny.md new file mode 100644 index 00000000000..fb784fbc620 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/libretiny.md @@ -0,0 +1,58 @@ +# libretiny + +## LibreTiny rtc preference emulation (#21007) + +The PR keeps `storage: rtc` preferences in FlashDB and deletes them on the first load or save after a boot +whose `lt_get_reboot_reason()` is not software, watchdog, crash or sleep. Only the families listed in +`_libretiny_rtc_supported()` accept the config keys; on others force the backend in with +`platformio_options: build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"]` after confirming the rejection. + +- Harness: `scripts/libretiny_prefs/ltprefs.hpp` plus `templates/bk72xx-rtc-prefs-dut.yaml`. `ltprefs_boot()` + loads one rtc and one flash preference, a second pair that shares one key as rtc and as flash, and a third + rtc entry (so a hard reset drops three entries in one pass), logs the reason and both results, saves `100 + step`, syncs; after + a templated delay `ltprefs_exit()` bumps the flash-backed step and runs the step's action (`lt_reboot()`, + `__builtin_trap()`, `lt_reboot_wdt()` or idle). The last 8 boot reports are kept in a flash preference and + repeated in the 10 s alive line, so a boot missed by the capture (a power cut, an OTA-only board) is still + readable. Bump `run:` for fresh keys. +- A store to an unmapped address does not fault on the ARM968, the board carries on: use `__builtin_trap()` + for the crash row. The SDK crash handler reboots about 10 s later. Works on BK7238 and BK7231N. +- Hard resets: `serial_capture.py ... --reset` pulses RTS on CEN (reported as Power-On). A real power cut + needs a hand or a smart plug; the history in the alive line covers the boot the host cannot see. On the + Beken SDK a chip-enable reset and a power cut both report Power-On, there is no separate reset-pin source. +- Step 0 and step 8 wait 30 s before acting so the host can open the port (and pulse a reset) after a flash + without losing the first boot; the flash tool's own reset starts the sequence immediately otherwise. +- OTA-only board: keep the device's wifi, api and ota blocks verbatim, keep safe_mode, read logs with + `esphome logs --device .local` (the API attaches late, hence the history), and restore the + device's own config afterwards. +- Plain-build comparison: `raw_firmware.bin` differs by the timestamp and by `__LINE__` in log calls of + functions below the added code; map offsets through `objdump -h` load addresses and `addr2line` before + calling a byte difference a code change. + +## BK72xx crash report on the next boot (#21012) + +Harness: `scripts/libretiny_crash/bkprobe.hpp` plus `templates/bk72xx-crash-probe-dut.yaml`. A flash +preference holds the step; `on_boot` (priority -100) logs the reset reason, the SDK start type, two canaries +and hex dumps of the low SRAM, a set of marker words and the record area after `.bss`, waits 30 s on step 0 +(so a capture can be opened after the flash) or 5 s, saves `step + 1`, syncs, then acts: 0 `__builtin_trap()`, +1 call into unmapped memory, 2 unmapped and unaligned reads and a store (no fault on the ARM968), 3 +`lt_reboot()`. A `globals: restore_value` step counter does not work here: it is only saved from the global's +loop, which a crash 50 ms later never reaches; save an `ESPPreferenceObject` and `global_preferences->sync()` +first. Bump `run:` for fresh keys. Keep the `wifi:` block: the bug the fix addresses only shows with Wi-Fi up. + +Flow: `flash_bk72xx.py` (retry up to three times), one `serial_capture.py ... --reset` capture of 150 to 230 s, +then `grep -a "libretiny.crash\|probe"`. Decode with +`arm-none-eabi-addr2line -pfiaC -e .pioenvs//raw_firmware.elf ` (the ELF is `raw_firmware.elf` +on LibreTiny), or run `scripts/libretiny_crash/decode_capture.py ` from the config directory +to feed the capture through the CLI's `LogLineProcessor`. + +Expected: `*** CRASH DETECTED ON PREVIOUS BOOT ***`, `Reason: Undefined instruction`, PC on the `udf`, +LR = PC + 1, BT0..BT7 walking the lambda, `StatelessLambdaAction`, `Action::play_complex`, `DelayAction`, +`Scheduler::call`, `Application::loop`. The wild call reports where the core ended up (it executes zeros as +Thumb NOPs until it wraps into real code), not the called address. Without the fix a dev build with Wi-Fi +connected never comes back from the crash; see the BK72xx section in `boards.md`. + +OTA-only board: copy the device's own config, add the probe include and the step machine (step 0 crashes +45 s after boot, later steps idle), keep its wifi, api, ota and safe_mode blocks and use the secrets file +next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the +upload in a short timeout. Read the replayed report with `esphome logs ... --device .local` (an API +client gets it on subscribe) or on the board's serial log port if it has one. diff --git a/.agents/skills/hardware-test/reference/recipes/mqtt.md b/.agents/skills/hardware-test/reference/recipes/mqtt.md new file mode 100644 index 00000000000..6298f769aa9 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/mqtt.md @@ -0,0 +1,12 @@ +# mqtt + +## MQTT (#20433, #17935, #20762, #20767, #20768) + +A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives +each subscription's QoS, the device address (`New client connected from as -`) and exact +subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription. +Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right +after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a +leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish. +`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a +paho script publishes and polls the serial capture file. diff --git a/.agents/skills/hardware-test/reference/recipes/nrf52.md b/.agents/skills/hardware-test/reference/recipes/nrf52.md new file mode 100644 index 00000000000..a9ae66e1b15 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/nrf52.md @@ -0,0 +1,6 @@ +# nrf52 + +## nRF52840 dongle boards (#19009, #20814) + +Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a +10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first. diff --git a/.agents/skills/hardware-test/reference/recipes/online_image.md b/.agents/skills/hardware-test/reference/recipes/online_image.md new file mode 100644 index 00000000000..da81c1eb969 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/online_image.md @@ -0,0 +1,20 @@ +# online_image + +## online_image stalled downloads (#20594) + +One classic ESP32, both frameworks (the ESP32 Arduino build also uses `http_request_idf.cpp`). The host +serves the images: `scripts/online_image/make_images.py ` writes 16x16 BMP/PNG/JPEG/QOI plus an 8 bit +palette BMP with a 1078 byte header; `scripts/online_image/oi_server.py 8765 ` answers one +misbehaviour per path name (`ok`, `truncated`, `large_header`, `empty`, `stall` holds the socket 40 s, +`closed_early`, `slow` pauses 2.5 s, `chunked`, `chunked_cut`, `noclen`, `huge` claims 20 MB) with an ETag +and a 304 on If-None-Match. `templates/online-image-dut.yaml` has two AUTO-format images (default and +256 byte buffer), API actions `fetch`/`fetch_small` (set_url with `update: false`, then component.update) +and `refetch`/`refetch_small` (component.update only, keeps the cache headers), and a restart button. +`scripts/online_image/drive.py http://:8765 pr|base` runs the +case list over aioesphomeapi and judges each from the serial capture. ESP-IDF facts: a stalled server +ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout, an early close with +`-1`, both before and after the PR; a body without Content-Length and without chunking reads as complete +(Size: 0) and finishes. A build without the fix hangs on the truncated, oversized header, empty, chunked PNG +and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs +a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list. +A case expected as `any` passes on `finished` or `error`, never on no outcome. diff --git a/.agents/skills/hardware-test/reference/recipes/openthread.md b/.agents/skills/hardware-test/reference/recipes/openthread.md new file mode 100644 index 00000000000..e126eb6e235 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/openthread.md @@ -0,0 +1,5 @@ +# openthread + +## Thread border router (#18104) + +See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR. diff --git a/.agents/skills/hardware-test/reference/recipes/ota.md b/.agents/skills/hardware-test/reference/recipes/ota.md new file mode 100644 index 00000000000..1c9d6642bb0 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/ota.md @@ -0,0 +1,8 @@ +# ota + +## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037) + +All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release +after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A +refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot. +`pkill -f "a\|b"` does not alternate; use two pkills. diff --git a/.agents/skills/hardware-test/reference/recipes/rtsp_server.md b/.agents/skills/hardware-test/reference/recipes/rtsp_server.md new file mode 100644 index 00000000000..10438ccd0f4 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/rtsp_server.md @@ -0,0 +1,18 @@ +# rtsp_server + +## RTSP camera streaming on the Waveshare ESP32-S3-ETH (#17990) + +`templates/rtsp-waveshare-s3-eth-dut.yaml` (OV5640 pins, GPIO8 camera power switch with the 1.2 s boot delay, +`rtsp_server`, logger VERBOSE for `rtsp_server`); swap the `wifi:` block for the W5500 `ethernet:` block when +the cable is in, and add `network: enable_high_performance: true` for the big TCP buffers. Flash over native +USB, capture the log with `serial_capture.py`, take the IP from the last `IP Address` line that is not +0.0.0.0 (or `dns-sd -Q .local`). `scripts/rtsp_server/rtsp_client.py IP [cases]` covers play, udp, +pipeline, badreq, replay, getparam, teardown, third, stall, timeout; `rawrate2.py IP quiet|chatty|rtcp|bigbuf` +measures the raw receive rate; `measure.sh IP LABEL LOGFILE [OUTDIR]` runs the fixed set (ping, ffmpeg 12 s +twice, two ffmpeg clients, raw reader) and appends to `results.txt`; `cam_api.py IP` pulls one image over the +API; `ffmpeg -rtsp_transport tcp -i rtsp://IP:554/ -frames:v 30 -f image2 f%03d.jpg` plus Pillow checks +decoding. The 5760 byte default TCP send buffer makes the stream stall over Wi-Fi (about 1 fps, dozens of +stall drops per run); `enable_high_performance` gives several fps on Wi-Fi and removes the stalls on Ethernet. +Frame counts vary a lot between runs (auto exposure changes the JPEG size), so always measure twice. Home +Assistant's stream pipeline does not accept MJPEG over RTSP; its ffmpeg camera opens one RTSP session per +snapshot, so `max_connections` needs headroom, and go2rtc transcoding to H.264 is the way to a live view. diff --git a/.agents/skills/hardware-test/reference/recipes/scheduler.md b/.agents/skills/hardware-test/reference/recipes/scheduler.md new file mode 100644 index 00000000000..d3210f105be --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/scheduler.md @@ -0,0 +1,8 @@ +# scheduler + +## Scheduler and loop wake A/B (#20356) + +An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266; +the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout +logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus +`button_command`). Build the same yaml twice with different names, one per tree. diff --git a/.agents/skills/hardware-test/reference/recipes/udp.md b/.agents/skills/hardware-test/reference/recipes/udp.md new file mode 100644 index 00000000000..b029e42ff27 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/udp.md @@ -0,0 +1,11 @@ +# udp + +## UDP, multicast, sockets (#20556 to #20559, #20585, #20590) + +A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device +multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock +listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate +(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi +reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect` +(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action +rejects `%.*s`; use a lambda. diff --git a/.agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md b/.agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md new file mode 100644 index 00000000000..688a415f3ad --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md @@ -0,0 +1,20 @@ +# usb_cdc_acm + +## usb_cdc_acm on the nRF52840 dongle (#11634) + +`templates/usb-cdc-acm-dongle-dut.yaml`: logger on USB_CDC port 0, `usb_cdc_acm` interface 0 disabled, +interface 1 echoing from a 20 ms lambda, 0x01-prefixed commands for burst, stream, flush, peek, line coding +and counters. Build and flash with `dfu: true` through the app port; the dongle then shows three CDC ports: +the board DTS node `cdc_acm_uart` (unused), `cdc_acm_uart0` (log) and `cdc_acm_uart1` (data), in that order. +Capture the log port with `serial_capture.py`, drive the data port with `scripts/usb_cdc_acm/drive.py +DATA_PORT` (11 cases, prints PASS/FAIL and a summary) and grep the log for `Opened`, `Closed`, `buffer full`, +`flush result`, `line coding`. Boot lines are held in the driver's 1 KB ring until the host opens the port, so +a capture started later still shows the first 1 KB of the boot and then a truncated line. + +With three CDC instances the Zephyr legacy stack's `CONFIG_USB_MAX_NUM_TRANSFERS=4` starves the last instance +(each instance holds an OUT read and an IN ZLP), so interface 1 shows DTR changes but moves no data. +`scripts/usb_cdc_acm/cdc_probe.hpp` (include it, call `cdc_probe()` from an interval) reads the Zephyr driver +directly to tell a dead endpoint from a component bug. Raising the transfer pool to 8, or disabling the +board's `cdc_acm_uart` node, fixes it. Design limit of the driver: `fifo_fill` holds `tx_ready` until the +write callback and `write_array` drops when the ring is full, so a 64 KiB full-duplex echo loses one 64 byte +tail on every buffer size. diff --git a/.agents/skills/hardware-test/reference/recipes/wifi.md b/.agents/skills/hardware-test/reference/recipes/wifi.md new file mode 100644 index 00000000000..055fe267084 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/wifi.md @@ -0,0 +1,25 @@ +# wifi + +## `reboot_timeout` with a fallback AP (#9958) + +Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network, +`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and +ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes: +`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles +are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot +with no station joined, no reboot for minutes while one is, then a reboot within a second of the last +disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way. + +## DHCP lease cache (#17767) + +Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the +`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep +sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use +`upload` then `logs`. + +## Wi-Fi Easy Connect (DPP) enrollee (#17934) + +Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's +QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG. +`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the +supplicant state machine. diff --git a/.agents/skills/hardware-test/reference/recipes/zephyr.md b/.agents/skills/hardware-test/reference/recipes/zephyr.md new file mode 100644 index 00000000000..e10b2010230 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/zephyr.md @@ -0,0 +1,10 @@ +# zephyr + +## Zephyr preferences on the nRF52840 dongle (#20810) + +`templates/nrf52-preferences-dut.yaml`: logger on USB_CDC at VERBOSE, `flash_write_interval: 5s`, one +`globals` with `restore_value` incremented every 15 s. Give each build its own global id so each first boot +sees a never-saved key (settings survive a reflash). Flow per build: flash through the log port, 40 s capture +of the first boot, then start a second capture and reboot with a 2001 baud touch on the dongle's other CDC +port (the dfu rate callback is global, and the capture holds the log port); `serial_capture.py` reopens the +log port after the USB re-enumeration and gets the buffered boot lines (`load setting`, `Loaded N settings`). diff --git a/.agents/skills/hardware-test/reference/recipes/zephyr_coredump.md b/.agents/skills/hardware-test/reference/recipes/zephyr_coredump.md new file mode 100644 index 00000000000..80f2db8574d --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/zephyr_coredump.md @@ -0,0 +1,17 @@ +# zephyr_coredump + +## zephyr_coredump crash and decode on nRF52 boards (#13122) + +`templates/nrf52-coredump-crash-dut.yaml` (dongle; change `board:` to `adafruit_itsybitsy_nrf52840` for the +ItsyBitsy): `zephyr_coredump:`, `dfu: true`, a `restore_value` global keyed by the `run` substitution +(settings survive a reflash, so bump `run` for every new crash), and an `on_boot` sequence that sets the +flag, yields 3 s so the globals loop saves it (a blocking lambda never gets there and the board crash-loops), +syncs preferences, then writes to 0xDEADBEEF. Flash, keep every port closed for 80 s, then +`scripts/zephyr_coredump/flow.sh LABEL PORT ELF [OUTDIR]` does the three opens (3 s cut, 100 s full print, +20 s reopen) and calls `decode.sh`, which keeps the first complete `#CD:BEGIN#..END#` block, runs Zephyr's +`coredump_serial_log_parser.py` and `coredump_gdbserver.py` from the sdk-nrf ESPHome installed and prints +`bt` and `info threads` with its `arm-zephyr-eabi-gdb`. The ELF is `.pioenvs//zephyr/zephyr/zephyr.elf`. +Expect about 9 KB dumps, 140 to 150 `#CD:` lines, a 673 ms loop warning for the erase, and `Has stored +dump: 0` on the reopen. Check `compiled on` in every capture: another session flashing the same board +mid-test looks exactly like a failed test otherwise. Boards without a network cannot exercise the API and +MQTT delivery paths of the component; say so in the results. diff --git a/.agents/skills/hardware-test/reference/recipes/zwave_proxy.md b/.agents/skills/hardware-test/reference/recipes/zwave_proxy.md new file mode 100644 index 00000000000..b9e4ca99018 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/zwave_proxy.md @@ -0,0 +1,23 @@ +# zwave_proxy + +## zwave_proxy frame checksums on a live proxy (#20922) + +A Z-Wave proxy (ESP32-S3 with the module on `usb_uart`) is a live device: flash it over OTA and read logs +over the API, there is no serial console because the native USB is the host port for the module, so the +boot-time `GET_NETWORK_IDS` exchange is never captured. Home Assistant's Z-Wave JS holds the single API +subscription (it polls `0x3B` every 30 s and resubscribes after every reboot), so do not subscribe from a +script, and keep the device `name:` so it stays on Z-Wave JS; give each build its own scratch directory +instead. + +1. `logger: level: VERY_VERBOSE` with `logs: zwave_proxy: VERY_VERBOSE` and DEBUG for the rest: the + `Sending:`, `Received frame:` and `CHECKSUM Received ... Calculated` lines are `ESP_LOGVV`, which needs + the compile-time level. +2. `md5` the two `firmware.ota.bin` files to be sure the A and B builds differ, then `esphome upload + --device .local` and a `timeout 330 esphome logs --device .local` capture started right after + the OTA to catch the resubscribe traffic. +3. `scripts/zwave_proxy/verify_checksums.py ` recomputes the XOR (init 0xFF, LENGTH..payload) over + every logged frame and counts bad checksums and ACK/NAK sent and received; `scripts/zwave_proxy/devinfo.py + .local` prints the firmware version, compile time and the Home ID from the API (a Home ID proves the + device-built `GET_NETWORK_IDS` frame was accepted at boot). +4. Pre-existing: one `zwave_proxy took a long time for an operation` warning on the first frame after the + log client connects, caused by VERY_VERBOSE logging; it appears on dev too. diff --git a/.agents/skills/hardware-test/scripts/cse7766/observe.py b/.agents/skills/hardware-test/scripts/cse7766/observe.py new file mode 100644 index 00000000000..e88e1da1b2e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/cse7766/observe.py @@ -0,0 +1,71 @@ +"""Subscribe to states and logs of a device over the API for N seconds and summarize its float sensors. + +usage: observe.py IP SECONDS OUTFILE [NAME_FILTER] + +Every float sensor state whose entity name contains NAME_FILTER (default: all) is appended to OUTFILE +with a relative timestamp, log lines are appended with a LOG prefix, and a one-line summary per sensor +(count, min, max, last) plus the warning and error count is printed at the end. +""" + +import asyncio +import pathlib +import re +import sys +import time + +from aioesphomeapi import APIClient, LogLevel + +IP, SECS, OUT = sys.argv[1], float(sys.argv[2]), sys.argv[3] +FILTER = sys.argv[4] if len(sys.argv) > 4 else "" +# ESPHome LOG_SECRET span; one cut off before its end marker is secret to the end of the line +SECRET = re.compile(r"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL) +ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +async def main() -> None: + c = APIClient(IP, 6053, None) + await c.connect(login=True) + di = await c.device_info() + ents, _ = await c.list_entities_services() + names = {e.key: e.name for e in ents} + readings: dict[str, list[float]] = {} + warnings: list[str] = [] + t0 = time.monotonic() + with pathlib.Path(OUT).open("w", encoding="utf-8") as out: + out.write( + f"device {di.name} version {di.esphome_version} compiled {di.compilation_time}\n" + ) + + def on_state(st) -> None: + n = names.get(st.key, str(st.key)) + if FILTER in n and hasattr(st, "state") and isinstance(st.state, float): + readings.setdefault(n, []).append(st.state) + out.write(f"[{time.monotonic() - t0:6.1f}] {n}: {st.state}\n") + out.flush() + + def on_log(msg) -> None: + line = ( + msg.message.decode(errors="replace") + if isinstance(msg.message, bytes) + else msg.message + ) + line = ANSI.sub("", SECRET.sub("", line)) + out.write(f"[{time.monotonic() - t0:6.1f}] LOG {line}\n") + out.flush() + if "[W]" in line or "[E]" in line or "Invalid" in line: + warnings.append(line) + + c.subscribe_states(on_state) + c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE) + await asyncio.sleep(SECS) + await c.disconnect() + summary = "; ".join( + f"{k.split()[-1]} n={len(v)} min={min(v):.3f} max={max(v):.3f} last={v[-1]:.3f}" + for k, v in sorted(readings.items()) + ) + print(f"{di.compilation_time}: {summary}; warnings/errors {len(warnings)}") + for w in warnings[:5]: + print(" ", w) + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/cse7766/run_obs.sh b/.agents/skills/hardware-test/scripts/cse7766/run_obs.sh new file mode 100755 index 00000000000..e9e9083f978 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/cse7766/run_obs.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +# usage: run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER] +# Observes states and logs over the API with a concurrent ping and prints a summary: the device-side +# "frames parsed" counter per 10 s, gaps over 5 s between voltage updates seen by the API, and warnings. +# Needs a python with aioesphomeapi (PYTHON, default python3). +D=${0:A:h} +IP=$1; SECS=$2; L=$3; S=${4:-.}; F=${5:-} +PY=${PYTHON:-python3} +setopt pipefail +ping -i 1 -c $SECS $IP > $S/ping-$L.txt 2>&1 & +P=$! +if ! timeout $((SECS + 40)) $PY $D/observe.py $IP $SECS $S/obs-$L.txt "$F" 2>&1 | sed "s/$IP//g"; then + echo "observe.py failed or timed out, no summary"; wait $P; exit 1 +fi +wait $P +echo "ping: $(tail -2 $S/ping-$L.txt | head -1 | sed "s/$IP//g") $(tail -1 $S/ping-$L.txt)" +python3 - "$S/obs-$L.txt" <<'PY' +import re, sys +txt = open(sys.argv[1]).read() +fr = [int(m.group(1)) for m in re.finditer(r"frames parsed: (\d+) in 10 s", txt)] +print(f"device frame counter per 10 s: n={len(fr)} min={min(fr) if fr else None} max={max(fr) if fr else None} mean={sum(fr)/len(fr) if fr else 0:.1f}") +ts = [float(m.group(1)) for m in re.finditer(r"^\[\s*([0-9.]+)\] .*Voltage: ", txt, re.M)] +gaps = [round(b - a, 1) for a, b in zip(ts, ts[1:]) if b - a > 5] +print(f"API voltage updates {len(ts)}, gaps over 5 s: {gaps}") +warn = [l for l in txt.splitlines() if "LOG" in l and ("[W]" in l or "[E]" in l or "Invalid" in l)] +print(f"warnings/errors in log: {len(warn)}", *warn[:3], sep="\n ") +PY diff --git a/.agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp b/.agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp new file mode 100644 index 00000000000..b36739c362b --- /dev/null +++ b/.agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp @@ -0,0 +1,116 @@ +// BK72xx crash probe: forces one crash per step and shows what the SDK and the bootloader leave behind. +// Steps: 0 undefined instruction, 1 call into unmapped memory, 2 unmapped and unaligned accesses (no fault), +// 3 lt_reboot(). The step lives in a flash preference saved and synced before the crash. +#pragma once +#include +#include +#include +#include +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" + +extern "C" unsigned bk_misc_get_start_type(void); +extern "C" char _bss_end[]; + +static constexpr uint32_t PROBE_CANARY_LOW = 0x004000F8; // free word in the SDK-reserved low SRAM +static constexpr uint32_t PROBE_CANARY_ITCM = 0x003F3FF0; // top of ITCM +static char bkprobe_report[112]; +static constexpr uint32_t PROBE_MARKS[] = {0x40A1B0, 0x40B000, 0x40C000, 0x40E000, 0x410000, 0x414000, 0x418000, + 0x41C000, 0x420000, 0x428000, 0x430000, 0x438000, 0x43C000, 0x43E000, + 0x43F000, 0x43F800, 0x43FC00, 0x43FE00, 0x43FFF0}; +static constexpr size_t PROBE_MARK_COUNT = sizeof(PROBE_MARKS) / sizeof(PROBE_MARKS[0]); + +static inline uint32_t rd(uint32_t a) { return *(volatile uint32_t *) a; } + +inline esphome::ESPPreferenceObject bkprobe_pref(uint32_t run) { + return esphome::global_preferences->make_preference(0xBC000000u + run); +} + +inline int bkprobe_boot(uint32_t run) { + using namespace esphome; + int step = 0; + bkprobe_pref(run).load(&step); + unsigned st = bk_misc_get_start_type(); + snprintf(bkprobe_report, sizeof(bkprobe_report), "step=%d reason=%s(%d) start_type=%u low=%08lx itcm=%08lx top=%08lx", + step, lt_get_reboot_reason_name(lt_get_reboot_reason()), (int) lt_get_reboot_reason(), st, + (unsigned long) rd(PROBE_CANARY_LOW), (unsigned long) rd(PROBE_CANARY_ITCM), + (unsigned long) rd(0x0043FFF0u)); + ESP_LOGI("probe", "%s", bkprobe_report); + for (uint32_t a = 0x400000; a < 0x400100; a += 32) { + ESP_LOGI("probe", "%06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a, + (unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12), + (unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24), + (unsigned long) rd(a + 28)); + } + for (size_t i = 0; i < PROBE_MARK_COUNT; i += 7) { + char line[120]; + int n = 0; + for (size_t j = i; j < i + 7 && j < PROBE_MARK_COUNT; j++) + n += snprintf(line + n, sizeof(line) - n, " %06lx=%08lx", (unsigned long) PROBE_MARKS[j], + (unsigned long) rd(PROBE_MARKS[j])); + ESP_LOGI("probe", "marks%s", line); + } + uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u; + for (uint32_t a = rec; a < rec + 64; a += 32) { + ESP_LOGI("probe", "rec %06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a, + (unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12), + (unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24), + (unsigned long) rd(a + 28)); + } + *(volatile uint32_t *) PROBE_CANARY_LOW = 0xC0DE0000u | (uint32_t) step; + *(volatile uint32_t *) PROBE_CANARY_ITCM = 0x17C40000u | (uint32_t) step; + return step; +} + +__attribute__((noinline)) void bkprobe_data_abort() { + using namespace esphome; + volatile uint32_t v = rd(0xFFFFFFF0); + ESP_LOGI("probe", "read 0xFFFFFFF0 -> %08lx (no fault)", (unsigned long) v); + uint32_t r; + __asm__ volatile("ldr %0, [%1]" : "=r"(r) : "r"(0x00400101) : "memory"); + ESP_LOGI("probe", "unaligned ldr 0x400101 -> %08lx (no fault)", (unsigned long) r); + *(volatile uint32_t *) 0xFFFFFFF0 = 1; + ESP_LOGI("probe", "store 0xFFFFFFF0 (no fault)"); +} + +// act is the step number before it was bumped +inline void bkprobe_act(uint32_t run, int act) { + using namespace esphome; + int next = act + 1; + bkprobe_pref(run).save(&next); + global_preferences->sync(); + ESP_LOGI("probe", "acting on step %d (saved %d)", act, next); + // mark candidate storage areas so the next boot shows what survives + uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u; + for (uint32_t i = 0; i < 8; i++) + *(volatile uint32_t *) (rec + 32 + i * 4) = 0xA5A50000u | i; // second half of the record area + for (size_t i = 0; i < PROBE_MARK_COUNT; i++) + *(volatile uint32_t *) PROBE_MARKS[i] = 0x70700000u | (uint32_t) i; + ESP_LOGI("probe", "ramtop before: %08lx", (unsigned long) rd(0x0043FFF0u)); + vTaskDelay(pdMS_TO_TICKS(50)); + switch (act) { + case 0: + __builtin_trap(); // undefined instruction + break; + case 1: { + void (*fn)() = (void (*)()) 0xFFFFFFF1; // prefetch abort (unmapped, Thumb) + fn(); + ESP_LOGI("probe", "call 0xFFFFFFF1 returned (no fault)"); + break; + } + case 2: + bkprobe_data_abort(); + break; + case 3: + lt_reboot(); + break; + default: + break; + } +} + +inline void bkprobe_alive(int step) { + using namespace esphome; + ESP_LOGI("probe", "alive step=%d uptime=%lus boot: %s", step, (unsigned long) (millis() / 1000), bkprobe_report); +} diff --git a/.agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py b/.agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py new file mode 100644 index 00000000000..366dbe65d47 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py @@ -0,0 +1,25 @@ +"""Feed a serial capture through the CLI's stacktrace decoder for a build. + +usage: decode_capture.py CONFIG_YAML CAPTURE + +Run from the directory that holds the config (its .esphome build is used), with the esphome checkout that +built it on PYTHONPATH. Lines of the crash report are echoed, decoded frames follow them. +""" + +import logging +from pathlib import Path +import sys + +from esphome.config import read_config +from esphome.core import CORE +from esphome.stacktrace import LogLineProcessor + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") +yaml, capture = Path(sys.argv[1]), Path(sys.argv[2]) +CORE.config_path = yaml +config = read_config({}) +proc = LogLineProcessor(config, CORE.target_platform) +for line in capture.read_text(encoding="utf-8", errors="replace").splitlines(): + if "libretiny.crash" in line: + print(line[:120]) + proc.process_line(line) diff --git a/.agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp b/.agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp new file mode 100644 index 00000000000..295ba7a9814 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp @@ -0,0 +1,155 @@ +#pragma once +// BK7238 bench harness for esphome#21007: one rtc and one flash preference, a step counter in flash, +// and an exit action per step so the board drives its own soft reboot, crash and watchdog reboot. +#include +#include +#include +#include +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" + +using namespace esphome; + +// Boot report kept for the alive line, so a boot the host could not capture is still visible +static char ltprefs_report[128]; +// Last boots' reports, kept in a flash preference so boots the host never saw stay readable +struct LtprefsHistory { + char line[8][128]; +}; +static LtprefsHistory ltprefs_hist; + +inline ESPPreferenceObject ltprefs_step_pref(uint32_t run) { + return global_preferences->make_preference(0x3B210070u ^ run, true); +} + +// Loads the step and both preferences, logs them, saves this step's value. +// Returns the exit action: 0 idle, 1 lt_reboot, 2 crash, 3 watchdog. +inline int ltprefs_boot(int &step, uint32_t run) { + auto reason = lt_get_reboot_reason(); + step = 0; + ltprefs_step_pref(run).load(&step); + auto rtcp = global_preferences->make_preference(0x1B210070u ^ run, false); + auto flp = global_preferences->make_preference(0x2B210070u ^ run, true); + uint32_t rv = 0, fv = 0; + // a second pair sharing one key as rtc and as flash, and a third rtc entry, so a hard reset drops three + auto rtcp2 = global_preferences->make_preference(0x5B210070u ^ run, false); + auto flp2 = global_preferences->make_preference(0x5B210070u ^ run, true); + auto rtcp3 = global_preferences->make_preference(0x6B210070u ^ run, false); + uint32_t rv2 = 0, fv2 = 0, rv3 = 0; + bool rok = rtcp.load(&rv); + bool fok = flp.load(&fv); + bool rok2 = rtcp2.load(&rv2); + bool fok2 = flp2.load(&fv2); + bool rok3 = rtcp3.load(&rv3); + snprintf(ltprefs_report, sizeof(ltprefs_report), + "step=%d reason=%s(%d) rtc=%s%u flash=%s%u sharedkey rtc=%s%u flash=%s%u rtc3=%s%u", step, + lt_get_reboot_reason_name(reason), (int) reason, rok ? "" : "none ", rv, fok ? "" : "none ", fv, + rok2 ? "" : "none ", rv2, fok2 ? "" : "none ", fv2, rok3 ? "" : "none ", rv3); + ESP_LOGI("lttest", "%s", ltprefs_report); + auto histp = global_preferences->make_preference(0x4B210070u ^ run, true); + if (!histp.load(<prefs_hist)) + memset(<prefs_hist, 0, sizeof(ltprefs_hist)); + memmove(<prefs_hist.line[1], <prefs_hist.line[0], sizeof(ltprefs_hist.line) - sizeof(ltprefs_hist.line[0])); + strncpy(ltprefs_hist.line[0], ltprefs_report, sizeof(ltprefs_hist.line[0]) - 1); + ltprefs_hist.line[0][sizeof(ltprefs_hist.line[0]) - 1] = 0; + if (!histp.save(<prefs_hist)) { + ESP_LOGW("lttest", "history save failed"); + } + int act = 0; + bool save = true; + switch (step) { + case 0: + act = 1; + break; // soft reboot + case 1: + act = 2; + break; // crash + case 2: + act = 3; + break; // watchdog + case 3: + act = 0; + break; // idle: chip-enable reset from the host + case 4: + act = 1; + break; // soft reboot after the hard reset + case 5: + act = 0; + break; // idle: chip-enable reset from the host + case 6: + act = 1; + save = false; + break; // no save, soft reboot: deleted, not skipped? + case 7: + act = 0; + break; // idle + case 8: + act = 2; + break; // crash + case 9: + act = 0; + break; // idle + case 10: + act = 0; + break; // idle: power cut by the user + default: + act = 0; + save = false; + break; + } + if (save) { + uint32_t v = 100 + step, v2r = 200 + step, v2f = 300 + step, v3 = 400 + step; + bool srtc = rtcp.save(&v); + bool sfl = flp.save(&v); + bool srtc2 = rtcp2.save(&v2r); + bool sfl2 = flp2.save(&v2f); + bool srtc3 = rtcp3.save(&v3); + bool synced = global_preferences->sync(); + ESP_LOGI("lttest", "save %u/%u/%u/%u: rtc=%d flash=%d sharedkey rtc=%d flash=%d rtc3=%d sync=%d", v, v2r, v2f, v3, + srtc, sfl, srtc2, sfl2, srtc3, synced); + } + ESP_LOGI("lttest", "exit action=%d", act); + return act; +} + +inline void ltprefs_exit(int &step, uint32_t run, int act) { + step++; + bool saved = ltprefs_step_pref(run).save(&step); + bool synced = global_preferences->sync(); + ESP_LOGI("lttest", "step %d save=%d sync=%d", step, saved, synced); + if (!saved || !synced) { + ESP_LOGE("lttest", "step not stored, staying idle instead of repeating the step"); + return; + } + vTaskDelay(pdMS_TO_TICKS(200)); + switch (act) { + case 1: + ESP_LOGW("lttest", "soft reboot now"); + vTaskDelay(pdMS_TO_TICKS(50)); + lt_reboot(); + break; + case 2: + ESP_LOGW("lttest", "crashing now"); + vTaskDelay(pdMS_TO_TICKS(50)); + __builtin_trap(); // undefined instruction: a plain bad store does not fault on the ARM968 + break; + case 3: + ESP_LOGW("lttest", "watchdog reboot now"); + vTaskDelay(pdMS_TO_TICKS(50)); + lt_reboot_wdt(); + break; + default: + ESP_LOGW("lttest", "idle, waiting for an external reset"); + break; + } +} + +inline void ltprefs_alive(int step, int act) { + ESP_LOGI("lttest", "alive step=%d act=%d boot: %s", step, act, ltprefs_report); + for (int i = 7; i >= 0; i--) { + if (ltprefs_hist.line[i][0] != 0) { + ESP_LOGI("lttest", " history[-%d]: %s", i, ltprefs_hist.line[i]); + } + } +} diff --git a/.agents/skills/hardware-test/scripts/online_image/drive.py b/.agents/skills/hardware-test/scripts/online_image/drive.py new file mode 100644 index 00000000000..2fbf3c47b56 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/online_image/drive.py @@ -0,0 +1,179 @@ +"""Drive the online_image cases over the native API and judge each from the serial capture.""" + +import asyncio +import pathlib +import re +import sys +import time + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, +) + +HOST, KEY, CAPTURE, BASE, MODE = sys.argv[1:6] +CAP_S = 45 + +CASES = [ + # (label, action, path, expected outcome) + ("ok bmp", "fetch", "ok.bmp", "finished"), + ("ok png", "fetch", "ok.png", "finished"), + ("ok jpg", "fetch", "ok.jpg", "finished"), + ("ok qoi", "fetch", "ok.qoi", "finished"), + ("truncated bmp", "fetch", "truncated.bmp", "error"), + ("ok bmp after error", "fetch", "ok.bmp", "finished"), + ("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"), + ("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"), + ("empty body", "fetch", "empty.bmp", "error"), + ("stalled connection", "fetch", "stall.bmp", "error"), + ("closed early", "fetch", "closed_early.bmp", "error"), + ("slow but alive", "fetch", "slow.bmp", "finished"), + ("chunked bmp", "fetch", "chunked.bmp", "finished"), + ("chunked png", "fetch", "chunked.png", "finished"), + ("chunked qoi", "fetch", "chunked.qoi", "finished"), + ("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"), + ("chunked png cut", "fetch", "chunked_cut.png", "any"), + ("no content-length", "fetch", "noclen.bmp", "any"), + ("huge jpeg claim", "fetch", "huge.jpg", "error"), + ("ok jpg after huge", "fetch", "ok.jpg", "finished"), + ("ok bmp again", "fetch", "ok.bmp", "finished"), + ("ok bmp refetch: 304", "refetch", "", "finished"), + ("ok png", "fetch", "ok.png", "finished"), + ("ok png refetch: 304", "refetch", "", "finished"), + ("chunked bmp", "fetch", "chunked.bmp", "finished"), + ("chunked bmp refetch: 304", "refetch", "", "finished"), + ("chunked png", "fetch", "chunked.png", "finished"), + ("chunked png refetch: not cached", "refetch", "", "finished"), + ("chunked png cut", "fetch", "chunked_cut.png", "any"), + ("chunked png cut refetch: not cached", "refetch", "", "any"), +] +if MODE == "base": + # Each stuck case is followed by a fetch that shows the symptom, then a reboot. + CASES = [ + ("ok bmp", "fetch", "ok.bmp", "finished"), + ("truncated bmp", "fetch", "truncated.bmp", "error"), + ("ok bmp after truncated", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"), + ("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("empty body", "fetch", "empty.bmp", "error"), + ("ok bmp after empty", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("stalled connection", "fetch", "stall.bmp", "error"), + ("ok bmp after stall", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("closed early", "fetch", "closed_early.bmp", "error"), + ("ok bmp after closed early", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("chunked png", "fetch", "chunked.png", "finished"), + ("ok bmp after chunked png", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"), + ("ok bmp after chunked cut", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("huge jpeg claim", "fetch", "huge.jpg", "error"), + ("ok jpg after huge", "fetch", "ok.jpg", "finished"), + ] + +OUTCOME = re.compile(r"download (finished|error): (img_small|img)") +INTEREST = re.compile( + r"\[(E|W)\]\[online_image|Image already being updated|download (finished|error)|Downloading image|Updating image|Image fully downloaded|HTTP 304|allocat|Panic|abort\(\)|Guru|Backtrace|rst:0x", + re.IGNORECASE, +) + + +def tail(path: str, offset: int) -> tuple[list[str], int]: + with pathlib.Path(path).open("rb") as f: + f.seek(offset) + data = f.read() + return data.decode("utf-8", "replace").splitlines(), offset + len(data) + + +async def connect() -> tuple[APIClient, dict, int]: + for _ in range(60): + try: + c = APIClient(HOST, 6053, None, noise_psk=KEY) + await c.connect(login=True) + ents, services = await c.list_entities_services() + except (InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError) as exc: + raise SystemExit(f"API key problem, not retrying: {exc}") from exc + except (APIConnectionError, OSError) as exc: + # only connection trouble is retried; a wrong key or a missing button must fail at once + print(f"connect: {exc}", flush=True) + await asyncio.sleep(3) + continue + btn = next( + (e.key for e in ents if getattr(e, "object_id", "") == "reboot"), None + ) + if btn is None: + raise SystemExit("the config has no reboot button") + return c, {s.name: s for s in services}, btn + raise SystemExit("API never connected") + + +async def main() -> None: + client, actions, btn = await connect() + with pathlib.Path(CAPTURE).open("rb") as f: + f.seek(0, 2) + offset = f.tell() + rows = [] + for label, action, path, expect in CASES: + if label == "REBOOT": + client.button_command(btn) + await asyncio.sleep(1) + await client.disconnect() + await asyncio.sleep(8) + client, actions, btn = await connect() + _, offset = tail(CAPTURE, offset) + print("--- rebooted", flush=True) + continue + img = "img_small" if action.endswith("_small") else "img" + t0 = time.monotonic() + args = {} if action.startswith("refetch") else {"url": f"{BASE}/{path}"} + await client.execute_service(actions[action], args) + outcome, notes = "no outcome", [] + while time.monotonic() - t0 < CAP_S: + await asyncio.sleep(0.5) + lines, offset = tail(CAPTURE, offset) + done = False + for line in lines: + if INTEREST.search(line): + notes.append(line.strip()) + m = OUTCOME.search(line) + if m and m.group(2) == img: + outcome = m.group(1) + done = True + if done: + break + took = time.monotonic() - t0 + if outcome == "no outcome": + took = CAP_S + # drain a little more so the "already being updated" line lands + await asyncio.sleep(1) + lines, offset = tail(CAPTURE, offset) + notes += [line.strip() for line in lines if INTEREST.search(line)] + ok = outcome == expect or (expect == "any" and outcome != "no outcome") + rows.append((label, path, expect, outcome, took, ok, notes)) + print( + f"{'PASS' if ok else 'FAIL'} {label:32s} {path:18s} expected {expect:9s} got {outcome:10s} {took:5.1f}s", + flush=True, + ) + for n in notes: + print(f" {n}", flush=True) + await asyncio.sleep(1.5) + await client.disconnect() + print( + "\nSUMMARY", + "all expectations met" + if all(r[5] for r in rows) + else "MISMATCHES: " + ", ".join(r[0] for r in rows if not r[5]), + flush=True, + ) + if not all(r[5] for r in rows): + sys.exit(1) + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/online_image/make_images.py b/.agents/skills/hardware-test/scripts/online_image/make_images.py new file mode 100644 index 00000000000..a7e3451d291 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/online_image/make_images.py @@ -0,0 +1,38 @@ +"""Build the small test images the HTTP server serves.""" + +from pathlib import Path +import struct +import sys + +from PIL import Image + +out = Path(sys.argv[1]) +out.mkdir(exist_ok=True) +w = h = 16 +im = Image.new("RGB", (w, h)) +px = im.load() +for y in range(h): + for x in range(w): + px[x, y] = (x * 16, y * 16, (x + y) * 8) +im.save(out / "ok.bmp", "BMP") +im.save(out / "ok.png", "PNG") +im.save(out / "ok.jpg", "JPEG", quality=90) + +# QOI with only QOI_OP_RGB codes: valid and trivially encoded. +qoi = b"qoif" + struct.pack(">IIBB", w, h, 3, 0) +for y in range(h): + for x in range(w): + qoi += bytes([0xFE, *px[x, y]]) +qoi += bytes(7) + b"\x01" +(out / "ok.qoi").write_bytes(qoi) + +# 8x8 8-bit palette BMP: 1078 byte header, pixels start beyond a 256 byte buffer. +pw = ph = 8 +data_offset = 14 + 40 + 256 * 4 +file_size = data_offset + pw * ph +hdr = b"BM" + struct.pack(" None: + LOG.write(f"{time.strftime('%H:%M:%S')} {msg}\n") + + +def headers(ctype: str, extra: list[bytes]) -> bytes: + lines = [ + b"HTTP/1.1 200 OK", + b"Content-Type: " + ctype.encode(), + b"Connection: close", + *extra, + b"", + b"", + ] + return b"\r\n".join(lines) + + +def etag(path: str) -> bytes: + return b'"' + path.strip("/").encode() + b'-v1"' + + +def chunked(body: bytes, size: int) -> bytes: + out = b"" + for i in range(0, len(body), size): + part = body[i : i + size] + out += b"%x\r\n" % len(part) + part + b"\r\n" + return out + b"0\r\n\r\n" + + +async def respond(path: str, req: bytes, w: asyncio.StreamWriter) -> str: + name, _, ext = path.lstrip("/").rpartition(".") + ctype = MIME.get(ext, "application/octet-stream") + body = IMG.get(ext, BMP) + tag = [b"ETag: " + etag(path)] + if b"if-none-match: " + etag(path).lower() in req.lower(): + w.write( + b"HTTP/1.1 304 Not Modified\r\nETag: " + + etag(path) + + b"\r\nConnection: close\r\n\r\n" + ) + return "304 not modified" + if name == "ok": + w.write(headers(ctype, [b"Content-Length: %d" % len(body), *tag]) + body) + return f"full {len(body)} bytes" + if name == "truncated": # cut one byte into the sixth pixel, Content-Length matches + cut = BMP[: BMP_HDR + 5 * 3 + 1] + w.write(headers(ctype, [b"Content-Length: %d" % len(cut)]) + cut) + return f"truncated {len(cut)} bytes" + if name == "large_header": + w.write( + headers(ctype, [b"Content-Length: %d" % len(LARGE_HEADER)]) + LARGE_HEADER + ) + return f"large header {len(LARGE_HEADER)} bytes" + if name == "empty": + w.write(headers(ctype, [b"Content-Length: 0"])) + return "empty body" + if ( + name == "stall" + ): # claims the full length, sends part, then holds the socket open + part = body[: BMP_HDR + 15] + w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part) + await w.drain() + await asyncio.sleep(40) + return f"stalled after {len(part)} bytes, closed after 40 s" + if name == "closed_early": # claims the full length, sends part, closes at once + part = body[: BMP_HDR + 15] + w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part) + return f"closed after {len(part)} of {len(body)} bytes" + if name == "slow": # pauses under the timeout, then completes + w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + body[:BMP_HDR]) + await w.drain() + await asyncio.sleep(2.5) + w.write(body[BMP_HDR:]) + return f"full {len(body)} bytes with a 2.5 s pause" + if name == "chunked": + w.write( + headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(body, 64) + ) + return f"chunked full {len(body)} bytes" + if ( + name == "chunked_cut" + ): # chunked, ends cleanly at a pixel boundary (or half a png) + cut = body[: BMP_HDR + 5 * 3] if ext == "bmp" else body[: len(body) // 2] + w.write( + headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(cut, 64) + ) + return f"chunked cut {len(cut)} of {len(body)} bytes" + if name == "noclen": # body delimited by connection close + w.write(headers(ctype, []) + body) + return f"no content-length, full {len(body)} bytes" + if name == "huge": # claims 20 MB so the JPEG buffer resize fails + w.write(headers(ctype, [b"Content-Length: 20000000"]) + body) + return f"claims 20000000, sent {len(body)} bytes" + w.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + return "404" + + +async def handle(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None: + peer = w.get_extra_info("peername") + try: + async with asyncio.timeout(5): + line = await r.readuntil(b"\r\n") + req = await r.readuntil(b"\r\n\r\n") + path = line.split(b" ")[1].decode() + log( + f"GET {path} from {peer}" + + (" (If-None-Match)" if b"if-none-match" in req.lower() else "") + ) + what = await respond(path, req, w) + await w.drain() + log(f" -> {path}: {what}") + except Exception as exc: # noqa: BLE001 + log(f" !! {peer}: {exc!r}") + finally: + w.close() + + +async def main() -> None: + server = await asyncio.start_server(handle, "0.0.0.0", PORT) + log(f"listening on {PORT}") + async with server: + await server.serve_forever() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/cam_api.py b/.agents/skills/hardware-test/scripts/rtsp_server/cam_api.py new file mode 100644 index 00000000000..53c9996b3ba --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/cam_api.py @@ -0,0 +1,33 @@ +"""Request one camera image over the native API and report its size (direct esp32_camera check).""" + +import asyncio +import sys + +from aioesphomeapi import APIClient + + +async def main(): + c = APIClient(sys.argv[1], 6053, None) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + cams = [e for e in ents if type(e).__name__ == "CameraInfo"] + print("camera entities:", [e.name for e in cams]) + got = asyncio.get_event_loop().create_future() + + def on_state(state): + if type(state).__name__ == "CameraState" and not got.done(): + got.set_result(len(state.data)) + + c.subscribe_states(on_state) + c.request_single_image() + try: + n = await asyncio.wait_for(got, 10) + print("single image bytes:", n) + except TimeoutError: + print("no image within 10 s") + await c.disconnect() + sys.exit(1) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/measure.sh b/.agents/skills/hardware-test/scripts/rtsp_server/measure.sh new file mode 100755 index 00000000000..b8b4d30326f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/measure.sh @@ -0,0 +1,32 @@ +#!/bin/zsh +# usage: measure.sh IP LABEL LOGFILE [OUTDIR] +# Runs the fixed RTSP measurement set against one build and appends a result line to OUTDIR/results.txt: +# ping RTT, two 12 s ffmpeg runs, two concurrent ffmpeg clients, and the raw receive rate with the default +# and a 1 MiB socket buffer. LOGFILE is the device capture, used to count "stalled" drops during the run. +# A measurement that produced nothing is written as ERR and the script exits 1, so a broken run never looks +# like a measured one. Needs ffmpeg and a python with the rtsp scripts' dependencies (PYTHON, default python3). +D=${0:A:h} +IP=$1; LABEL=$2; LOG=$3; S=${4:-.} +PY=${PYTHON:-python3} +[ -f "$LOG" ] || { echo "device capture not found: $LOG"; exit 1; } +rtt=$(ping -c 10 -i 0.2 $IP 2>&1 | tail -1 | awk -F/ '{print $5}') +stall0=$(grep -a -c "stalled" $LOG) +frames() { # OUTFILE RC: the frame count of a run that finished on its own, nothing for a failed or killed one + [ "$2" = 0 ] && grep -o 'frame= *[0-9]*' $1 | tail -1 | tr -dc 0-9 +} +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f1.txt 2>&1; f1=$(frames $S/m-f1.txt $?); sleep 2 +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f2.txt 2>&1; f2=$(frames $S/m-f2.txt $?); sleep 2 +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoA.txt 2>&1 & A=$!; sleep 1 +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoB.txt 2>&1 & B=$! +wait $A; ra=$?; wait $B; rb=$? +fa=$(frames $S/m-twoA.txt $ra); fb=$(frames $S/m-twoB.txt $rb); sleep 2 +timeout 40 $PY $D/rawrate2.py $IP quiet > $S/m-raw.txt 2>&1 && raw=$(grep -o "[0-9.]* KiB/s" $S/m-raw.txt | head -1); sleep 2 +timeout 40 $PY $D/rawrate2.py $IP bigbuf > $S/m-raw2.txt 2>&1 && raw2=$(grep -o "[0-9.]* KiB/s" $S/m-raw2.txt | head -1); sleep 1 +stall1=$(grep -a -c "stalled" $LOG) +bad=0 +for v in rtt f1 f2 fa fb raw raw2; do + if [ -z "${(P)v}" ]; then eval "$v=ERR"; bad=1; fi +done +line="$LABEL | rtt avg ${rtt} ms | ffmpeg single 12 s: ${f1}, ${f2} frames | two clients: ${fa} + ${fb} | python raw: ${raw} (1 MiB rcvbuf ${raw2}) | stalls logged: $((stall1 - stall0))" +echo "$line" | tee -a $S/results.txt +if [ $bad = 1 ]; then echo "one or more measurements failed (ERR above)"; exit 1; fi diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py b/.agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py new file mode 100644 index 00000000000..f65329c4025 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py @@ -0,0 +1,83 @@ +import contextlib +import socket +import sys +import time + +IP = sys.argv[1] +mode = sys.argv[2] +secs = 8 +s = socket.create_connection((IP, 554), timeout=5) +if mode == "bigbuf": + s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20) +cseq = 0 + + +buf = b"" + + +def read_response() -> bytes: + """One complete RTSP response: headers through the blank line plus a Content-Length body.""" + global buf # noqa: PLW0603 + while (end := buf.find(b"\r\n\r\n")) < 0: + chunk = s.recv(4096) + if not chunk: + raise ConnectionError("closed") + buf += chunk + head = buf[:end].decode(errors="replace") + body_len = 0 + for line in head.split("\r\n"): + if line.lower().startswith("content-length:"): + body_len = int(line.split(":", 1)[1]) + while len(buf) < end + 4 + body_len: + chunk = s.recv(4096) + if not chunk: + raise ConnectionError("closed") + buf += chunk + resp, buf = buf[: end + 4 + body_len], buf[end + 4 + body_len :] + return resp + + +def req(m, extra="", wait=True): + global cseq # noqa: PLW0603 + cseq += 1 + url = f"rtsp://{IP}:554/trackID=0" if m == "SETUP" else f"rtsp://{IP}:554/" + s.sendall(f"{m} {url} RTSP/1.0\r\nCSeq: {cseq}\r\n{extra}\r\n".encode()) + return read_response() if wait else b"" + + +req("OPTIONS") +req("DESCRIBE", "Accept: application/sdp\r\n") +r = req("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n") +sess = ( + [x for x in r.decode(errors="replace").split("\r\n") if x.startswith("Session")][0] + .split()[1] + .split(";")[0] +) +req("PLAY", f"Session: {sess}\r\n") +t0 = time.monotonic() +total = 0 +chunks = 0 +last_chat = t0 +s.settimeout(2) +while time.monotonic() - t0 < secs: + now = time.monotonic() + if mode == "chatty" and now - last_chat > 0.2: + req("GET_PARAMETER", f"Session: {sess}\r\n", wait=False) + last_chat = now + if mode == "rtcp" and now - last_chat > 0.2: + s.sendall(b"$\x01\x00\x08" + b"\x80\xc9\x00\x01\x00\x00\x00\x01") + last_chat = now + try: + d = s.recv(65536) + except TimeoutError: + continue + if not d: + break + total += len(d) + chunks += 1 +print( + f"{mode:7s}: {total / 1024 / secs:5.1f} KiB/s, {chunks} recvs, avg {total // max(chunks, 1)} B/recv" +) +with contextlib.suppress(OSError): + req("TEARDOWN", f"Session: {sess}\r\n") +s.close() diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py b/.agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py new file mode 100644 index 00000000000..6cec93b3a4e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py @@ -0,0 +1,345 @@ +"""Raw RTSP/TCP client for the esphome rtsp_server hardware test. + +usage: rtsp_client.py HOST [case ...] +cases: play, udp, pipeline, third, stall, teardown, getparam, badreq, replay, timeout +""" + +import socket +import struct +import sys +import time + +HOST = sys.argv[1] +PORT = 554 +cases = sys.argv[2:] or [ + "play", + "udp", + "pipeline", + "badreq", + "replay", + "getparam", + "teardown", + "third", + "stall", +] +results = [] + + +def say(case, ok, detail): + results.append((case, ok, detail)) + print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True) + + +class Client: + def __init__(self, timeout=5): + self.s = socket.create_connection((HOST, PORT), timeout=timeout) + self.cseq = 0 + self.session = None + self.buf = b"" + + def send(self, method, extra="", url=None): + self.cseq += 1 + url = ( + url or f"rtsp://{HOST}:{PORT}/trackID=0" + if method == "SETUP" + else (url or f"rtsp://{HOST}:{PORT}/") + ) + req = f"{method} {url} RTSP/1.0\r\nCSeq: {self.cseq}\r\n" + if self.session: + req += f"Session: {self.session}\r\n" + req += extra + "\r\n" + self.s.sendall(req.encode()) + return self.cseq + + def _recv(self): + chunk = self.s.recv(4096) + if not chunk: + raise ConnectionError("closed") + self.buf += chunk + + def read_response(self): + # Read until a full RTSP response (headers + optional body); skip any interleaved data in between + while True: + if self.buf.startswith(b"$"): + if len(self.buf) < 4: + self._recv() + continue + ln = struct.unpack(">H", self.buf[2:4])[0] + if len(self.buf) < 4 + ln: + self._recv() + continue + self.buf = self.buf[4 + ln :] + continue + end = self.buf.find(b"\r\n\r\n") + if end < 0: + self._recv() + continue + head = self.buf[:end].decode(errors="replace") + body_len = 0 + for line in head.split("\r\n"): + if line.lower().startswith("content-length:"): + body_len = int(line.split(":", 1)[1]) + while len(self.buf) < end + 4 + body_len: + self._recv() + body = self.buf[end + 4 : end + 4 + body_len] + self.buf = self.buf[end + 4 + body_len :] + status = int(head.split(" ", 2)[1]) + for line in head.split("\r\n"): + if line.lower().startswith("session:"): + self.session = line.split(":", 1)[1].strip().split(";")[0] + return status, head, body + + def read_frames(self, seconds): + """Collect interleaved RTP packets on channel 0 for `seconds`; return (frames, packets, seq_gaps, sizes).""" + deadline = time.monotonic() + seconds + frames = 0 + packets = 0 + gaps = 0 + last_seq = None + cur = 0 + sizes = [] + self.s.settimeout(1.0) + while time.monotonic() < deadline: + try: + chunk = self.s.recv(65536) + except TimeoutError: + continue + if not chunk: + break + self.buf += chunk + while True: + if len(self.buf) < 4 or self.buf[0:1] != b"$": + if self.buf and self.buf[0:1] != b"$": + end = self.buf.find(b"\r\n\r\n") + if end < 0: + break + self.buf = self.buf[end + 4 :] + continue + break + ch = self.buf[1] + ln = struct.unpack(">H", self.buf[2:4])[0] + if len(self.buf) < 4 + ln: + break + pkt = self.buf[4 : 4 + ln] + self.buf = self.buf[4 + ln :] + if ch != 0 or len(pkt) < 12: + continue + packets += 1 + seq = struct.unpack(">H", pkt[2:4])[0] + marker = pkt[1] & 0x80 + if last_seq is not None and seq != ((last_seq + 1) & 0xFFFF): + gaps += 1 + last_seq = seq + cur += len(pkt) - 12 + if marker: + frames += 1 + sizes.append(cur) + cur = 0 + return frames, packets, gaps, sizes + + def close(self): + self.s.close() + + +def setup_play(c): + c.send("OPTIONS") + st, head, _ = c.read_response() + assert st == 200, head + c.send("DESCRIBE", "Accept: application/sdp\r\n") + st, head, body = c.read_response() + assert st == 200, head + c.send("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n") + st, head, _ = c.read_response() + assert st == 200, head + c.send("PLAY") + st, head, _ = c.read_response() + assert st == 200, head + return body + + +for case in cases: + try: + if case == "play": + c = Client() + sdp = setup_play(c) + frames, packets, gaps, sizes = c.read_frames(10) + c.send("TEARDOWN") + c.read_response() + c.close() + ok = frames >= 50 and gaps == 0 + say( + case, + ok, + f"{frames} frames in 10 s ({frames / 10:.1f} fps), {packets} RTP packets, {gaps} seq gaps, frame size min {min(sizes) if sizes else 0} max {max(sizes) if sizes else 0}; sdp has m=video {'m=video' in sdp.decode()}", + ) + elif case == "udp": + c = Client() + c.send("OPTIONS") + c.read_response() + c.send("SETUP", "Transport: RTP/AVP;unicast;client_port=5000-5001\r\n") + st, head, _ = c.read_response() + c.close() + say(case, st == 461, f"UDP SETUP answered {st}") + elif case == "pipeline": + c = Client() + c.s.sendall( + f"OPTIONS rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 1\r\n\r\nDESCRIBE rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n".encode() + ) + c.cseq = 2 + st1, h1, _ = c.read_response() + st2, h2, b2 = c.read_response() + c.close() + cs = [x for x in (h1 + h2).split("\r\n") if x.startswith("CSeq")] + say( + case, + st1 == 200 and st2 == 200 and cs == ["CSeq: 1", "CSeq: 2"], + f"two pipelined requests answered {st1}, {st2} with {cs}", + ) + elif case == "badreq": + c = Client() + c.s.sendall(b"BOGUS rtsp://x/ RTSP/1.0\r\nCSeq: 7\r\n\r\n") + st, head, _ = c.read_response() + c.close() + say( + case, + st in (400, 405, 501) and "CSeq: 7" in head, + f"unknown method answered {st}, CSeq echoed {'CSeq: 7' in head}", + ) + elif case == "replay": + c = Client() + setup_play(c) + f1, _, _, _ = c.read_frames(3) + c.send("PLAY") + st, head, _ = c.read_response() + f2, _, gaps, _ = c.read_frames(3) + c.send("TEARDOWN") + c.read_response() + c.close() + say( + case, + st == 200 and f2 >= 10 and gaps == 0, + f"second PLAY answered {st}, {f1} then {f2} frames, {gaps} seq gaps after repeat PLAY", + ) + elif case == "getparam": + c = Client() + setup_play(c) + c.read_frames(1) + c.send("GET_PARAMETER") + st, head, _ = c.read_response() + f, _, _, _ = c.read_frames(2) + c.send("TEARDOWN") + c.read_response() + c.close() + say( + case, + st == 200 and f > 5, + f"GET_PARAMETER while playing answered {st}, stream continued with {f} frames in 2 s", + ) + elif case == "teardown": + c = Client() + setup_play(c) + c.read_frames(2) + c.send("TEARDOWN") + st, head, _ = c.read_response() + c.s.settimeout(3) + time.sleep(1.5) + try: + rest = c.s.recv(65536) + closed = rest == b"" + except TimeoutError: + rest = b"x" + closed = False + except ConnectionError: + closed = True + c.close() + say( + case, + st == 200, + f"TEARDOWN answered {st}; connection closed by server afterwards: {closed}", + ) + elif case == "third": + a = Client() + setup_play(a) + b = Client() + setup_play(b) + fa, _, ga, _ = a.read_frames(4) + fb, _, gb, _ = b.read_frames(4) + try: + d = Client(timeout=3) + d.send("OPTIONS") + st, _, _ = d.read_response() + third = f"third got {st}" + d.close() + except ConnectionError as e: + # refused, reset or closed by the server; a timeout or a bug propagates and fails the case + third = f"third rejected ({type(e).__name__})" + a.send("TEARDOWN") + a.read_response() + a.close() + b.send("TEARDOWN") + b.read_response() + b.close() + say( + case, + fa > 10 and fb > 10 and ga == 0 and gb == 0 and "rejected" in third, + f"two clients: {fa} and {fb} frames in 4 s each, gaps {ga}/{gb}; {third}", + ) + elif case == "stall": + a = Client() + setup_play(a) + b = Client() + setup_play(b) + # stop reading on a for 4 s while b keeps playing + a.s.settimeout(None) + fb1, _, gb1, _ = b.read_frames(4) + a.s.settimeout(1.0) + fa, _, ga, _ = a.read_frames(4) + fb2, _, gb2, _ = b.read_frames(3) + a.send("TEARDOWN") + a.read_response() + a.close() + b.send("TEARDOWN") + b.read_response() + b.close() + say( + case, + fb1 > 10 and fb2 > 10 and fa > 5, + f"b kept {fb1} then {fb2} frames while a stalled 4 s; a recovered {fa} frames after resuming (gaps {ga}); b gaps {gb1}/{gb2}; device log must show stalled/dropping frame", + ) + elif case == "timeout": + c = Client(timeout=70) + setup_play(c) + c.s.settimeout( + 2 + ) # a server that stops sending but never closes must not hang the run + t0 = time.monotonic() + dead = False + try: + while time.monotonic() - t0 < 68: + try: + d = c.s.recv(65536) + except TimeoutError: + continue + if not d: + dead = True + break + except ConnectionError: + dead = True + elapsed = time.monotonic() - t0 + say( + case, + dead and 50 <= elapsed <= 68, + f"connection closed by server after idle: {dead} at {elapsed:.0f} s (expect the 60 s session timeout when the client sends nothing)", + ) + except Exception as e: # noqa: BLE001 + say(case, False, f"exception {type(e).__name__}: {e}") + time.sleep(1) + +print( + "SUMMARY", + sum(1 for r in results if r[1]), + "pass", + sum(1 for r in results if not r[1]), + "fail", +) +sys.exit(1 if any(not r[1] for r in results) else 0) diff --git a/.agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp b/.agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp new file mode 100644 index 00000000000..3fa93631a0f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp @@ -0,0 +1,13 @@ +#pragma once +#include +#include +#include "esphome/core/log.h" + +// Direct look at the Zephyr cdc_acm_uart1 driver state, bypassing the usb_cdc_acm component +inline void cdc_probe() { + const device *d = DEVICE_DT_GET(DT_NODELABEL(cdc_acm_uart1)); + uint8_t tmp[32]; + int rr = uart_irq_rx_ready(d); + int n = uart_fifo_read(d, tmp, sizeof(tmp)); + ESP_LOGI("probe", "driver rx_ready=%d fifo_read=%d first=0x%02X", rr, n, n > 0 ? tmp[0] : 0); +} diff --git a/.agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py b/.agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py new file mode 100644 index 00000000000..dbd8e4f9753 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py @@ -0,0 +1,203 @@ +"""Drive the usb_cdc_acm echo firmware on the nRF52840 dongle from the Mac. + +usage: drive.py DATA_PORT [case ...] +Cases: short, bulk1k, bulk16k, bulk64k, burst, flush, peek, linecoding, dtr, stream, counters +""" + +import random +import sys +import time + +import serial + +port = sys.argv[1] +cases = sys.argv[2:] or [ + "short", + "bulk1k", + "bulk16k", + "bulk64k", + "burst", + "flush", + "peek", + "linecoding", + "dtr", + "stream", + "counters", +] +results = [] + + +def say(case, ok, detail): + results.append((case, ok, detail)) + print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True) + + +def info(case, detail): + """A case whose result lives in the device log; printed, not counted.""" + print(f"INFO {case}: {detail}", flush=True) + + +def open_port(baud=115200): + s = serial.Serial(port, baudrate=baud, timeout=1) + time.sleep(0.3) # let the 100 ms line-state poll see DTR and the echo loop settle + return s + + +def read_exact(s, n, timeout): + buf = bytearray() + deadline = time.monotonic() + timeout + while len(buf) < n and time.monotonic() < deadline: + chunk = s.read(min(4096, n - len(buf))) + if chunk: + buf += chunk + return bytes(buf) + + +def payload(n): + # No 0x01 anywhere: the device treats a chunk starting with 0x01 as a command + rnd = random.Random(n) + return bytes(rnd.choice(range(2, 256)) for _ in range(n)) + + +def cmd(s, c, extra=b""): + s.write(b"\x01" + c + extra) + s.flush() + + +def echo_case(name, n, chunk): + s = open_port() + s.reset_input_buffer() + data = payload(n) + t0 = time.monotonic() + for i in range(0, n, chunk): + s.write(data[i : i + chunk]) + got = read_exact(s, n, timeout=max(5, n / 2000)) + dt = time.monotonic() - t0 + s.close() + ok = got == data + first_bad = next((i for i in range(min(len(got), n)) if got[i] != data[i]), None) + say( + name, + ok, + f"{len(got)}/{n} bytes echoed in {dt:.2f}s ({n / dt / 1024:.1f} KiB/s), first mismatch {first_bad}", + ) + + +for case in cases: + if case == "short": + s = open_port() + s.reset_input_buffer() + ok = True + lat = [] + for i in range(20): + msg = f"hello {i}\n".encode() + t0 = time.monotonic() + s.write(msg) + got = read_exact(s, len(msg), 2) + lat.append(time.monotonic() - t0) + ok = ok and got == msg + s.close() + say( + case, + ok, + f"20 lines, latency min {min(lat) * 1000:.0f} ms max {max(lat) * 1000:.0f} ms", + ) + elif case == "bulk1k": + echo_case(case, 1024, 1024) + elif case == "bulk16k": + echo_case(case, 16 * 1024, 4096) + elif case == "bulk64k": + echo_case(case, 64 * 1024, 16384) + elif case == "burst": + s = open_port() + s.reset_input_buffer() + cmd(s, b"B") + got = read_exact(s, 1000, 3) + s.close() + expect = bytes((ord("A") + (i % 26)) for i in range(1000)) + prefix_ok = got == expect[: len(got)] + say( + case, + prefix_ok and 0 < len(got) < 1000, + f"{len(got)} of 1000 bytes arrived (256 byte TX ring, rest must be dropped and warned), prefix intact {prefix_ok}", + ) + elif case == "flush": + s = open_port() + s.reset_input_buffer() + cmd(s, b"F") + got = read_exact(s, 200, 3) + s.close() + expect = bytes((ord("a") + (i % 26)) for i in range(200)) + say(case, got == expect, f"{len(got)}/200 bytes, flush result in device log") + time.sleep(0.5) + elif case == "peek": + s = open_port() + s.reset_input_buffer() + cmd(s, b"K", b"Z") + time.sleep(0.5) + s.close() + info( + case, + "peek/read result is in the device log: expect `peek ok=1 0x5A read ok=1 0x5A`", + ) + elif case == "linecoding": + for baud in (9600, 115200, 921600): + s = open_port(baud) + s.reset_input_buffer() + cmd(s, b"L") + time.sleep(0.4) + s.close() + time.sleep(0.3) + info(case, "opened at 9600, 115200, 921600; the device log must show each baud") + elif case == "dtr": + for _i in range(5): + s = open_port() + time.sleep(0.3) + s.close() + time.sleep(0.3) + info( + case, "5 open/close cycles; the device log must show 5 Opened and 5 Closed" + ) + elif case == "stream": + # device streams 100 x 100 byte lines over 10 s; close the port for 3 s in the middle + s = open_port() + s.reset_input_buffer() + cmd(s, b"S") + part1 = bytearray() + t0 = time.monotonic() + while time.monotonic() - t0 < 2.5: + part1 += s.read(4096) + s.close() + time.sleep(3.0) + s = open_port() + part2 = bytearray() + t0 = time.monotonic() + while time.monotonic() - t0 < 6.0: + part2 += s.read(4096) + s.close() + lines1 = [x for x in bytes(part1).split(b"\n") if x.startswith(b"STREAM")] + lines2 = [x for x in bytes(part2).split(b"\n") if x.startswith(b"STREAM")] + n1 = [int(x[7:10]) for x in lines1] + n2 = [int(x[7:10]) for x in lines2] + resumed = bool(n2) and max(n2) >= 90 + say( + case, + resumed, + f"before close: lines {min(n1) if n1 else None}-{max(n1) if n1 else None} ({len(n1)}), after reopen: {min(n2) if n2 else None}-{max(n2) if n2 else None} ({len(n2)}); streaming resumed after reopen {resumed}", + ) + elif case == "counters": + s = open_port() + cmd(s, b"C") + time.sleep(0.3) + s.close() + info(case, "counters are in the device log") + time.sleep(0.5) + +print( + "SUMMARY", + sum(1 for r in results if r[1]), + "pass", + sum(1 for r in results if not r[1]), + "fail", +) +sys.exit(1 if any(not r[1] for r in results) else 0) diff --git a/.agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh b/.agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh new file mode 100755 index 00000000000..11476fcddf9 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh @@ -0,0 +1,39 @@ +#!/bin/zsh +# usage: decode.sh CAPTURE ELF OUTPREFIX +# Parses the #CD: lines of a serial capture with Zephyr's coredump tools from the sdk-nrf that ESPHome +# installed (SDK_NRF, default: the sdk-nrf directory under the ESPHome cache), serves the dump to GDB on a +# random port and prints the backtrace and the thread list. The ELF is .pioenvs//zephyr/zephyr/zephyr.elf. +# Every step is checked: a missing tool, a capture without a complete dump, an empty .bin or a gdbserver +# that died all stop the script with exit 1, and the exit status is GDB's otherwise. +CAP=$1; ELF=$2; OUT=$3 +SDK_NRF=${SDK_NRF:-$(ls -d ~/Library/Caches/esphome/sdk-nrf ~/.cache/esphome/sdk-nrf 2>/dev/null | head -1)} +[ -n "$SDK_NRF" ] || { echo "sdk-nrf not found, set SDK_NRF"; exit 1; } +F=$(ls -d $SDK_NRF/frameworks/v*/zephyr/scripts/coredump 2>/dev/null | tail -1) +PY=${PYTHON:-$(ls $SDK_NRF/penvs/*/bin/python 2>/dev/null | head -1)} +# SDK 0.x keeps the tools under arm-zephyr-eabi/, SDK 1.0+ under gnu/arm-zephyr-eabi/ +GDB=$(ls $SDK_NRF/toolchains/*/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb $SDK_NRF/toolchains/*/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb 2>/dev/null | tail -1) +for v in F PY GDB; do + [ -n "${(P)v}" ] || { echo "$v not found under $SDK_NRF"; exit 1; } +done +[ -f "$ELF" ] || { echo "ELF not found: $ELF"; exit 1; } +# strip capture timestamps and ANSI so the parser sees the raw log lines +sed -E 's/^\[[0-9:.]+\]//; s/\x1b\[[0-9;]*m//g' "$CAP" > $OUT-all.log +# keep only the first complete #CD:BEGIN# .. #CD:END# block +awk '/#CD:BEGIN#/{if(!done){p=1;buf=""}} p{buf=buf $0 "\n"} /#CD:END#/{if(p){printf "%s", buf; done=1; p=0}}' $OUT-all.log > $OUT-raw.log +echo "CD lines in capture: $(grep -c '#CD:' $OUT-all.log) (BEGIN $(grep -c '#CD:BEGIN#' $OUT-all.log), END $(grep -c '#CD:END#' $OUT-all.log)); first complete block: $(grep -c '#CD:' $OUT-raw.log) lines" +[ -s $OUT-raw.log ] || { echo "no complete #CD:BEGIN# .. #CD:END# block in the capture"; exit 1; } +if ! $PY $F/coredump_serial_log_parser.py $OUT-raw.log $OUT.bin > $OUT-parser.log 2>&1; then + echo "parser failed:"; tail -5 $OUT-parser.log; exit 1 +fi +tail -3 $OUT-parser.log +[ -s $OUT.bin ] || { echo "parser wrote an empty $OUT.bin"; exit 1; } +ls -la $OUT.bin | awk '{print "bin bytes", $5}' +PORT=$((20000 + RANDOM % 10000)) +$PY $F/coredump_gdbserver.py --port $PORT "$ELF" $OUT.bin > $OUT-gdbserver.log 2>&1 & +G=$! +sleep 3 +if ! kill -0 $G 2>/dev/null; then echo "gdbserver exited:"; tail -5 $OUT-gdbserver.log; exit 1; fi +$GDB -q -batch -ex "target remote :$PORT" -ex "bt" -ex "info threads" "$ELF" 2>&1 | grep -v "^warning\|Reading symbols\|^$" | head -40 +rc=$pipestatus[1] +kill $G 2>/dev/null +exit $rc diff --git a/.agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh b/.agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh new file mode 100755 index 00000000000..262951ba5ff --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh @@ -0,0 +1,29 @@ +#!/bin/zsh +# usage: flow.sh LABEL PORT ELF [OUTDIR] +# Run at least 60 s after the board rebooted from its crash, with the port closed. Proves the stored +# coredump waits for a reader and survives a reader that goes away: +# 1) open the port for 3 s and close it (a reader that leaves mid-print) +# 2) open it for 100 s: the whole dump must print again, then be erased +# 3) open it again for 20 s: dump_config must report no stored dump +# then decode.sh parses the first complete dump and prints the GDB backtrace. +# Needs a python with pyserial (PYTHON, default python3). +D=${0:A:h} +L=$1; P=$2; ELF=$3; S=${4:-.} +PY=${PYTHON:-python3} +CAP=$D/../serial_capture.py +capture() { # SECONDS OUTFILE: stop the flow when the capture fails (busy or missing port) + if ! $PY $CAP $P $1 $2 > $2.err 2>&1; then + echo "capture of $2 failed:"; cat $2.err; exit 1 + fi +} +capture 3 $S/$L-brief.txt +echo "$L brief 3 s open: $(wc -l < $S/$L-brief.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-brief.txt), END $(grep -a -c '#CD:END#' $S/$L-brief.txt)" +sleep 5 +capture 100 $S/$L-full.txt +echo "$L full 100 s open: $(wc -l < $S/$L-full.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-full.txt), BEGIN $(grep -a -c '#CD:BEGIN#' $S/$L-full.txt), END $(grep -a -c '#CD:END#' $S/$L-full.txt)" +grep -a "crashtest\|zephyr_coredump\|Reset reason\|Has stored\| Size:\| Error:\|took a long\|compiled on" $S/$L-full.txt | grep -a -v "#CD:[0-9A-F]" | head -16 +sleep 3 +capture 20 $S/$L-after.txt +echo "$L reopen 20 s: CD lines $(grep -a -c '#CD:' $S/$L-after.txt); $(grep -a 'Has stored\| Size:' $S/$L-after.txt | sed -E 's/^\[[^]]*\]//' | tr '\n' ' ')" +echo "== decode" +$D/decode.sh $S/$L-full.txt $ELF $S/$L diff --git a/.agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py b/.agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py new file mode 100644 index 00000000000..e6d73ee60cb --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py @@ -0,0 +1,24 @@ +import asyncio +import sys + +from aioesphomeapi import APIClient + + +async def main(): + c = APIClient(sys.argv[1], 6053, None) + await c.connect(login=True) + di = await c.device_info() + print( + "version", + di.esphome_version, + "compiled", + di.compilation_time, + "home_id", + hex(di.zwave_home_id), + "flags", + di.zwave_proxy_feature_flags, + ) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py b/.agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py new file mode 100644 index 00000000000..6b12146dd1e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py @@ -0,0 +1,45 @@ +"""Recompute the Z-Wave checksum (XOR of LENGTH..payload, initial 0xFF) over every frame the proxy logged.""" + +from functools import reduce +import pathlib +import re +import sys + + +def zw_checksum(frame: bytes) -> int: + return reduce(lambda a, b: a ^ b, frame[1:-1], 0xFF) + + +def complete(frame: bytes) -> bool: + """A data frame is SOF, LENGTH, then LENGTH bytes; the proxy logs at most 168 bytes, so longer ones are cut.""" + return len(frame) >= 3 and frame[0] == 0x01 and len(frame) == frame[1] + 2 + + +for path in sys.argv[1:]: + text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace") + sent = re.findall(r"Sending: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text) + recv = re.findall(r"Received frame: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text) + calc = re.findall( + r"CHECKSUM Received: 0x([0-9A-F]{2}) - Calculated: 0x([0-9A-F]{2})", text + ) + bad = len(re.findall(r"Bad checksum", text)) + acks_sent = len(re.findall(r"Sending ACK", text)) + naks_sent = len(re.findall(r"Sending NAK/CAN", text)) + acks_recv = len(re.findall(r"Received ACK", text)) + naks_recv = len(re.findall(r"Received NAK", text)) + reboots = len(re.findall(r"Boot count|Reset reason|rst:0x", text)) + long_ops = len(re.findall(r"zwave_proxy took a long time", text)) + rx_frames = [bytes.fromhex(f.replace(":", "")) for f in recv] + tx_frames = [bytes.fromhex(f.replace(":", "")) for f in sent] + rx_cut = sum(1 for b in rx_frames if not complete(b)) + tx_cut = sum(1 for b in tx_frames if len(b) > 1 and not complete(b)) + rx_ok = sum(1 for b in rx_frames if complete(b) and zw_checksum(b) == b[-1]) + tx_ok = sum(1 for b in tx_frames if complete(b) and zw_checksum(b) == b[-1]) + calc_ok = sum(1 for r, c in calc if r == c) + cmds = sorted({f.split(":")[3] for f in recv}) + print( + f"{path.rsplit('/', 1)[-1]}: tx frames {len(sent)} (checksum ok {tx_ok}, truncated in log {tx_cut}), " + f"rx frames {len(recv)} (recomputed ok {rx_ok}, truncated in log {rx_cut}), " + f"device calc==received {calc_ok}/{len(calc)}, bad checksum warnings {bad}, ACK sent {acks_sent}, NAK sent {naks_sent}, " + f"ACK received {acks_recv}, NAK received {naks_recv}, long ops {long_ops}, rx cmd ids {cmds}" + ) diff --git a/.agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml b/.agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml new file mode 100644 index 00000000000..b43b850c99a --- /dev/null +++ b/.agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml @@ -0,0 +1,44 @@ +substitutions: + run: "1" + +esphome: + name: bk72xx-crash-probe + includes: [../scripts/libretiny_crash/bkprobe.hpp] + # step 0 waits 30 s so a capture can be opened (and a reset pulsed) after the flash; later steps 5 s + on_boot: + priority: -100 + then: + - lambda: id(step) = bkprobe_boot(${run}); + - delay: !lambda 'return id(step) == 0 ? 30000 : 5000;' + - lambda: bkprobe_act(${run}, id(step)); + +bk72xx: + board: generic-bk7238-tuya + +logger: + hardware_uart: UART1 + level: DEBUG + +preferences: + flash_write_interval: 1s + +globals: + - id: step + type: int + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: bkprobe_alive(id(step)); + +# Wi-Fi matters: a crashed BK72xx with Wi-Fi up never reboots on its own without the crash handler fix +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + reboot_timeout: 0s + +ota: + - platform: esphome diff --git a/.agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml b/.agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml new file mode 100644 index 00000000000..0e8bacdc48b --- /dev/null +++ b/.agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml @@ -0,0 +1,38 @@ +substitutions: + run: "1" + +esphome: + name: lt21007e + includes: [../scripts/libretiny_prefs/ltprefs.hpp] + platformio_options: + build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"] + on_boot: + priority: -100 + then: + - lambda: id(act) = ltprefs_boot(id(step), ${run}); + # the first boot waits so the host can open the port and pulse a reset before the sequence starts + - delay: !lambda 'return (id(step) == 0 || id(step) == 8) ? 30000 : 3000;' + - lambda: ltprefs_exit(id(step), ${run}, id(act)); + +bk72xx: + board: generic-bk7238-tuya + +logger: + hardware_uart: UART1 + level: DEBUG + +preferences: + flash_write_interval: 1s + +globals: + - id: step + type: int + initial_value: "0" + - id: act + type: int + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: ltprefs_alive(id(step), id(act)); diff --git a/.agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml b/.agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml new file mode 100644 index 00000000000..c4e42ce4c2f --- /dev/null +++ b/.agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml @@ -0,0 +1,34 @@ +esphome: + name: i19430-dut3 + on_boot: + priority: 800 + then: + - lambda: |- + auto marker = global_preferences->make_preference(0x19430001, true); + uint32_t v = 0; + if (!marker.load(&v)) { + ESP_LOGW("test", "first boot: wiping preferences"); + global_preferences->reset(); + static_cast(global_preferences)->open(); + v = 1; + marker.save(&v); + global_preferences->sync(); + } + +bk72xx: + board: generic-bk7238 + +logger: + hardware_uart: UART1 + level: VERBOSE + +wifi: + ap: + ssid: i19430-ap + +captive_portal: + +interval: + - interval: 5s + then: + - lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000)); diff --git a/.agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml b/.agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml new file mode 100644 index 00000000000..7e163db752d --- /dev/null +++ b/.agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml @@ -0,0 +1,50 @@ +substitutions: + run: "3" + +esphome: + name: cd13122d + on_boot: + priority: -100 + then: + - lambda: |- + ESP_LOGI("crashtest", "boot, crashed_before=%d run=%s", id(crashed_${run}), "${run}"); + - delay: 20s + - if: + condition: + lambda: 'return id(crashed_${run}) == 0;' + then: + - lambda: |- + id(crashed_${run}) = 1; + ESP_LOGW("crashtest", "flag set, letting the loop save it"); + - delay: 3s + - lambda: |- + global_preferences->sync(); + delay(500); + ESP_LOGW("crashtest", "writing to 0xDEADBEEF now"); + delay(100); + *reinterpret_cast(0xDEADBEEF) = 1; + else: + - lambda: |- + ESP_LOGI("crashtest", "already crashed once, running normally"); + +nrf52: + board: nrf52840dongle + dfu: true + +logger: + hardware_uart: USB_CDC + level: DEBUG + +zephyr_coredump: + +globals: + - id: crashed_${run} + type: int + restore_value: true + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: |- + ESP_LOGI("crashtest", "alive"); diff --git a/.agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml b/.agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml new file mode 100644 index 00000000000..f7f588f5767 --- /dev/null +++ b/.agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml @@ -0,0 +1,31 @@ +esphome: + name: pref20810 + on_boot: + priority: -100 + then: + - lambda: |- + ESP_LOGI("test", "boot: restored counter_pr=%d", id(counter_pr)); + +nrf52: + board: nrf52840dongle + dfu: true + +logger: + hardware_uart: USB_CDC + level: VERBOSE + +preferences: + flash_write_interval: 5s + +globals: + - id: counter_pr + type: int + restore_value: true + initial_value: "0" + +interval: + - interval: 15s + then: + - lambda: |- + id(counter_pr) += 1; + ESP_LOGI("test", "counter_pr now %d", id(counter_pr)); diff --git a/.agents/skills/hardware-test/templates/online-image-dut.yaml b/.agents/skills/hardware-test/templates/online-image-dut.yaml new file mode 100644 index 00000000000..f95c6ab7de6 --- /dev/null +++ b/.agents/skills/hardware-test/templates/online-image-dut.yaml @@ -0,0 +1,84 @@ +esphome: + name: online-image-dut + friendly_name: online-image-dut + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + level: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + actions: + - action: fetch + variables: + url: string + then: + - online_image.set_url: + id: img + url: !lambda 'return url;' + update: false + - component.update: img + - action: refetch + then: + - component.update: img + - action: fetch_small + variables: + url: string + then: + - online_image.set_url: + id: img_small + url: !lambda 'return url;' + update: false + - component.update: img_small + - action: refetch_small + then: + - component.update: img_small + +http_request: + timeout: 4.5s + verify_ssl: false + +display: + +image: + - platform: online_image + id: img + url: http://127.0.0.1/none.bmp + format: AUTO + type: RGB565 + update_interval: never + on_download_finished: + logger.log: + format: "download finished: img cached=%d" + args: [cached] + on_error: + logger.log: "download error: img" + - platform: online_image + id: img_small + url: http://127.0.0.1/none.bmp + format: AUTO + type: RGB565 + buffer_size: 256 + update_interval: never + on_download_finished: + logger.log: + format: "download finished: img_small cached=%d" + args: [cached] + on_error: + logger.log: "download error: img_small" + +button: + - platform: restart + id: reboot_btn + name: Reboot diff --git a/.agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml b/.agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml new file mode 100644 index 00000000000..8bab3df9abe --- /dev/null +++ b/.agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml @@ -0,0 +1,105 @@ +esphome: + name: rtsp17990 + on_boot: + # GPIO8 low enables the camera's power supply on the Waveshare ESP32-S3-ETH; give the sensor + # time to come up before esp32_camera (setup_priority 200) probes it + priority: 700 + then: + - switch.turn_off: cam_power + - lambda: |- + delay(1200); + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHz + +logger: + level: VERBOSE + hardware_uart: USB_SERIAL_JTAG + logs: + rtsp_server: VERBOSE + esp32_camera: DEBUG + camera: DEBUG + wifi: DEBUG + api: DEBUG + api.connection: DEBUG + api.socket: DEBUG + socket: DEBUG + scheduler: DEBUG + component: DEBUG + app: DEBUG + mdns: DEBUG + ota: DEBUG + logger: DEBUG + psram: DEBUG + esp32: DEBUG + network: DEBUG + i2c: DEBUG + i2c.idf: DEBUG + preferences: DEBUG + debug: DEBUG + sensor: DEBUG + wifi_esp32: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +api: + reboot_timeout: 0s + +ota: + - platform: esphome + +debug: + update_interval: 10s + +sensor: + - platform: debug + free: + name: "Heap Free" + block: + name: "Heap Largest Block" + psram: + name: "PSRAM Free" + +# Waveshare ESP32-S3-ETH camera header (Waveshare wiki, WIFI_Web_CAM table) +i2c: + - id: cam_i2c + sda: GPIO48 + scl: GPIO47 + frequency: 400kHz + +switch: + - platform: gpio + id: cam_power + name: Camera Power + pin: GPIO8 + restore_mode: ALWAYS_OFF + setup_priority: 800 + +esp32_camera: + name: cam + setup_priority: 200 + data_pins: [GPIO41, GPIO45, GPIO46, GPIO42, GPIO40, GPIO38, GPIO15, GPIO18] + vsync_pin: GPIO1 + href_pin: GPIO2 + pixel_clock_pin: GPIO39 + external_clock: + pin: GPIO3 + frequency: 20MHz + i2c_id: cam_i2c + resolution: 640X480 + max_framerate: 10fps + +rtsp_server: + port: 554 + max_connections: 2 diff --git a/.agents/skills/hardware-test/templates/s31-cse7766-dut.yaml b/.agents/skills/hardware-test/templates/s31-cse7766-dut.yaml new file mode 100644 index 00000000000..bcbe505c71d --- /dev/null +++ b/.agents/skills/hardware-test/templates/s31-cse7766-dut.yaml @@ -0,0 +1,107 @@ +# Basic Config + +esphome: + name: s31-cse7766 + +esp8266: + board: esp01_1m + toolchain: arduino + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +logger: + baud_rate: 0 # (UART logging interferes with cse7766) + level: VERBOSE + logs: + cse7766: VERBOSE + api: DEBUG + api.connection: DEBUG + api.socket: DEBUG + socket: DEBUG + scheduler: DEBUG + component: DEBUG + app: DEBUG + wifi: DEBUG + mdns: DEBUG + ota: DEBUG + logger: DEBUG + sensor: DEBUG + switch: DEBUG + binary_sensor: DEBUG + status_led: DEBUG + uart: DEBUG + gpio: DEBUG + preferences: DEBUG + esp8266: DEBUG + wifi_esp8266: DEBUG +api: +ota: + platform: esphome + +# Device Specific Config + +uart: + rx_pin: RX + baud_rate: 4800 + parity: EVEN + +binary_sensor: + - platform: gpio + pin: + number: GPIO0 + mode: INPUT_PULLUP + inverted: True + name: "S31 Button" + on_press: + - switch.toggle: relay + - platform: status + name: "S31 Status" + +sensor: + - platform: wifi_signal + name: "S31 WiFi Signal" + update_interval: 60s + - platform: cse7766 + current: + accuracy_decimals: 3 + name: "S31 Current" + filters: + - throttle: 2s + voltage: + accuracy_decimals: 1 + name: "S31 Voltage" + filters: + - throttle: 2s + on_raw_value: + then: + - lambda: |- + id(frames) += 1; + power: + accuracy_decimals: 1 + name: "S31 Power" + filters: + - throttle: 2s +switch: + - platform: gpio + name: "S31 Relay" + pin: GPIO12 + id: relay + restore_mode: RESTORE_DEFAULT_ON + +status_led: + pin: GPIO13 + +globals: + - id: frames + type: uint32_t + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: |- + static uint32_t last = 0; + ESP_LOGI("frames", "cse7766 frames parsed: %u in 10 s (total %u)", (unsigned) (id(frames) - last), (unsigned) id(frames)); + last = id(frames); diff --git a/.agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml b/.agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml new file mode 100644 index 00000000000..0dc9fa4a96a --- /dev/null +++ b/.agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml @@ -0,0 +1,112 @@ +esphome: + name: cdc11634 + +nrf52: + board: nrf52840dongle + dfu: true + +logger: + hardware_uart: USB_CDC + level: VERBOSE + logs: + usb_cdc_acm: VERBOSE + scheduler: DEBUG + component: DEBUG + app: DEBUG + preferences: DEBUG + logger: DEBUG + nrf52: DEBUG + dfu: DEBUG + +usb_cdc_acm: + interfaces: + - disabled: true + - id: cdc1 + +globals: + - id: rx_total + type: uint32_t + initial_value: "0" + - id: tx_attempted + type: uint32_t + initial_value: "0" + - id: stream_left + type: int32_t + initial_value: "0" + +interval: + - interval: 20ms + then: + - lambda: |- + uint8_t buf[64]; + while (id(cdc1).available() > 0) { + // a command is exactly two bytes, so what the host sends after it stays in the driver for peek/read + uint8_t first = 0; + id(cdc1).peek_byte(&first); + size_t n = std::min(id(cdc1).available(), sizeof(buf)); + if (first == 0x01 && n >= 2) + n = 2; + if (!id(cdc1).read_array(buf, n)) { + ESP_LOGE("test", "read_array(%u) failed", (unsigned) n); + break; + } + id(rx_total) += n; + if (buf[0] == 0x01 && n >= 2) { + const char cmd = buf[1]; + if (cmd == 'B') { + // one oversized write: the 256 byte TX ring must drop the rest and warn once + static uint8_t big[1000]; + for (size_t i = 0; i < sizeof(big); i++) big[i] = 'A' + (i % 26); + id(cdc1).write_array(big, sizeof(big)); + id(tx_attempted) += sizeof(big); + ESP_LOGI("test", "burst 1000 written"); + } else if (cmd == 'S') { + id(stream_left) = 100; + ESP_LOGI("test", "stream started"); + } else if (cmd == 'L') { + ESP_LOGI("test", "line coding baud=%u data=%u stop=%u parity=%u", (unsigned) id(cdc1).get_baud_rate(), + id(cdc1).get_data_bits(), id(cdc1).get_stop_bits(), (unsigned) id(cdc1).get_parity()); + } else if (cmd == 'K') { + // the host sends "\x01KZ" in one write; the Z must still be in the driver here + uint8_t pb = 0, rb = 0; + bool okp = id(cdc1).peek_byte(&pb); + bool okr = id(cdc1).read_byte(&rb); + ESP_LOGI("test", "peek ok=%d 0x%02X read ok=%d 0x%02X (expect ok=1 0x5A for both)", okp, pb, okr, rb); + if (okr) id(rx_total) += 1; + } else if (cmd == 'F') { + static uint8_t pat[200]; + for (size_t i = 0; i < sizeof(pat); i++) pat[i] = 'a' + (i % 26); + id(cdc1).write_array(pat, sizeof(pat)); + id(tx_attempted) += sizeof(pat); + const uint32_t t0 = millis(); + auto res = id(cdc1).flush(); + ESP_LOGI("test", "flush result=%d took %u ms", (int) res, (unsigned) (millis() - t0)); + } else if (cmd == 'C') { + ESP_LOGI("test", "counters rx=%u tx_attempted=%u (write_array drops silently when the ring is full)", (unsigned) id(rx_total), (unsigned) id(tx_attempted)); + } else { + ESP_LOGW("test", "unknown cmd 0x%02X", cmd); + } + continue; + } + id(cdc1).write_array(buf, n); + id(tx_attempted) += n; + } + - interval: 100ms + then: + - lambda: |- + if (id(stream_left) > 0) { + char line[101]; + int len = snprintf(line, sizeof(line), "STREAM %03d ", 100 - id(stream_left)); + for (int i = len; i < 99; i++) line[i] = 'x'; + line[99] = '\n'; + id(cdc1).write_array(reinterpret_cast(line), 100); + id(tx_attempted) += 100; + id(stream_left) -= 1; + if (id(stream_left) == 0) { + ESP_LOGI("test", "stream done"); + } + } + - interval: 10s + then: + - lambda: |- + ESP_LOGI("test", "alive rx=%u tx_attempted=%u", (unsigned) id(rx_total), (unsigned) id(tx_attempted)); diff --git a/.agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml b/.agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml new file mode 100644 index 00000000000..b7c8fc2a16e --- /dev/null +++ b/.agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml @@ -0,0 +1,83 @@ +esphome: + name: camdiag + on_boot: + priority: -100 + then: + - output.set_level: + id: xclk + level: 50% + - delay: 200ms + - lambda: |- + auto *bus = id(cam_i2c); + auto scan = [&](const char *what) { + int found = 0; + for (uint8_t a = 0x08; a < 0x78; a++) { + if (bus->write(a, nullptr, 0) == i2c::ERROR_OK) { + ESP_LOGI("camdiag", " ACK at 0x%02X", a); + found++; + } + } + ESP_LOGI("camdiag", "scan %s: %d devices", what, found); + }; + auto rd16 = [&](uint16_t reg) { + uint8_t r[2] = {(uint8_t) (reg >> 8), (uint8_t) reg}; + uint8_t v = 0xEE; + auto err = bus->write_readv(0x3C, r, 2, &v, 1); + ESP_LOGI("camdiag", " 0x3C [0x%04X] err=%d -> 0x%02X", reg, (int) err, v); + return v; + }; + auto rd8 = [&](uint8_t addr, uint8_t reg) { + uint8_t v = 0xEE; + auto err = bus->write_readv(addr, ®, 1, &v, 1); + ESP_LOGI("camdiag", " 0x%02X [0x%02X] (8 bit) err=%d -> 0x%02X", addr, reg, (int) err, v); + }; + id(g8).turn_off(); delay(1500); + id(xclk).set_level(0.0f); delay(300); + scan("GPIO8 LOW, XCLK OFF"); + id(xclk).set_level(0.5f); delay(300); + scan("GPIO8 LOW, XCLK ON"); + ESP_LOGI("camdiag", "OV5640 style 16 bit reads (expect 300A=0x56 300B=0x40, 302A=0xB0..B2):"); + rd16(0x300A); rd16(0x300B); rd16(0x302A); rd16(0x3008); rd16(0x3100); rd16(0x3103); rd16(0x4300); + ESP_LOGI("camdiag", "8 bit reads at 0x3C (GC2145 expects F0=0x21 F1=0x45):"); + rd8(0x3C, 0xF0); rd8(0x3C, 0xF1); rd8(0x3C, 0x0A); rd8(0x3C, 0x0B); + ESP_LOGI("camdiag", "write 0x3103=0x11 then read back, then soft reset 0x3008=0x82 and re-read IDs:"); + uint8_t w[3] = {0x31, 0x03, 0x11}; + auto werr = bus->write(0x3C, w, 3); + ESP_LOGI("camdiag", " write err=%d", (int) werr); + rd16(0x3103); + uint8_t rst[3] = {0x30, 0x08, 0x82}; + bus->write(0x3C, rst, 3); delay(100); + rd16(0x300A); rd16(0x300B); + id(g8).turn_on(); delay(500); scan("GPIO8 HIGH (camera off)"); + id(g8).turn_off(); delay(1500); scan("GPIO8 LOW again"); + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHz + +logger: + level: DEBUG + hardware_uart: USB_SERIAL_JTAG + +i2c: + - id: cam_i2c + sda: GPIO48 + scl: GPIO47 + frequency: 100kHz + scan: true + +output: + - platform: gpio + id: g8 + pin: GPIO8 + - platform: ledc + pin: GPIO3 + id: xclk + frequency: 20000000Hz From b53b3968b41538bd11c899a5d1d1693042414a42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:10:02 -1000 Subject: [PATCH 629/651] [light] Build light.toggle with register_apply_action (#20833) --- esphome/components/light/automation.h | 23 ------------------- esphome/components/light/automation.py | 18 +++------------ esphome/components/light/types.py | 1 - .../fixtures/light_toggle_action.yaml | 4 ++-- tests/integration/test_light_toggle_action.py | 6 +---- 5 files changed, 6 insertions(+), 46 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 2e963479f76..fb4a156f7be 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,29 +8,6 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction final : public Action { - public: - explicit ToggleAction(LightState *state) : state_(state) {} - - template void set_transition_length(V value) requires(HasTransitionLength) { - this->transition_length_ = value; - } - - void play(const Ts &...x) override { - auto call = this->state_->toggle(); - if constexpr (HasTransitionLength) { - call.set_transition_length(this->transition_length_.optional_value(x...)); - } - call.perform(); - } - - protected: - LightState *state_; - struct NoTransition {}; - [[no_unique_address]] std::conditional_t, NoTransition> - transition_length_{}; -}; - template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index a616d26571d..df2c4c16445 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -46,7 +46,6 @@ from .types import ( DimRelativeAction, LightEffectCycleAction, LightState, - ToggleAction, ) _LOGGER = logging.getLogger(__name__) @@ -76,9 +75,8 @@ def validate_light_state(value: Any) -> Any: ) from err -@automation.register_action( +automation.register_apply_action( "light.toggle", - ToggleAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(LightState), @@ -87,19 +85,9 @@ def validate_light_state(value: Any) -> Any: ), } ), - synchronous=True, + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + call="toggle", ) -async def light_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - has_transition_length = CONF_TRANSITION_LENGTH in config - toggle_template_arg = cg.TemplateArguments(has_transition_length, *template_arg) - var = cg.new_Pvariable(action_id, toggle_template_arg, paren) - if has_transition_length: - template_ = await cg.templatable( - config[CONF_TRANSITION_LENGTH], args, cg.uint32 - ) - cg.add(var.set_transition_length(template_)) - return var class LightStateField(NamedTuple): diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 23d1d7e00ea..a6cc2c7efed 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -38,7 +38,6 @@ LimitMode = light_ns.enum("LimitMode", is_class=True) LIMIT_MODES = LimitMode.options("CLAMP", "DO_NOTHING") # Actions -ToggleAction = light_ns.class_("ToggleAction", automation.Action) LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) diff --git a/tests/integration/fixtures/light_toggle_action.yaml b/tests/integration/fixtures/light_toggle_action.yaml index 265d8ba1acb..6044f524234 100644 --- a/tests/integration/fixtures/light_toggle_action.yaml +++ b/tests/integration/fixtures/light_toggle_action.yaml @@ -20,14 +20,14 @@ light: default_transition_length: 0s button: - # Test 1: light.toggle without transition_length (HasTransitionLength=false) + # Test 1: light.toggle without transition_length - platform: template id: btn_toggle name: "Toggle" on_press: - light.toggle: test_light - # Test 2: light.toggle with transition_length (HasTransitionLength=true) + # Test 2: light.toggle with transition_length - platform: template id: btn_toggle_with_trans name: "Toggle With Trans" diff --git a/tests/integration/test_light_toggle_action.py b/tests/integration/test_light_toggle_action.py index ffbadabb5bd..e4dbc98075c 100644 --- a/tests/integration/test_light_toggle_action.py +++ b/tests/integration/test_light_toggle_action.py @@ -1,8 +1,4 @@ -"""Integration test for light::ToggleAction. - -Tests both ToggleAction and -ToggleAction instantiations. -""" +"""Integration test for the light.toggle action, with and without transition_length.""" from __future__ import annotations From 257a636099b88317e4df203e89818f7f9f9444a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:17:24 -1000 Subject: [PATCH 630/651] [rx8025t][rx8130][rx8111][ufm01] Use the shared BCD helpers (#20861) --- esphome/components/rx8025t/rx8025t.cpp | 25 ++---------------------- esphome/components/rx8111/rx8111.cpp | 27 +++----------------------- esphome/components/rx8130/rx8130.cpp | 22 ++------------------- esphome/components/ufm01/ufm01.cpp | 2 +- 4 files changed, 8 insertions(+), 68 deletions(-) diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index f33d39b8d0c..33df13a970e 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -13,9 +13,6 @@ static constexpr uint8_t RX8025T_FLAG_VLF = 0x02; ESPHOME_LOG_TAG(TAG, "rx8025t"); -constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } -constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } - void RX8025TComponent::setup() { uint8_t flags; if (!this->read_flags_(&flags)) { @@ -62,19 +59,7 @@ void RX8025TComponent::read_time() { return; } - ESPTime rtc_time{ - .second = bcd2dec(date[0] & 0x7f), - .minute = bcd2dec(date[1] & 0x7f), - .hour = bcd2dec(date[2] & 0x3f), - .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), - .day_of_month = bcd2dec(date[4] & 0x3f), - .day_of_year = 1, - .month = bcd2dec(date[5] & 0x1f), - .year = static_cast(bcd2dec(date[6]) + 2000), - .is_dst = false, - .timestamp = 0, - }; - rtc_time.recalc_timestamp_utc(false); + ESPTime rtc_time = time::decode_bcd_calendar(date); if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; @@ -92,13 +77,7 @@ void RX8025TComponent::write_time() { } uint8_t buff[7]; - buff[0] = dec2bcd(now.second); - buff[1] = dec2bcd(now.minute); - buff[2] = dec2bcd(now.hour); - buff[3] = 1 << (now.day_of_week - 1); - buff[4] = dec2bcd(now.day_of_month); - buff[5] = dec2bcd(now.month); - buff[6] = dec2bcd(now.year % 100); + time::encode_bcd_calendar(now, buff); if (!this->write_bytes(RX8025T_REG_SEC, buff, sizeof(buff))) { ESP_LOGE(TAG, "Can't write I2C data."); this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp index 531c4233e47..1806b0c8fd0 100644 --- a/esphome/components/rx8111/rx8111.cpp +++ b/esphome/components/rx8111/rx8111.cpp @@ -15,9 +15,6 @@ static constexpr uint8_t RX8111_CTRL_STOP = 0x01; // FLAG bits are write-zero-to-clear; keep UF/TF/AF/EVF even if they latch during the write. static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C; -static constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } -static constexpr uint8_t uint8_to_bcd(uint8_t val) { return ((val / 10) << 4) | (val % 10); } - void RX8111Component::setup() { this->read_time(); } void RX8111Component::update() { this->read_time(); } @@ -63,25 +60,13 @@ void RX8111Component::read_time() { this->status_set_warning(LOG_STR("Invalid RTC time")); return; } - const uint8_t weekday = static_cast(__builtin_ctz(onehot) + 1); - ESPTime rtc_time{ - .second = bcd_to_uint8(buf[0]), - .minute = bcd_to_uint8(buf[1]), - .hour = bcd_to_uint8(buf[2]), - .day_of_week = weekday, - .day_of_month = bcd_to_uint8(buf[4]), - .month = bcd_to_uint8(buf[5]), - .year = static_cast(bcd_to_uint8(buf[6]) + 2000), - .is_dst = false, - .timestamp = 0, - }; - rtc_time.recalc_timestamp_utc(false); + ESPTime rtc_time = time::decode_bcd_calendar(buf); if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) { ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock"); this->status_set_warning(LOG_STR("Invalid RTC time")); return; } - if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == weekday, + if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week, LOG_STR("Invalid RTC time"))) { ESP_LOGW(TAG, "RTC weekday does not match calendar date"); return; @@ -103,13 +88,7 @@ void RX8111Component::write_time() { } uint8_t buf[7]; - buf[0] = uint8_to_bcd(now.second); - buf[1] = uint8_to_bcd(now.minute); - buf[2] = uint8_to_bcd(now.hour); - buf[3] = 1 << (now.day_of_week - 1); - buf[4] = uint8_to_bcd(now.day_of_month); - buf[5] = uint8_to_bcd(now.month); - buf[6] = uint8_to_bcd(static_cast(now.year - 2000)); + time::encode_bcd_calendar(now, buf); const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf)); // Always try to restart the counter, even after a failed calendar write. const bool restarted = this->set_stop_bit_(false); diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 63153c5db3f..a5b1243c263 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -23,9 +23,6 @@ static const uint8_t RX8130_CLEAR_FLAGS = 0x00; ESPHOME_LOG_TAG(TAG, "rx8130"); -constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } -constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } - void RX8130Component::setup() { // Set digital offset to disabled with no offset if (this->write_register(RX8130_REG_DIG_OFFSET, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { @@ -70,16 +67,7 @@ void RX8130Component::read_time() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - ESPTime rtc_time{ - .second = bcd2dec(date[0] & 0x7f), - .minute = bcd2dec(date[1] & 0x7f), - .hour = bcd2dec(date[2] & 0x3f), - .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), - .day_of_month = bcd2dec(date[4] & 0x3f), - .month = bcd2dec(date[5] & 0x1f), - .year = static_cast(bcd2dec(date[6]) + 2000), - }; - rtc_time.recalc_timestamp_utc(false); + ESPTime rtc_time = time::decode_bcd_calendar(date); if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; @@ -96,13 +84,7 @@ void RX8130Component::write_time() { return; } uint8_t buff[7]; - buff[0] = dec2bcd(now.second); - buff[1] = dec2bcd(now.minute); - buff[2] = dec2bcd(now.hour); - buff[3] = 1 << (now.day_of_week - 1); - buff[4] = dec2bcd(now.day_of_month); - buff[5] = dec2bcd(now.month); - buff[6] = dec2bcd(now.year % 100); + time::encode_bcd_calendar(now, buff); this->stop_(true); if (this->write_register(RX8130_REG_SEC, buff, 7) != i2c::ERROR_OK) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 15d645ce1a9..2d07fa892ac 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -81,7 +81,7 @@ static_assert(SOFTWARE_VERSION_RESPONSE_SIZE <= PASSIVE_FRAME_MAX_SIZE, "softwar 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 float to_float(uint8_t data) { return bcd_to_uint8(data); } static bool bcd_to_digits(const uint8_t *data, size_t len, char *out) { bool all_zero = true; From 8d6b40f735a0068d5e084244a18dac3d757f7e9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:19:53 -1000 Subject: [PATCH 631/651] [components] Use Component::status_comm_result for communication failures (#20864) --- esphome/components/apds9930/apds9930.cpp | 6 +-- esphome/components/bh1745/bh1745.cpp | 5 +- esphome/components/bq27220/bq27220.cpp | 6 +-- esphome/components/bq76972/bq76972.cpp | 6 +-- .../es9038q2m_katana/es9038q2m_katana.cpp | 9 +--- esphome/components/gdk101/gdk101.cpp | 6 +-- esphome/components/hdc2080/hdc2080.cpp | 7 +-- esphome/components/max17048/max17048.cpp | 6 +-- esphome/components/pca9505/pca9505.cpp | 24 +++------ .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 18 +++---- esphome/components/rx8025t/rx8025t.cpp | 6 +-- esphome/components/rx8111/rx8111.cpp | 5 +- esphome/components/shtcx/shtcx.cpp | 3 +- .../spd2010/spd2010_touchscreen.cpp | 7 +-- esphome/components/tas58xx/tas58xx.cpp | 8 ++- esphome/components/tca9555/tca9555.cpp | 29 +++-------- .../components/tof10120/tof10120_sensor.cpp | 6 +-- .../waveshare_io_ch32v003.cpp | 50 +++++-------------- 18 files changed, 53 insertions(+), 154 deletions(-) diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index eec741d5938..3df24a47537 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -104,11 +104,9 @@ void APDS9930Component::dump_config() { 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")); + if (!this->status_comm_result(this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw)), + 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) { diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index c34c2493310..bc89a17351e 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -134,11 +134,8 @@ void BH1745Component::update() { return; uint16_t buffer[BH1745_CHANNELS]; - if (!this->read_bytes_16_le(REG_RED_DATA_LSB, buffer, BH1745_CHANNELS)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->status_comm_result(this->read_bytes_16_le(REG_RED_DATA_LSB, buffer, BH1745_CHANNELS))) return; - } - this->status_clear_warning(); const uint16_t red = buffer[0]; const uint16_t green = buffer[1]; diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp index 3c78893437d..a7caca62233 100644 --- a/esphome/components/bq27220/bq27220.cpp +++ b/esphome/components/bq27220/bq27220.cpp @@ -57,11 +57,7 @@ void BQ27220Component::update() { [](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")); - } + this->status_comm_result(ok, LOG_STR("Failed to read one or more registers from BQ27220")); } void BQ27220Component::dump_config() { diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index 59d18878370..c27b56dc50d 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -292,11 +292,7 @@ void BQ76972Component::update() { ok = false; } } - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - } + this->status_comm_result(ok); } #else void BQ76972Component::update() {} diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp index e3cd3820d7e..3eb1ad27253 100644 --- a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -119,14 +119,7 @@ bool ES9038Q2MKatana::write_volume_() { } // Records the outcome of a runtime write; the cached state only changes on success. -bool ES9038Q2MKatana::check_write_(bool ok) { - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - } - return ok; -} +bool ES9038Q2MKatana::check_write_(bool ok) { return this->status_comm_result(ok); } bool ES9038Q2MKatana::set_volume(float volume) { if (this->is_failed()) diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index a84d5ba06fb..222ae5b349d 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -30,11 +30,7 @@ void GDK101Component::update() { return; } - if (!this->read_measurement_duration_(data)) { - this->status_set_warning(LOG_STR("Failed to read measurement duration")); - return; - } - this->status_clear_warning(); + this->status_comm_result(this->read_measurement_duration_(data), LOG_STR("Failed to read measurement duration")); } void GDK101Component::setup() { diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index 8672dc497f5..3386fe4d540 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -44,17 +44,14 @@ void HDC2080Component::update() { data = MEAS_TRIG | MEAS_CONF_HUM; } if (this->write_register(REG_MEASUREMENT_CONFIGURATION, &data, 1) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // wait for conversion to complete 2ms should be enough, more is fine this->set_timeout(5, [this]() { uint8_t raw_data[4]; - if (this->read_register(REG_TEMPERATURE_LOW, raw_data, 4) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + if (!this->status_comm_result(this->read_register(REG_TEMPERATURE_LOW, raw_data, 4) == i2c::ERROR_OK)) return; - } - this->status_clear_warning(); if (this->temperature_sensor_ != nullptr) { float temp = encode_value_le(raw_data) * (165.0f / 65536.0f) - 40.5f; this->temperature_sensor_->publish_state(temp); diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp index 1caf8ab308b..6160506484d 100644 --- a/esphome/components/max17048/max17048.cpp +++ b/esphome/components/max17048/max17048.cpp @@ -55,11 +55,7 @@ void MAX17048Component::update() { ok = false; } } - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR("Register read failed")); - } + this->status_comm_result(ok, LOG_STR("Register read failed")); } void MAX17048Component::dump_config() { diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index 6ac50d3b1bc..15c26a078f1 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -109,24 +109,16 @@ bool PCA9505Component::read_bank_(uint8_t bank) { if (this->is_failed()) return false; - if (this->read_register(INPUT_REG + bank, &this->input_[bank], 1) != i2c::ERROR_OK) { - this->status_set_warning(LOG_STR("Failed to read input register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->read_register(INPUT_REG + bank, &this->input_[bank], 1) == i2c::ERROR_OK, + LOG_STR("Failed to read input register")); } bool PCA9505Component::write_bank_(uint8_t reg, uint8_t bank, uint8_t value) { if (this->is_failed()) return false; - if (this->write_register(reg + bank, &value, 1) != i2c::ERROR_OK) { - this->status_set_warning(LOG_STR("Failed to write register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_register(reg + bank, &value, 1) == i2c::ERROR_OK, + LOG_STR("Failed to write register")); } bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { @@ -137,12 +129,8 @@ bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { for (uint8_t &bank : values) { bank = value; } - if (this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) != i2c::ERROR_OK) { - this->status_set_warning(LOG_STR("Failed to write register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) == i2c::ERROR_OK, + LOG_STR("Failed to write register")); } float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 28f98325ea4..e6a423de7cf 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -90,13 +90,11 @@ bool PI4IOE5V6408Component::read_gpio_outputs_() { if (this->is_failed()) return false; - uint8_t data; - if (!this->read_byte(PI4IOE5V6408_REGISTER_OUT_SET, &data)) { - this->status_set_warning(LOG_STR("Failed to read output register")); + uint8_t data{0}; + if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_OUT_SET, &data), + LOG_STR("Failed to read output register"))) return false; - } this->output_mask_ = data; - this->status_clear_warning(); return true; } @@ -104,7 +102,7 @@ bool PI4IOE5V6408Component::read_gpio_modes_() { if (this->is_failed()) return false; - uint8_t data; + uint8_t data{0}; if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data), LOG_STR("Failed to read GPIO modes"))) return false; @@ -119,13 +117,11 @@ bool PI4IOE5V6408Component::digital_read_hw(uint8_t pin) { if (this->is_failed()) return false; - uint8_t data; - if (!this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &data)) { - this->status_set_warning(LOG_STR("Failed to read GPIO state")); + uint8_t data{0}; + if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &data), + LOG_STR("Failed to read GPIO state"))) return false; - } this->input_mask_ = data; - this->status_clear_warning(); return true; } diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index 33df13a970e..4d7e9b4e694 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -92,11 +92,7 @@ void RX8025TComponent::write_time() { return; } flags &= ~(RX8025T_FLAG_VLF | RX8025T_FLAG_VDET); - if (!this->write_byte(RX8025T_REG_FLAG, flags)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; - } - this->status_clear_warning(); + this->status_comm_result(this->write_byte(RX8025T_REG_FLAG, flags)); } } // namespace esphome::rx8025t diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp index 1806b0c8fd0..62923482c7e 100644 --- a/esphome/components/rx8111/rx8111.cpp +++ b/esphome/components/rx8111/rx8111.cpp @@ -96,11 +96,8 @@ void RX8111Component::write_time() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->status_comm_result(this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS))) return; - } - this->status_clear_warning(); ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, now.minute, now.second); } diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index 814e09162ec..7ce343a3a9e 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -81,8 +81,7 @@ void SHTCXComponent::update() { float humidity = NAN; uint16_t raw_data[2]; if (!this->read_data(raw_data, 2)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } else { temperature = 175.0f * float(raw_data[0]) / 65536.0f - 45.0f; humidity = 100.0f * float(raw_data[1]) / 65536.0f; diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp index b70fe1acad9..db0fb1f510b 100644 --- a/esphome/components/spd2010/spd2010_touchscreen.cpp +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -139,12 +139,7 @@ bool SPD2010Touchscreen::read_data_() { return false; } if ((status_high & STATUS_RUNNING) && length == 0) { - if (!this->clear_interrupt_()) { - this->status_set_warning(LOG_STR("Failed to clear interrupt")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->clear_interrupt_(), LOG_STR("Failed to clear interrupt")); } if (!(status_low & (STATUS_POINT | STATUS_GESTURE))) { const bool needs_clear = (status_high & STATUS_RUNNING) && (status_low & STATUS_AUX); diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp index 3addbfda00d..28d0ce22bdb 100644 --- a/esphome/components/tas58xx/tas58xx.cpp +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -222,12 +222,10 @@ void TAS58xx::on_audio_started() { } void TAS58xx::update() { - uint8_t power_state; - if (!this->read_faults_() || !this->read_byte(TAS58XX_POWER_STATE, &power_state)) { - this->status_set_warning(LOG_STR("Failed to read status")); + uint8_t power_state{0}; + if (!this->status_comm_result(this->read_faults_() && this->read_byte(TAS58XX_POWER_STATE, &power_state), + LOG_STR("Failed to read status"))) return; - } - this->status_clear_warning(); power_state &= TAS58XX_CTRL_STATE_MASK; if (power_state != this->power_state_) { diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 15272e614fb..448836e6609 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -65,12 +65,10 @@ bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) return false; uint16_t value; - if (!this->read_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, &value)) { - this->status_set_warning(LOG_STR("Failed to read output register")); + if (!this->status_comm_result(this->read_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, &value), + LOG_STR("Failed to read output register"))) return false; - } this->output_mask_ = value; - this->status_clear_warning(); return true; } @@ -78,13 +76,10 @@ bool TCA9555Component::read_gpio_modes_() { if (this->is_failed()) return false; uint16_t value; - if (!this->read_byte_16_le(TCA9555_CONFIGURATION_PORT_0, &value)) { - this->status_set_warning(LOG_STR("Failed to read mode register")); + if (!this->status_comm_result(this->read_byte_16_le(TCA9555_CONFIGURATION_PORT_0, &value), + LOG_STR("Failed to read mode register"))) return false; - } this->mode_mask_ = value; - - this->status_clear_warning(); return true; } bool TCA9555Component::digital_read_hw(uint8_t pin) { @@ -119,23 +114,15 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { this->output_mask_ &= ~(1 << pin); } - if (!this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_)) { - this->status_set_warning(LOG_STR("Failed to write output register")); - return; - } - - this->status_clear_warning(); + this->status_comm_result(this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_), + LOG_STR("Failed to write output register")); } bool TCA9555Component::write_gpio_modes_() { if (this->is_failed()) return false; - if (!this->write_byte_16_le(TCA9555_CONFIGURATION_PORT_0, this->mode_mask_)) { - this->status_set_warning(LOG_STR("Failed to write mode register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte_16_le(TCA9555_CONFIGURATION_PORT_0, this->mode_mask_), + LOG_STR("Failed to write mode register")); } bool TCA9555Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } diff --git a/esphome/components/tof10120/tof10120_sensor.cpp b/esphome/components/tof10120/tof10120_sensor.cpp index 3943b37150c..76a061cadf7 100644 --- a/esphome/components/tof10120/tof10120_sensor.cpp +++ b/esphome/components/tof10120/tof10120_sensor.cpp @@ -26,8 +26,7 @@ void TOF10120Sensor::setup() {} void TOF10120Sensor::update() { if (!this->write_bytes(TOF10120_DISTANCE_REGISTER, TOF10120_READ_DISTANCE_CMD, sizeof(TOF10120_READ_DISTANCE_CMD))) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -38,8 +37,7 @@ void TOF10120Sensor::update() { } delay(TOF10120_DEFAULT_DELAY); if (this->read(data, 2) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 5e16004dc2c..9ff7eb7f382 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -52,11 +52,9 @@ uint16_t WaveshareIOCH32V003Component::get_adc_value() { return 0; uint16_t adc_value; - if (!this->read_byte_16_le(IO_EXTENSION_ADC_ADDR, &adc_value)) { - this->status_set_warning(LOG_STR("Failed to read ADC register")); + if (!this->status_comm_result(this->read_byte_16_le(IO_EXTENSION_ADC_ADDR, &adc_value), + LOG_STR("Failed to read ADC register"))) return 0; - } - this->status_clear_warning(); return adc_value; } @@ -65,11 +63,9 @@ uint8_t WaveshareIOCH32V003Component::get_rtc_interrupt_status() { return 0; uint8_t data = 0; - if (!this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1)) { - this->status_set_warning(LOG_STR("Failed to read RTC interrupt register")); + if (!this->status_comm_result(this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1), + LOG_STR("Failed to read RTC interrupt register"))) return 0; - } - this->status_clear_warning(); return data; } @@ -81,34 +77,21 @@ void WaveshareIOCH32V003Component::set_pwm_value(uint8_t value) { // based on circuit schematic requirements. This follows the pattern from the // original Waveshare IO library function "void IO_EXTENSION_Pwm_Output(uint8_t Value)". - if (!this->write_byte(IO_EXTENSION_PWM_ADDR, value)) { - this->status_set_warning(LOG_STR("Failed to set PWM duty cycle")); - return; - } - - this->status_clear_warning(); + this->status_comm_result(this->write_byte(IO_EXTENSION_PWM_ADDR, value), LOG_STR("Failed to set PWM duty cycle")); } bool WaveshareIOCH32V003Component::write_gpio_modes_() { if (this->is_failed()) return false; - if (!this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_)) { - this->status_set_warning(LOG_STR("Failed to write mode register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_), + LOG_STR("Failed to write mode register")); } bool WaveshareIOCH32V003Component::write_gpio_outputs_() { if (this->is_failed()) return false; - if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_)) { - this->status_set_warning(LOG_STR("Failed to write output register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_), + LOG_STR("Failed to write output register")); } bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { @@ -116,13 +99,10 @@ bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { return false; uint8_t data = 0; - if (!this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1)) { - this->status_set_warning(LOG_STR("Failed to read input register")); + if (!this->status_comm_result(this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1), + LOG_STR("Failed to read input register"))) return false; - } this->input_mask_ = data; - - this->status_clear_warning(); return true; } @@ -137,12 +117,8 @@ void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { } uint8_t data = this->output_mask_; - if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data)) { - this->status_set_warning(LOG_STR("Failed to write output register")); - return; - } - - this->status_clear_warning(); + this->status_comm_result(this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data), + LOG_STR("Failed to write output register")); } bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } From 52390ebbe41b9964ed0c30c43f152feff93ea978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:22:23 -1000 Subject: [PATCH 632/651] [ethernet] Build the ethernet automations with the apply helpers (#20845) --- esphome/components/ethernet/__init__.py | 34 +++++++++++++----------- esphome/components/ethernet/automation.h | 30 --------------------- 2 files changed, 18 insertions(+), 46 deletions(-) delete mode 100644 esphome/components/ethernet/automation.h diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 1a50dbebfc3..a4b18942026 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -1,7 +1,6 @@ from dataclasses import dataclass from esphome import automation, pins -from esphome.automation import Condition import esphome.codegen as cg from esphome.components import spi from esphome.components.network import ( @@ -238,10 +237,6 @@ MANUAL_IP_SCHEMA = cv.Schema( EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") -EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition) -EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition) -EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action) -EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action) def _is_framework_spi_polling_mode_supported() -> bool: @@ -932,15 +927,22 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files -automation.register_bare_condition( - "ethernet.connected", EthernetConnectedCondition, cv.Schema({}) -) -automation.register_bare_condition( - "ethernet.enabled", EthernetEnabledCondition, cv.Schema({}) -) -automation.register_bare_action( - "ethernet.enable", EthernetEnableAction, cv.Schema({}), synchronous=True -) -automation.register_bare_action( - "ethernet.disable", EthernetDisableAction, cv.Schema({}), synchronous=True +ETHERNET_AUTOMATION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(EthernetComponent), + } ) + +for _name, _check in ( + ("ethernet.connected", "is_connected()"), + ("ethernet.enabled", "is_enabled()"), +): + automation.register_apply_condition(_name, ETHERNET_AUTOMATION_SCHEMA, _check) + +for _name, _call in ( + ("ethernet.enable", "enable()"), + ("ethernet.disable", "disable()"), +): + automation.register_apply_action( + _name, ETHERNET_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h deleted file mode 100644 index f975f52bffd..00000000000 --- a/esphome/components/ethernet/automation.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" -#ifdef USE_ETHERNET -#include "ethernet_component.h" - -namespace esphome::ethernet { - -template class EthernetConnectedCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_eth_component->is_connected(); } -}; - -template class EthernetEnabledCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } -}; - -template class EthernetEnableAction final : public Action { - public: - void play(const Ts &...x) override { global_eth_component->enable(); } -}; - -template class EthernetDisableAction final : public Action { - public: - void play(const Ts &...x) override { global_eth_component->disable(); } -}; - -} // namespace esphome::ethernet -#endif From ab656acbf1c6989a9e97761560d5ad35ef26ff6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:23:19 -1000 Subject: [PATCH 633/651] [api] Build api.connected with register_apply_condition (#20836) --- esphome/components/api/__init__.py | 20 ++++++-------------- esphome/components/api/api_server.h | 14 +++----------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 44d8b443676..dcca7ed1494 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -4,7 +4,6 @@ import re from typing import Any from esphome import automation -from esphome.automation import Condition import esphome.codegen as cg from esphome.components.const import CONF_DESCRIPTION, CONF_HOST from esphome.components.logger import request_log_listener @@ -99,7 +98,6 @@ ActionResponse = api_ns.class_("ActionResponse") HomeAssistantActionResponseTrigger = api_ns.class_( "HomeAssistantActionResponseTrigger", automation.Trigger ) -APIConnectedCondition = api_ns.class_("APIConnectedCondition", Condition) APIRespondAction = api_ns.class_("APIRespondAction", automation.Action) APIUnregisterServiceCallAction = api_ns.class_( "APIUnregisterServiceCallAction", automation.Action @@ -1057,19 +1055,13 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema( ) -@automation.register_condition( - "api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA +automation.register_apply_condition( + "api.connected", + API_CONNECTED_CONDITION_SCHEMA, + automation.ApplyCall( + "is_connected({})", ((CONF_STATE_SUBSCRIPTION_ONLY, cg.bool_),) + ), ) -async def api_connected_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_) - cg.add(var.set_state_subscription_only(templ)) - return var # user_services.cpp is only needed when user defined actions exist; the diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 09b08de4e3f..52310747fcf 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -213,6 +213,9 @@ class APIServer final : public Component bool is_connected() const { return this->api_connection_count_ != 0; } bool is_connected_with_state_subscription() const; + bool is_connected(bool state_subscription_only) const { + return state_subscription_only ? this->is_connected_with_state_subscription() : this->is_connected(); + } // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the @@ -407,16 +410,5 @@ class APIServer final : public Component extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class APIConnectedCondition final : public Condition { - TEMPLATABLE_VALUE(bool, state_subscription_only) - public: - bool check(const Ts &...x) override { - if (this->state_subscription_only_.value(x...)) { - return global_api_server->is_connected_with_state_subscription(); - } - return global_api_server->is_connected(); - } -}; - } // namespace esphome::api #endif From ded5ad6cb133fea0718d950feb9f748f1cdb059a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:23:31 -1000 Subject: [PATCH 634/651] [wifi] Build the wifi actions and conditions with the apply helpers (#20842) --- esphome/components/wifi/__init__.py | 65 ++++++++-------------------- esphome/components/wifi/automation.h | 30 ------------- 2 files changed, 18 insertions(+), 77 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5660b1189b8..886b7ef9990 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -3,7 +3,6 @@ import math from typing import Any from esphome import automation, preferences -from esphome.automation import Condition import esphome.codegen as cg from esphome.components.const import CONF_ENABLED, CONF_USE_PSRAM from esphome.components.esp32 import ( @@ -241,12 +240,6 @@ def _validate_dns_servers(value: list) -> list: return value -WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) -WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) -WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) -WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action) -WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action) -WiFiRoamAction = wifi_ns.class_("WiFiRoamAction", automation.Action) WiFiConfigureAction = wifi_ns.class_( "WiFiConfigureAction", automation.Action, cg.Component ) @@ -1042,49 +1035,27 @@ async def to_code(config): CORE.add_job(final_step) -automation.register_bare_condition( - "wifi.connected", - WiFiConnectedCondition, - cv.Schema({}), +WIFI_AUTOMATION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(WiFiComponent), + } ) +for _name, _check in ( + ("wifi.connected", "is_connected()"), + ("wifi.enabled", "is_disabled() == false"), + ("wifi.ap_active", "is_ap_active()"), +): + automation.register_apply_condition(_name, WIFI_AUTOMATION_SCHEMA, _check) -automation.register_bare_condition( - "wifi.enabled", - WiFiEnabledCondition, - cv.Schema({}), -) - - -automation.register_bare_condition( - "wifi.ap_active", - WiFiAPActiveCondition, - cv.Schema({}), -) - - -automation.register_bare_action( - "wifi.enable", - WiFiEnableAction, - cv.Schema({}), - synchronous=True, -) - - -automation.register_bare_action( - "wifi.disable", - WiFiDisableAction, - cv.Schema({}), - synchronous=True, -) - - -automation.register_bare_action( - "wifi.roam", - WiFiRoamAction, - cv.Schema({}), - synchronous=True, -) +for _name, _call in ( + ("wifi.enable", "enable()"), + ("wifi.disable", "disable()"), + ("wifi.roam", "force_roam_check()"), +): + automation.register_apply_action( + _name, WIFI_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index eb3d7471dc1..052fb11661d 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -6,36 +6,6 @@ namespace esphome::wifi { -template class WiFiConnectedCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } -}; - -template class WiFiEnabledCondition final : public Condition { - public: - bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } -}; - -template class WiFiAPActiveCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } -}; - -template class WiFiEnableAction final : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->enable(); } -}; - -template class WiFiDisableAction final : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->disable(); } -}; - -template class WiFiRoamAction final : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->force_roam_check(); } -}; - template class WiFiConfigureAction final : public Action, public Component { public: TEMPLATABLE_VALUE(std::string, ssid) From 28e43235fab54be9bc3c9ed801a360291cd1eb3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 16:27:01 -1000 Subject: [PATCH 635/651] [gpio_expander][pcf8574][mpr121][ch422g][ch423][pcm5122][weikai] Move the GPIO expanders and pin providers onto the shared pin helpers (#20868) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/ch422g/__init__.py | 15 +++---- esphome/components/ch422g/ch422g.cpp | 14 +----- esphome/components/ch422g/ch422g.h | 25 ++--------- esphome/components/ch423/__init__.py | 15 +++---- esphome/components/ch423/ch423.cpp | 14 +----- esphome/components/ch423/ch423.h | 25 ++--------- esphome/components/max6956/__init__.py | 34 ++------------ esphome/components/max6956/max6956.cpp | 13 +----- esphome/components/max6956/max6956.h | 25 ++--------- esphome/components/mcp23016/__init__.py | 31 ++----------- esphome/components/mcp23016/mcp23016.cpp | 10 +---- esphome/components/mcp23016/mcp23016.h | 25 ++--------- esphome/components/mcp23xxx_base/__init__.py | 28 ++---------- esphome/components/mpr121/__init__.py | 26 +++-------- esphome/components/mpr121/mpr121.cpp | 31 +++---------- esphome/components/mpr121/mpr121.h | 33 ++++---------- esphome/components/pca6416a/__init__.py | 42 +++-------------- esphome/components/pca6416a/pca6416a.cpp | 10 +---- esphome/components/pca6416a/pca6416a.h | 26 ++--------- esphome/components/pca9505/__init__.py | 28 ++---------- esphome/components/pca9505/pca9505.cpp | 14 +----- esphome/components/pca9505/pca9505.h | 23 ++-------- esphome/components/pca9554/__init__.py | 23 +--------- esphome/components/pca9554/pca9554.cpp | 10 +---- esphome/components/pca9554/pca9554.h | 29 ++---------- esphome/components/pcf8574/__init__.py | 28 ++++-------- esphome/components/pcf8574/pcf8574.cpp | 15 +------ esphome/components/pcf8574/pcf8574.h | 30 ++----------- esphome/components/pcm5122/audio_dac.py | 20 +-------- esphome/components/pi4ioe5v6408/__init__.py | 21 +-------- .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 12 +---- .../components/pi4ioe5v6408/pi4ioe5v6408.h | 23 ++-------- esphome/components/sx1509/__init__.py | 41 ++++------------- esphome/components/sx1509/sx1509.cpp | 2 + esphome/components/sx1509/sx1509.h | 6 ++- esphome/components/sx1509/sx1509_gpio_pin.cpp | 18 -------- esphome/components/sx1509/sx1509_gpio_pin.h | 31 ------------- esphome/components/tca9555/__init__.py | 28 ++---------- esphome/components/tca9555/tca9555.cpp | 10 +---- esphome/components/tca9555/tca9555.h | 24 ++-------- .../waveshare_io_ch32v003/__init__.py | 30 ++----------- .../waveshare_io_ch32v003.cpp | 14 +----- .../waveshare_io_ch32v003.h | 26 ++--------- esphome/components/weikai/__init__.py | 6 +-- esphome/components/wk2168_i2c/__init__.py | 11 +---- esphome/components/wk2168_spi/__init__.py | 11 +---- esphome/components/wk2212_i2c/__init__.py | 11 +---- esphome/components/wk2212_spi/__init__.py | 11 +---- esphome/components/xl9535/__init__.py | 45 ++++--------------- esphome/components/xl9535/xl9535.cpp | 12 +---- esphome/components/xl9535/xl9535.h | 26 ++--------- tests/components/pcf8574/test_pcf8574.cpp | 6 +-- tests/unit_tests/components/test_pcf8574.py | 39 +++++++++++++++- 53 files changed, 220 insertions(+), 906 deletions(-) delete mode 100644 esphome/components/sx1509/sx1509_gpio_pin.cpp delete mode 100644 esphome/components/sx1509/sx1509_gpio_pin.h diff --git a/esphome/components/ch422g/__init__.py b/esphome/components/ch422g/__init__.py index 60801e0c8dd..107a7bb871d 100644 --- a/esphome/components/ch422g/__init__.py +++ b/esphome/components/ch422g/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_I2C_ID, CONF_ID, CONF_INPUT, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OPEN_DRAIN, @@ -18,6 +17,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesterret", "@clydebarrow"] DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["gpio_expander"] DOMAIN = "ch422g" MULTI_CONF = True ch422g_ns = cg.esphome_ns.namespace("ch422g") @@ -66,13 +66,10 @@ CH422G_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_CH422G, CH422G_PIN_SCHEMA, pin_mode_check) async def ch422g_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) + var = await pins.new_parented_pin(config, CONF_CH422G) + # The parent collects every pin's mode before its setup() writes the mode register parent = await cg.get_variable(config[CONF_CH422G]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + cg.add( + parent.pin_mode(config[CONF_NUMBER], pins.gpio_flags_expr(config[CONF_MODE])) + ) return var diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index e76801ff77a..b6496ffa2c7 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -120,16 +120,6 @@ bool CH422GComponent::write_outputs_() { float CH422GComponent::get_setup_priority() const { return setup_priority::IO; } -void CH422GGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool CH422GGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } - -void CH422GGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); } -size_t CH422GGPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "EXIO%u via CH422G", this->pin_); -} -void CH422GGPIOPin::set_flags(gpio::Flags flags) { - flags_ = flags; - this->parent_->pin_mode(this->pin_, flags); -} - } // namespace esphome::ch422g + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::ch422g::CH422GComponent, "EXIO%u via CH422G") diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index a8729225e80..53b0d035e1a 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" @@ -42,26 +43,8 @@ class CH422GComponent final : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH422G pin as a GPIO pin. -class CH422GGPIOPin final : public GPIOPin { - public: - void setup() override{}; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(CH422GComponent *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags); - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - CH422GComponent *parent_{}; - uint8_t pin_{}; - bool inverted_{}; - gpio::Flags flags_{}; -}; +using CH422GGPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::ch422g + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::ch422g::CH422GComponent); diff --git a/esphome/components/ch423/__init__.py b/esphome/components/ch423/__init__.py index 42dc8bb2841..2e51d18d075 100644 --- a/esphome/components/ch423/__init__.py +++ b/esphome/components/ch423/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_I2C_ID, CONF_ID, CONF_INPUT, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OPEN_DRAIN, @@ -19,6 +18,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@dwmw2"] DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["gpio_expander"] DOMAIN = "ch423" MULTI_CONF = True ch423_ns = cg.esphome_ns.namespace("ch423") @@ -93,13 +93,10 @@ CH423_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_CH423, CH423_PIN_SCHEMA, pin_mode_check) async def ch423_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) + var = await pins.new_parented_pin(config, CONF_CH423) + # The parent collects every pin's mode before its setup() writes the mode register parent = await cg.get_variable(config[CONF_CH423]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + cg.add( + parent.pin_mode(config[CONF_NUMBER], pins.gpio_flags_expr(config[CONF_MODE])) + ) return var diff --git a/esphome/components/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp index 3a57f646d4e..2ad859f9b37 100644 --- a/esphome/components/ch423/ch423.cpp +++ b/esphome/components/ch423/ch423.cpp @@ -123,16 +123,6 @@ bool CH423Component::write_outputs_() { float CH423Component::get_setup_priority() const { return setup_priority::IO; } -void CH423GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool CH423GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } - -void CH423GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); } -size_t CH423GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "EXIO%u via CH423", this->pin_); -} -void CH423GPIOPin::set_flags(gpio::Flags flags) { - flags_ = flags; - this->parent_->pin_mode(this->pin_, flags); -} - } // namespace esphome::ch423 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::ch423::CH423Component, "EXIO%u via CH423") diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h index fbfffb521d2..38fcb1ed495 100644 --- a/esphome/components/ch423/ch423.h +++ b/esphome/components/ch423/ch423.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" @@ -41,26 +42,8 @@ class CH423Component final : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH423 pin as a GPIO pin. -class CH423GPIOPin final : public GPIOPin { - public: - void setup() override{}; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(CH423Component *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags); - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - CH423Component *parent_{}; - uint8_t pin_{}; - bool inverted_{}; - gpio::Flags flags_{}; -}; +using CH423GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::ch423 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::ch423::CH423Component); diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 6dd9dc0b09f..c2e5f71d4f5 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -2,21 +2,14 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, - CONF_PULLUP, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_OUTPUT, CONF_PULLUP from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@looping40"] DOMAIN = "max6956" +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -56,23 +49,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") - return value - - CONF_MAX6956 = "max6956" MAX6956_PIN_SCHEMA = pins.gpio_base_schema( MAX6956GPIOPin, cv.int_range(min=4, max=31), modes=[CONF_INPUT, CONF_PULLUP, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output_pullup, ).extend( { cv.Required(CONF_MAX6956): pins.use_id_or_address(MAX6956), @@ -82,16 +65,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MAX6956, MAX6956_PIN_SCHEMA) async def max6956_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MAX6956]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_MAX6956) automation.register_apply_action( diff --git a/esphome/components/max6956/max6956.cpp b/esphome/components/max6956/max6956.cpp index 7060a8c76b7..05814dcc46d 100644 --- a/esphome/components/max6956/max6956.cpp +++ b/esphome/components/max6956/max6956.cpp @@ -155,15 +155,6 @@ void MAX6956::dump_config() { } } -/************************************** - * MAX6956GPIOPin * - **************************************/ -void MAX6956GPIOPin::setup() { pin_mode(flags_); } -void MAX6956GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool MAX6956GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void MAX6956GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t MAX6956GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via Max6956", this->pin_); -} - } // namespace esphome::max6956 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::max6956::MAX6956, "%u via Max6956") diff --git a/esphome/components/max6956/max6956.h b/esphome/components/max6956/max6956.h index 4dbee165284..956a2bdbcfc 100644 --- a/esphome/components/max6956/max6956.h +++ b/esphome/components/max6956/max6956.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::max6956 { @@ -69,26 +70,8 @@ class MAX6956 final : public Component, public i2c::I2CDevice { int8_t prev_bright_[28] = {0}; }; -class MAX6956GPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(MAX6956 *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - MAX6956 *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using MAX6956GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::max6956 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::max6956::MAX6956); diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 67b302d1c51..d33b1e43ae4 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -2,15 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import gpio_expander, i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -41,20 +33,12 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - CONF_MCP23016 = "mcp23016" MCP23016_PIN_SCHEMA = pins.gpio_base_schema( MCP23016GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -65,13 +49,4 @@ MCP23016_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23016, MCP23016_PIN_SCHEMA) async def mcp23016_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MCP23016]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_MCP23016) diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 13d930fd470..d5a16f9964c 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -92,12 +92,6 @@ void MCP23016::update_reg_(uint8_t pin, bool pin_value, uint8_t reg_addr) { } } -void MCP23016GPIOPin::setup() { pin_mode(flags_); } -void MCP23016GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool MCP23016GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void MCP23016GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t MCP23016GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via MCP23016", this->pin_); -} - } // namespace esphome::mcp23016 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::mcp23016::MCP23016, "%u via MCP23016") diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 14c0c9a2fc4..a55d4dc6187 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::mcp23016 { @@ -56,26 +57,8 @@ class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expa InternalGPIOPin *interrupt_pin_{nullptr}; }; -class MCP23016GPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(MCP23016 *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - MCP23016 *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using MCP23016GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::mcp23016 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::mcp23016::MCP23016); diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index c7aae9d10ff..5f018412b66 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -7,9 +7,6 @@ from esphome.const import ( CONF_INPUT, CONF_INTERRUPT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_OPEN_DRAIN_INTERRUPT, CONF_OUTPUT, CONF_PULLUP, @@ -51,23 +48,13 @@ async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: return var -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") - return value - - CONF_MCP23XXX = "mcp23xxx" MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( MCP23XXXGPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT, CONF_PULLUP], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output_pullup, invertible=True, ).extend( { @@ -82,16 +69,9 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23XXX, MCP23XXX_PIN_SCHEMA) async def mcp23xxx_pin_to_code(config: ConfigType) -> MockObj: parent_id: ID = config[CONF_MCP23XXX] - parent = await cg.get_variable(parent_id) - + # Wait for the hub so its to_code has recorded the pin count + await cg.get_variable(parent_id) num_pins = cg.TemplateArguments(CORE.data[CONF_MCP23XXX][parent_id.id]) - - var = cg.new_Pvariable(config[CONF_ID], num_pins) - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + var = await pins.new_parented_pin(config, CONF_MCP23XXX, num_pins) cg.add(var.set_interrupt_mode(config[CONF_INTERRUPT])) return var diff --git a/esphome/components/mpr121/__init__.py b/esphome/components/mpr121/__init__.py index 1764ff9c054..3d75451ef82 100644 --- a/esphome/components/mpr121/__init__.py +++ b/esphome/components/mpr121/__init__.py @@ -7,8 +7,6 @@ from esphome.const import ( CONF_CHANNEL, CONF_ID, CONF_INPUT, - CONF_INVERTED, - CONF_MODE, CONF_NUMBER, CONF_OUTPUT, ) @@ -24,12 +22,15 @@ CONF_MAX_TOUCH_CHANNEL = "max_touch_channel" CONF_MPR121 = "mpr121" CONF_MPR121_ID = "mpr121_id" +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] DOMAIN = "mpr121" mpr121_ns = cg.esphome_ns.namespace("mpr121") MPR121Component = mpr121_ns.class_("MPR121Component", cg.Component, i2c.I2CDevice) -MPR121GPIOPin = mpr121_ns.class_("MPR121GPIOPin", cg.GPIOPin) +MPR121GPIOPin = mpr121_ns.class_( + "MPR121GPIOPin", cg.GPIOPin, cg.Parented.template(MPR121Component) +) MULTI_CONF = True CONFIG_SCHEMA = ( @@ -85,12 +86,6 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) -def validate_mode(value: ConfigType) -> ConfigType: - if bool(value[CONF_INPUT]) == bool(value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - return value - - # https://www.nxp.com/docs/en/data-sheet/MPR121.pdf, page 4 # # Among the 12 electrode inputs, 8 inputs are designed as multifunctional pins. When these pins are @@ -100,7 +95,7 @@ MPR121_GPIO_PIN_SCHEMA = pins.gpio_base_schema( MPR121GPIOPin, cv.int_range(min=4, max=11), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_MPR121): cv.use_id(MPR121Component), @@ -121,13 +116,4 @@ def mpr121_pin_final_validate( CONF_MPR121, MPR121_GPIO_PIN_SCHEMA, mpr121_pin_final_validate ) async def mpr121_gpio_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MPR121]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_MPR121) diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 781028e7f5c..2dd8614a323 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -80,9 +80,10 @@ void MPR121Component::loop() { this->read_byte(MPR121_GPIODATA, &this->gpio_input_); } -bool MPR121Component::digital_read(uint8_t ionum) { return (this->gpio_input_ & (1 << ionum)) != 0; } +bool MPR121Component::digital_read(uint8_t pin) { return (this->gpio_input_ & (1 << (pin - 4))) != 0; } -void MPR121Component::digital_write(uint8_t ionum, bool value) { +void MPR121Component::digital_write(uint8_t pin, bool value) { + const uint8_t ionum = pin - 4; if (value) { this->gpio_output_ |= (1 << ionum); } else { @@ -91,7 +92,8 @@ void MPR121Component::digital_write(uint8_t ionum, bool value) { this->flush_gpio_(); } -void MPR121Component::pin_mode(uint8_t ionum, gpio::Flags flags) { +void MPR121Component::pin_mode(uint8_t pin, gpio::Flags flags) { + const uint8_t ionum = pin - 4; this->gpio_enable_ |= (1 << ionum); if (flags & gpio::FLAG_INPUT) { this->gpio_direction_ &= ~(1 << ionum); @@ -115,25 +117,6 @@ bool MPR121Component::flush_gpio_() { return this->status_comm_result(this->write_byte(MPR121_GPIODATA, this->gpio_output_)); } -void MPR121GPIOPin::setup() { this->pin_mode(this->flags_); } - -void MPR121GPIOPin::pin_mode(gpio::Flags flags) { - assert(this->pin_ >= 4); - this->parent_->pin_mode(this->pin_ - 4, flags); -} - -bool MPR121GPIOPin::digital_read() { - assert(this->pin_ >= 4); - return this->parent_->digital_read(this->pin_ - 4) != this->inverted_; -} - -void MPR121GPIOPin::digital_write(bool value) { - assert(this->pin_ >= 4); - this->parent_->digital_write(this->pin_ - 4, value != this->inverted_); -} - -size_t MPR121GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "ELE%u on MPR121", this->pin_); -} - } // namespace esphome::mpr121 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::mpr121::MPR121Component, "ELE%u on MPR121") diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 4d945ded872..f998f015831 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -4,6 +4,7 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include @@ -73,10 +74,10 @@ class MPR121Component final : public Component, public i2c::I2CDevice { void set_max_touch_channel(uint8_t max_touch_channel) { this->max_touch_channel_ = max_touch_channel; } - // GPIO helper functions. - bool digital_read(uint8_t ionum); - void digital_write(uint8_t ionum, bool value); - void pin_mode(uint8_t ionum, gpio::Flags flags); + // GPIO helper functions; `pin` is the electrode number, 4 to 11, of the shared GPIO pins. + bool digital_read(uint8_t pin); + void digital_write(uint8_t pin, bool value); + void pin_mode(uint8_t pin, gpio::Flags flags); protected: std::vector channels_{}; @@ -98,26 +99,8 @@ class MPR121Component final : public Component, public i2c::I2CDevice { }; /// Helper class to expose a MPR121 pin as an internal input GPIO pin. -class MPR121GPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(MPR121Component *parent) { this->parent_ = parent; } - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - MPR121Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using MPR121GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::mpr121 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::mpr121::MPR121Component); diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 144088d48f1..92fb8f8eeed 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -6,9 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_OUTPUT, CONF_PULLUP, ) @@ -45,43 +42,18 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") - return value - - -PCA6416A_PIN_SCHEMA = cv.All( +PCA6416A_PIN_SCHEMA = pins.gpio_base_schema( + PCA6416AGPIOPin, + cv.int_range(min=0, max=15), + modes=[CONF_INPUT, CONF_PULLUP, CONF_OUTPUT], + mode_validator=pins.gpio_validate_input_xor_output_pullup, +).extend( { - cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin), cv.Required(CONF_PCA6416A): pins.use_id_or_address(PCA6416AComponent), - cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), - cv.Optional(CONF_MODE, default={}): cv.All( - { - cv.Optional(CONF_INPUT, default=False): cv.boolean, - cv.Optional(CONF_PULLUP, default=False): cv.boolean, - cv.Optional(CONF_OUTPUT, default=False): cv.boolean, - }, - validate_mode, - ), - cv.Optional(CONF_INVERTED, default=False): cv.boolean, } ) @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA6416A, PCA6416A_PIN_SCHEMA) async def pca6416a_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_PCA6416A]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCA6416A) diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 5f5b4ea4a10..1b643c6241d 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -187,12 +187,6 @@ void PCA6416AComponent::update_register_(uint8_t pin, bool pin_value, uint8_t re float PCA6416AComponent::get_setup_priority() const { return setup_priority::IO; } -void PCA6416AGPIOPin::setup() { pin_mode(flags_); } -void PCA6416AGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PCA6416AGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PCA6416AGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t PCA6416AGPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCA6416A", this->pin_); -} - } // namespace esphome::pca6416a + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca6416a::PCA6416AComponent, "%u via PCA6416A") diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 39011d53ab7..61b65501cfa 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pca6416a { @@ -48,27 +49,8 @@ class PCA6416AComponent final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a PCA6416A pin as an internal input GPIO pin. -class PCA6416AGPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(PCA6416AComponent *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - PCA6416AComponent *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using PCA6416AGPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pca6416a + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pca6416a::PCA6416AComponent); diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index 91521820b30..f1c34b1eaad 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -2,15 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import gpio_expander, i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -45,19 +37,11 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - PCA9505_PIN_SCHEMA = pins.gpio_base_schema( PCA9505GPIOPin, cv.int_range(min=0, max=39), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PCA9505): cv.use_id(PCA9505Component), @@ -67,10 +51,4 @@ PCA9505_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA9505, PCA9505_PIN_SCHEMA) async def pca9505_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_PCA9505]) - - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCA9505) diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index 15c26a078f1..567b480b1b4 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -135,16 +135,6 @@ bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } -void PCA9505GPIOPin::setup() { this->pin_mode(this->flags_); } - -void PCA9505GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } - -bool PCA9505GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } - -void PCA9505GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } - -size_t PCA9505GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCA9505", this->pin_); -} - } // namespace esphome::pca9505 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca9505::PCA9505Component, "%u via PCA9505") diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h index 29c4f18361c..a8ce6f1a324 100644 --- a/esphome/components/pca9505/pca9505.h +++ b/esphome/components/pca9505/pca9505.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" namespace esphome::pca9505 { @@ -49,24 +50,8 @@ class PCA9505Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -class PCA9505GPIOPin final : public GPIOPin, public Parented { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - uint8_t pin_{0}; - bool inverted_{false}; - gpio::Flags flags_{}; -}; +using PCA9505GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pca9505 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pca9505::PCA9505Component); diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 6e9478b1fd4..4e30c47bdae 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -6,8 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, CONF_NUMBER, CONF_OUTPUT, ) @@ -51,19 +49,11 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - PCA9554_PIN_SCHEMA = pins.gpio_base_schema( PCA9554GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PCA9554): pins.use_id_or_address(PCA9554Component), @@ -83,13 +73,4 @@ def pca9554_pin_final_validate( CONF_PCA9554, PCA9554_PIN_SCHEMA, pca9554_pin_final_validate ) async def pca9554_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_PCA9554]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCA9554) diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index 4f44143c547..add9c8d0109 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -135,12 +135,6 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { float PCA9554Component::get_setup_priority() const { return setup_priority::IO; } -void PCA9554GPIOPin::setup() { pin_mode(flags_); } -void PCA9554GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PCA9554GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PCA9554GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t PCA9554GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCA9554", this->pin_); -} - } // namespace esphome::pca9554 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca9554::PCA9554Component, "%u via PCA9554") diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index cc95f147ac5..b8d34e328eb 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pca9554 { @@ -52,30 +53,8 @@ class PCA9554Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a PCA9554 pin as an internal input GPIO pin. -class PCA9554GPIOPin final : public GPIOPin { - public: - // User provided, not "= default": `new(p) PCA9554GPIOPin()` would zero-fill .bss that is already zero. - PCA9554GPIOPin() {} - - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(PCA9554Component *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - PCA9554Component *parent_{nullptr}; - uint8_t pin_{0}; - bool inverted_{false}; - gpio::Flags flags_{}; -}; +using PCA9554GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pca9554 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pca9554::PCA9554Component); diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index b491dae5047..21e9e7b007e 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OUTPUT, @@ -23,7 +22,9 @@ MULTI_CONF = True pcf8574_ns = cg.esphome_ns.namespace("pcf8574") PCF8574Component = pcf8574_ns.class_("PCF8574Component", cg.Component, i2c.I2CDevice) -PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin) +PCF8574GPIOPin = pcf8574_ns.class_( + "PCF8574GPIOPin", cg.GPIOPin, cg.Parented.template(PCF8574Component) +) CONF_PCF8574 = "pcf8574" CONF_PCF8575 = "pcf8575" @@ -51,19 +52,11 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - PCF8574_PIN_SCHEMA = pins.gpio_base_schema( PCF8574GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -74,13 +67,8 @@ PCF8574_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCF8574, PCF8574_PIN_SCHEMA) async def pcf8574_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_PCF8574]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + var = await pins.new_parented_pin(config, CONF_PCF8574) + if config[CONF_MODE][CONF_INPUT]: + parent = await cg.get_variable(config[CONF_PCF8574]) + cg.add(parent.register_input_pin(config[CONF_NUMBER])) return var diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 5674b6a53e8..ddf1985db45 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -109,17 +109,6 @@ bool PCF8574Component::write_gpio_() { } float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } -void PCF8574GPIOPin::setup() { pin_mode(flags_); } -void PCF8574GPIOPin::set_flags(gpio::Flags flags) { - this->flags_ = flags; - if (flags & gpio::FLAG_INPUT) - this->parent_->register_input_pin(this->pin_); -} -void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t PCF8574GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCF8574", this->pin_); -} - } // namespace esphome::pcf8574 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pcf8574::PCF8574Component, "%u via PCF8574") diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 465bbd7a918..4bc19d204fa 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pcf8574 { @@ -56,31 +57,8 @@ class PCF8574Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a PCF8574 pin as an internal input GPIO pin. -class PCF8574GPIOPin final : public GPIOPin { - public: - // User provided, not "= default": `new(p) PCF8574GPIOPin()` would zero-fill .bss that is already zero. - PCF8574GPIOPin() {} - - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(PCF8574Component *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - // Call after set_parent() and set_pin(): input flags register the pin with the parent. - void set_flags(gpio::Flags flags); - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - PCF8574Component *parent_{nullptr}; - uint8_t pin_{0}; - bool inverted_{false}; - gpio::Flags flags_{}; -}; +using PCF8574GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pcf8574 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pcf8574::PCF8574Component); diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index fbfc998bb9f..6b7bfd47c3a 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -6,9 +6,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, CONF_ENABLE_PIN, - CONF_ID, CONF_INPUT, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OUTPUT, @@ -92,14 +90,6 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_pin_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output, not both") - return value - - def _validate_pin(value: ConfigType) -> ConfigType: if value[CONF_MODE][CONF_INPUT] and value[CONF_NUMBER] == 6: raise cv.Invalid("GPIO6 cannot be used as input on the PCM5122") @@ -111,7 +101,7 @@ PIN_SCHEMA = cv.All( PCM5122GPIOPin, cv.int_range(min=3, max=6), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=_validate_pin_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PCM5122): cv.use_id(PCM5122), @@ -123,13 +113,7 @@ PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) async def pcm5122_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_PCM5122]) - - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCM5122) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 458ea1f2728..d9d1fd31f51 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -6,9 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_OUTPUT, CONF_PULLDOWN, CONF_PULLUP, @@ -52,14 +49,6 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( PI4IOE5V6408GPIOPin, cv.int_range(min=0, max=7), @@ -69,7 +58,7 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( CONF_PULLUP, CONF_PULLDOWN, ], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PI4IOE5V6408): pins.use_id_or_address(PI4IOE5V6408Component), @@ -79,10 +68,4 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PI4IOE5V6408, PI4IOE5V6408_PIN_SCHEMA) async def pi4ioe5v6408_pin_schema(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_PI4IOE5V6408]) - - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PI4IOE5V6408) diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index e6a423de7cf..63fa1c249b4 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -182,14 +182,6 @@ bool PI4IOE5V6408Component::digital_read_cache(uint8_t pin) { return (this->inpu float PI4IOE5V6408Component::get_setup_priority() const { return setup_priority::IO; } -void PI4IOE5V6408GPIOPin::setup() { this->pin_mode(this->flags_); } -void PI4IOE5V6408GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PI4IOE5V6408GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PI4IOE5V6408GPIOPin::digital_write(bool value) { - this->parent_->digital_write(this->pin_, value != this->inverted_); -} -size_t PI4IOE5V6408GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PI4IOE5V6408", this->pin_); -} - } // namespace esphome::pi4ioe5v6408 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pi4ioe5v6408::PI4IOE5V6408Component, "%u via PI4IOE5V6408") diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 9909dc2217b..556404a3743 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -49,24 +50,8 @@ class PI4IOE5V6408Component final : public Component, bool read_gpio_outputs_(); }; -class PI4IOE5V6408GPIOPin final : public GPIOPin, public Parented { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using PI4IOE5V6408GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pi4ioe5v6408 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pi4ioe5v6408::PI4IOE5V6408Component); diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index c44c52a6fc6..c98f94d4b55 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -4,11 +4,7 @@ from esphome.components import i2c, key_provider from esphome.components.const import CONF_KEYS import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_ON_KEY, CONF_OPEN_DRAIN, CONF_OUTPUT, @@ -113,12 +109,7 @@ async def to_code(config: ConfigType) -> None: def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") + pins.gpio_validate_input_xor_output_pullup(value) if value[CONF_PULLDOWN] and not value[CONF_INPUT]: raise cv.Invalid("Pulldown only available with input") if value[CONF_PULLUP] and value[CONF_PULLDOWN]: @@ -129,34 +120,18 @@ def validate_mode(value: ConfigType) -> ConfigType: CONF_SX1509 = "sx1509" -SX1509_PIN_SCHEMA = cv.All( +SX1509_PIN_SCHEMA = pins.gpio_base_schema( + SX1509GPIOPin, + cv.int_range(min=0, max=15), + modes=[CONF_INPUT, CONF_PULLUP, CONF_PULLDOWN, CONF_OUTPUT, CONF_OPEN_DRAIN], + mode_validator=validate_mode, +).extend( { - cv.GenerateID(): cv.declare_id(SX1509GPIOPin), cv.Required(CONF_SX1509): pins.use_id_or_address(SX1509Component), - cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), - cv.Optional(CONF_MODE, default={}): cv.All( - { - cv.Optional(CONF_INPUT, default=False): cv.boolean, - cv.Optional(CONF_PULLUP, default=False): cv.boolean, - cv.Optional(CONF_PULLDOWN, default=False): cv.boolean, - cv.Optional(CONF_OUTPUT, default=False): cv.boolean, - cv.Optional(CONF_OPEN_DRAIN, default=False): cv.boolean, - }, - validate_mode, - ), - cv.Optional(CONF_INVERTED, default=False): cv.boolean, } ) @pins.PIN_SCHEMA_REGISTRY.register(CONF_SX1509, SX1509_PIN_SCHEMA) async def sx1509_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_SX1509]) - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_SX1509) diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index d883982bcd5..bbb38198459 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -312,3 +312,5 @@ void SX1509Component::set_debounce_keypad_(uint8_t time, uint8_t num_rows, uint8 } } // namespace esphome::sx1509 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::sx1509::SX1509Component, "%u via sx1509") diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index ef6ee6ec5e6..5b163cbd6e5 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -5,7 +5,7 @@ #include "esphome/components/gpio_expander/cached_gpio.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "sx1509_gpio_pin.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "sx1509_registers.h" #include @@ -97,4 +97,8 @@ class SX1509Component final : public Component, void clock_(uint8_t osc_source = 2, uint8_t osc_pin_function = 1, uint8_t osc_freq_out = 0, uint8_t osc_divider = 0); }; +using SX1509GPIOPin = gpio_expander::ExpanderGPIOPin; + } // namespace esphome::sx1509 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::sx1509::SX1509Component); diff --git a/esphome/components/sx1509/sx1509_gpio_pin.cpp b/esphome/components/sx1509/sx1509_gpio_pin.cpp deleted file mode 100644 index 41f967b6672..00000000000 --- a/esphome/components/sx1509/sx1509_gpio_pin.cpp +++ /dev/null @@ -1,18 +0,0 @@ -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" -#include "sx1509.h" -#include "sx1509_gpio_pin.h" - -namespace esphome::sx1509 { - -ESPHOME_LOG_TAG(TAG, "sx1509_gpio_pin"); - -void SX1509GPIOPin::setup() { pin_mode(flags_); } -void SX1509GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool SX1509GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void SX1509GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t SX1509GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via sx1509", this->pin_); -} - -} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/sx1509_gpio_pin.h b/esphome/components/sx1509/sx1509_gpio_pin.h deleted file mode 100644 index 3bd3d90bd93..00000000000 --- a/esphome/components/sx1509/sx1509_gpio_pin.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "esphome/core/gpio.h" - -namespace esphome::sx1509 { - -class SX1509Component; - -class SX1509GPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(SX1509Component *parent) { this->parent_ = parent; } - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - SX1509Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; - -} // namespace esphome::sx1509 diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index a3992b7c88e..5b7f58253e8 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -2,15 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import gpio_expander, i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -44,19 +36,11 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - TCA9555_PIN_SCHEMA = pins.gpio_base_schema( TCA9555GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -67,10 +51,4 @@ TCA9555_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_TCA9555, TCA9555_PIN_SCHEMA) async def tca9555_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_TCA9555]) - - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_TCA9555) diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 448836e6609..bfb837e243c 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -129,12 +129,6 @@ bool TCA9555Component::digital_read_cache(uint8_t pin) { return this->input_mask float TCA9555Component::get_setup_priority() const { return setup_priority::IO; } -void TCA9555GPIOPin::setup() { this->pin_mode(this->flags_); } -void TCA9555GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool TCA9555GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void TCA9555GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t TCA9555GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via TCA9555", this->pin_); -} - } // namespace esphome::tca9555 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::tca9555::TCA9555Component, "%u via TCA9555") diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 50037cbe92d..fe4f6c21264 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -46,25 +47,8 @@ class TCA9555Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a TCA9555 pin as an internal input GPIO pin. -class TCA9555GPIOPin final : public GPIOPin, public Parented { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using TCA9555GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::tca9555 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::tca9555::TCA9555Component); diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index 0b89ca8b561..e01fd924e7b 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -2,14 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -48,19 +41,11 @@ async def to_code(config: ConfigType) -> None: await i2c.new_i2c_component(config) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( WaveshareIOCH32V003GPIOPin, cv.int_range(min=0, max=7), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -73,13 +58,4 @@ WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) async def waveshare_io_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WAVESHARE_IO_CH32V003) diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 9ff7eb7f382..86571363257 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -124,16 +124,6 @@ void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } float WaveshareIOCH32V003Component::get_setup_priority() const { return setup_priority::IO; } -void WaveshareIOCH32V003GPIOPin::setup() { this->pin_mode(this->flags_); } -void WaveshareIOCH32V003GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool WaveshareIOCH32V003GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } - -void WaveshareIOCH32V003GPIOPin::digital_write(bool value) { - this->parent_->digital_write(this->pin_, value ^ this->inverted_); -} - -size_t WaveshareIOCH32V003GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "EXIO%u via WaveshareIO", this->pin_); -} - } // namespace esphome::waveshare_io_ch32v003 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::waveshare_io_ch32v003::WaveshareIOCH32V003Component, "EXIO%u via WaveshareIO") diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h index 4a31602fa41..adecaa681cc 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h @@ -4,6 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::waveshare_io_ch32v003 { @@ -27,8 +28,6 @@ class WaveshareIOCH32V003Component : public Component, void set_pwm_value(uint8_t value); // 0 - 255 protected: - friend class WaveshareIOCH32V003GPIOPin; - bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -41,25 +40,8 @@ class WaveshareIOCH32V003Component : public Component, bool write_gpio_outputs_(); }; -/// Helper class to expose a WaveshareIO pin as a GPIO pin. -class WaveshareIOCH32V003GPIOPin : public GPIOPin, public Parented { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - uint8_t pin_{}; - bool inverted_{}; - gpio::Flags flags_{}; -}; +using WaveshareIOCH32V003GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::waveshare_io_ch32v003 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::waveshare_io_ch32v003::WaveshareIOCH32V003Component); diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index 58bfc6f815d..5fb54a906aa 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -1,3 +1,4 @@ +from esphome import pins import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS @@ -90,10 +91,7 @@ async def register_weikai(var: MockObj, config: ConfigType) -> None: def validate_pin_mode(value: ConfigType) -> ConfigType: """Checks input/output mode inconsistency""" - if not (value[CONF_MODE][CONF_INPUT] or value[CONF_MODE][CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_MODE][CONF_INPUT] and value[CONF_MODE][CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") + pins.gpio_validate_input_xor_output(value[CONF_MODE]) return value diff --git a/esphome/components/wk2168_i2c/__init__.py b/esphome/components/wk2168_i2c/__init__.py index c450717ea2c..9c2b074e503 100644 --- a/esphome/components/wk2168_i2c/__init__.py +++ b/esphome/components/wk2168_i2c/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -52,11 +52,4 @@ WK2168_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2168_I2C, WK2168_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2168_I2C]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2168_I2C) diff --git a/esphome/components/wk2168_spi/__init__.py b/esphome/components/wk2168_spi/__init__.py index ef12117a2c7..12192e71db0 100644 --- a/esphome/components/wk2168_spi/__init__.py +++ b/esphome/components/wk2168_spi/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import spi, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -50,11 +50,4 @@ WK2168_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2168_SPI, WK2168_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2168_SPI]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2168_SPI) diff --git a/esphome/components/wk2212_i2c/__init__.py b/esphome/components/wk2212_i2c/__init__.py index 1f51fea3145..73c41b36504 100644 --- a/esphome/components/wk2212_i2c/__init__.py +++ b/esphome/components/wk2212_i2c/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -52,11 +52,4 @@ WK2212_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2212_I2C, WK2212_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2212_I2C]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2212_I2C) diff --git a/esphome/components/wk2212_spi/__init__.py b/esphome/components/wk2212_spi/__init__.py index a0cce3331ad..cc4c358f284 100644 --- a/esphome/components/wk2212_spi/__init__.py +++ b/esphome/components/wk2212_spi/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import spi, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -50,11 +50,4 @@ WK2212_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2212_SPI, WK2212_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2212_SPI]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2212_SPI) diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 85330357d2d..8d1e03ff156 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -2,19 +2,13 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType CONF_XL9535 = "xl9535" +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] CODEOWNERS = ["@mreditor97"] DOMAIN = "xl9535" @@ -36,45 +30,24 @@ async def to_code(config: ConfigType) -> None: await i2c.new_i2c_component(config) -def validate_mode(mode: ConfigType) -> ConfigType: - if not (mode[CONF_INPUT] or mode[CONF_OUTPUT]) or ( - mode[CONF_INPUT] and mode[CONF_OUTPUT] - ): - raise cv.Invalid("Mode must be either a input or a output") - return mode - - def validate_pin(pin: int) -> int: if pin in (8, 9): raise cv.Invalid(f"pin {pin} doesn't exist") return pin -XL9535_PIN_SCHEMA = cv.All( +XL9535_PIN_SCHEMA = pins.gpio_base_schema( + XL9535GPIOPin, + cv.All(cv.int_range(min=0, max=17), validate_pin), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=pins.gpio_validate_input_xor_output, +).extend( { - cv.GenerateID(): cv.declare_id(XL9535GPIOPin), cv.Required(CONF_XL9535): pins.use_id_or_address(XL9535Component), - cv.Required(CONF_NUMBER): cv.All(cv.int_range(min=0, max=17), validate_pin), - cv.Optional(CONF_MODE, default={}): cv.All( - { - cv.Optional(CONF_INPUT, default=False): cv.boolean, - cv.Optional(CONF_OUTPUT, default=False): cv.boolean, - }, - validate_mode, - ), - cv.Optional(CONF_INVERTED, default=False): cv.boolean, } ) @pins.PIN_SCHEMA_REGISTRY.register(CONF_XL9535, XL9535_PIN_SCHEMA) async def xl9535_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_XL9535]) - - cg.add(var.set_parent(parent)) - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - - return var + return await pins.new_parented_pin(config, CONF_XL9535) diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index 9be536353c8..d26641c3afa 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -103,14 +103,6 @@ void XL9535Component::pin_mode(uint8_t pin, gpio::Flags mode) { } } -void XL9535GPIOPin::setup() { this->pin_mode(this->flags_); } - -size_t XL9535GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via XL9535", this->pin_); -} - -void XL9535GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool XL9535GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void XL9535GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } - } // namespace esphome::xl9535 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::xl9535::XL9535Component, "%u via XL9535") diff --git a/esphome/components/xl9535/xl9535.h b/esphome/components/xl9535/xl9535.h index 11fb3acc8d0..8f883d36df7 100644 --- a/esphome/components/xl9535/xl9535.h +++ b/esphome/components/xl9535/xl9535.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/hal.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::xl9535 { @@ -28,27 +29,8 @@ class XL9535Component final : public Component, public i2c::I2CDevice { float get_setup_priority() const override { return setup_priority::IO; } }; -class XL9535GPIOPin final : public GPIOPin { - public: - void set_parent(XL9535Component *parent) { this->parent_ = parent; } - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - void setup() override; - size_t dump_summary(char *buffer, size_t len) const override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - - protected: - XL9535Component *parent_; - - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using XL9535GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::xl9535 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::xl9535::XL9535Component); diff --git a/tests/components/pcf8574/test_pcf8574.cpp b/tests/components/pcf8574/test_pcf8574.cpp index 3500069ba10..1caf86febaf 100644 --- a/tests/components/pcf8574/test_pcf8574.cpp +++ b/tests/components/pcf8574/test_pcf8574.cpp @@ -50,10 +50,8 @@ class PCF8574ResetTest : public ::testing::TestWithParam { } void configure_input_(uint8_t number) { - PCF8574GPIOPin pin; - pin.set_parent(&this->component_); - pin.set_pin(number); - pin.set_flags(gpio::FLAG_INPUT); + // Codegen registers input pins with the parent before setup() + this->component_.register_input_pin(number); EXPECT_EQ(this->bus_.writes, 0); EXPECT_EQ(this->bus_.reads, 0); } diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py index e5b6519700d..250eb5be9bc 100644 --- a/tests/unit_tests/components/test_pcf8574.py +++ b/tests/unit_tests/components/test_pcf8574.py @@ -5,7 +5,14 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from esphome.components import pcf8574 -from esphome.const import CONF_ID, CONF_RESET +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, + CONF_RESET, +) @pytest.mark.asyncio @@ -31,3 +38,33 @@ async def test_reset_configuration(options: dict, expected: bool) -> None: var.set_reset.assert_not_called() else: var.set_reset.assert_called_once_with(False) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "number, is_input", + [ + (0, True), + (15, True), + (0, False), + (15, False), + ], +) +async def test_pin_registers_inputs_only(number: int, is_input: bool) -> None: + config = { + CONF_NUMBER: number, + CONF_MODE: {CONF_INPUT: is_input, CONF_OUTPUT: not is_input}, + pcf8574.CONF_PCF8574: "hub", + } + parent = Mock() + with ( + patch.object(pcf8574.pins, "new_parented_pin", new_callable=AsyncMock), + patch.object(pcf8574.cg, "get_variable", AsyncMock(return_value=parent)), + patch.object(pcf8574.cg, "add"), + ): + await pcf8574.pcf8574_pin_to_code(config) + + if is_input: + parent.register_input_pin.assert_called_once_with(number) + else: + parent.register_input_pin.assert_not_called() From 0e8e45c5283b13f175e70d2817dc53790560ca89 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:35:15 +1300 Subject: [PATCH 636/651] [mipi_spi] Fix GC9107 native size and add M5Stack AtomS3 model (#21087) --- esphome/components/mipi_spi/models/m5stack.py | 11 +++ .../mipi_spi/test_padding_and_offsets.py | 67 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index f62bee5f602..4846635ac85 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -55,6 +55,8 @@ ILI9341.extend( GC9107 = ST7789V.extend( "GC9107", + native_width=132, + native_height=130, width=128, height=128, offset_width=2, @@ -73,6 +75,15 @@ GC9107.extend( requires={"psram"}, ) +GC9107.extend( + "M5STACK-ATOMS3", + data_rate="40MHz", + invert_colors=True, + reset_pin=34, + dc_pin=33, + cs_pin=15, +) + # M5Stack AtomS3R (ST7735 revision - May 14, 2026+) ST7735.extend( "M5STACK-ATOMS3R-ST7735", diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index b2b421c1e20..e3471721730 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -601,3 +601,70 @@ class TestSwapXYNativeDimensions: # native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20 assert pad_w == 20 assert pad_h == 0 + + +class TestGC9107Dimensions: + """Test the GC9107 native size and the M5Stack AtomS3 visible area.""" + + @pytest.mark.parametrize( + "config", + [ + pytest.param({"model": "M5STACK-ATOMS3"}, id="atoms3_model"), + pytest.param( + { + "model": "GC9107", + "dc_pin": 33, + "dimensions": { + "width": 128, + "height": 128, + "offset_width": 2, + "offset_height": 1, + }, + }, + id="gc9107_explicit_offsets", + ), + ], + ) + @pytest.mark.parametrize("rotation", [0, 180]) + def test_atoms3_offsets_and_pads( + self, + config: ConfigType, + rotation: int, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Pads come from the 132x130 GC9107 memory, so rotation 180 lands on the visible area.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config = validated_config({**config, "rotation": rotation}) + _, templateargs = get_instance(config) + + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT + assert templateargs[5:11] == [128, 128, 2, 1, 2, 1] + # Hardware rotation swaps in the pads as offsets for rotation 180 + assert templateargs[12] is True + + def test_atoms3r_defaults_unchanged( + self, + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + ) -> None: + """The GC9107 native size does not change the AtomS3R model defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + set_component_config("psram", True) + + config = validated_config({"model": "M5STACK-ATOMS3R-GC9107"}) + _, templateargs = get_instance(config) + + assert templateargs[5:11] == [128, 128, 2, 1, 2, 1] From 4d4ba5a1bcb2c1bd62b88ac63bae73f6379ef746 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:37:35 +1100 Subject: [PATCH 637/651] [core] Move CONF_MAX_PACKET_SIZE to components/const (#21102) Co-authored-by: Claude Opus 5.5 --- esphome/components/const/__init__.py | 1 + esphome/components/usb_host/__init__.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index c2ae1a1a87b..a15df707e60 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -47,6 +47,7 @@ CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_MANUFACTURER = "manufacturer" +CONF_MAX_PACKET_SIZE = "max_packet_size" CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 1f12112eea7..07a5cae0ae5 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -2,7 +2,7 @@ import itertools import logging import esphome.codegen as cg -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_MANUFACTURER, CONF_MAX_PACKET_SIZE from esphome.components.esp32 import ( VARIANT_ESP32H4, VARIANT_ESP32P4, @@ -38,7 +38,6 @@ CONF_PID = "pid" CONF_PRODUCT = "product" CONF_ENABLE_HUBS = "enable_hubs" CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests" -CONF_MAX_PACKET_SIZE = "max_packet_size" # VID/PID set to 0 or `None` product/manufacturer are wildcards From 1dc4006bfc2787e5da5a76ca9984151263d3b488 Mon Sep 17 00:00:00 2001 From: ab-tools Date: Mon, 19 Oct 2026 05:44:59 +0200 Subject: [PATCH 638/651] [usb_audio] Add USB Audio Class (UAC) host support (#11728) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/usb_audio/__init__.py | 152 +++ .../usb_audio/microphone/__init__.py | 56 ++ .../microphone/usb_audio_microphone.cpp | 182 ++++ .../microphone/usb_audio_microphone.h | 76 ++ .../components/usb_audio/speaker/__init__.py | 62 ++ .../usb_audio/speaker/usb_audio_speaker.cpp | 359 +++++++ .../usb_audio/speaker/usb_audio_speaker.h | 112 +++ esphome/components/usb_audio/usb_audio.cpp | 876 ++++++++++++++++++ esphome/components/usb_audio/usb_audio.h | 239 +++++ esphome/core/defines.h | 5 + esphome/idf_component.yml | 5 + tests/component_tests/usb_audio/__init__.py | 1 + .../usb_audio/test_validation.py | 132 +++ tests/components/usb_audio/common.yaml | 21 + .../usb_audio/test.esp32-p4-idf.yaml | 2 + .../usb_audio/test.esp32-s3-idf.yaml | 2 + 17 files changed, 2283 insertions(+) create mode 100644 esphome/components/usb_audio/__init__.py create mode 100644 esphome/components/usb_audio/microphone/__init__.py create mode 100644 esphome/components/usb_audio/microphone/usb_audio_microphone.cpp create mode 100644 esphome/components/usb_audio/microphone/usb_audio_microphone.h create mode 100644 esphome/components/usb_audio/speaker/__init__.py create mode 100644 esphome/components/usb_audio/speaker/usb_audio_speaker.cpp create mode 100644 esphome/components/usb_audio/speaker/usb_audio_speaker.h create mode 100644 esphome/components/usb_audio/usb_audio.cpp create mode 100644 esphome/components/usb_audio/usb_audio.h create mode 100644 tests/component_tests/usb_audio/__init__.py create mode 100644 tests/component_tests/usb_audio/test_validation.py create mode 100644 tests/components/usb_audio/common.yaml create mode 100644 tests/components/usb_audio/test.esp32-p4-idf.yaml create mode 100644 tests/components/usb_audio/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 61f1f4722cc..3bb01931381 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -684,6 +684,7 @@ esphome/components/ultrasonic/* @ssieb @swoboda1337 esphome/components/unicode/* @esphome/core esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon +esphome/components/usb_audio/* @ab-tools esphome/components/usb_cdc_acm/* @kbx81 esphome/components/usb_host/* @clydebarrow esphome/components/usb_uart/* @clydebarrow diff --git a/esphome/components/usb_audio/__init__.py b/esphome/components/usb_audio/__init__.py new file mode 100644 index 00000000000..fd1333d46f6 --- /dev/null +++ b/esphome/components/usb_audio/__init__.py @@ -0,0 +1,152 @@ +from collections.abc import Callable + +import esphome.codegen as cg +from esphome.components import audio, esp32, usb_host +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_BUFFER_SIZE, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_PLATFORM, + CONF_SAMPLE_RATE, +) +from esphome.cpp_generator import MockObj +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@ab-tools"] +DEPENDENCIES = ["esp32"] +# usb_host installs the USB host library, services its events and limits the variants +AUTO_LOAD = ["audio", "usb_host"] +DOMAIN = "usb_audio" + +usb_audio_ns = cg.esphome_ns.namespace("usb_audio") +USBAudioComponent = usb_audio_ns.class_("USBAudioComponent", cg.Component) +AudioStreamInfo = audio.audio_ns.class_("AudioStreamInfo") + +CONF_USB_AUDIO_ID = "usb_audio_id" +BITS_PER_SAMPLE_OPTIONS = [8, 16, 24, 32] +DEFAULT_BUFFER_SIZE = 6400 + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBAudioComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +def stream_schema(default_sample_rate: int, default_num_channels: int) -> cv.Schema: + """Stream format and buffer options shared by the speaker and microphone platforms.""" + return cv.Schema( + { + cv.GenerateID(CONF_USB_AUDIO_ID): cv.use_id(USBAudioComponent), + cv.Optional(CONF_SAMPLE_RATE, default=default_sample_rate): cv.int_range( + min=8000, max=96000 + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + cv.float_with_unit("bits", "bit"), cv.one_of(*BITS_PER_SAMPLE_OPTIONS) + ), + cv.Optional(CONF_NUM_CHANNELS, default=default_num_channels): cv.int_range( + min=1, max=2 + ), + # The default depends on the format; see apply_buffer_size() + cv.Optional(CONF_BUFFER_SIZE): cv.positive_not_null_int, + } + ).extend(cv.COMPONENT_SCHEMA) + + +def set_stream_limits(config: ConfigType) -> ConfigType: + """Pin the audio stream limits to the one configured format.""" + bits_per_sample = int(config[CONF_BITS_PER_SAMPLE]) + channels = config[CONF_NUM_CHANNELS] + sample_rate = config[CONF_SAMPLE_RATE] + # The limits validator edits the config in place and returns None + audio.set_stream_limits( + min_bits_per_sample=bits_per_sample, + max_bits_per_sample=bits_per_sample, + min_channels=channels, + max_channels=channels, + min_sample_rate=sample_rate, + max_sample_rate=sample_rate, + )(config) + return config + + +def bytes_per_frame(config: ConfigType) -> int: + """Bytes in one frame (one sample for every channel) of a platform's configured format.""" + return config[CONF_NUM_CHANNELS] * int(config[CONF_BITS_PER_SAMPLE]) // 8 + + +def apply_buffer_size(config: ConfigType, min_size: int, minimum: str) -> ConfigType: + """Default buffer_size to DEFAULT_BUFFER_SIZE, or the format's minimum when that is larger; reject smaller ones. + + `minimum` describes what min_size holds, for the error message. + """ + if CONF_BUFFER_SIZE not in config: + config[CONF_BUFFER_SIZE] = max(DEFAULT_BUFFER_SIZE, min_size) + elif config[CONF_BUFFER_SIZE] < min_size: + raise cv.Invalid( + f"buffer_size must be at least {min_size} bytes ({minimum}) for this format", + path=[CONF_BUFFER_SIZE], + ) + return config + + +def final_validate_one_per_hub(domain: str) -> Callable[[ConfigType], ConfigType]: + """The hub streams one speaker and one microphone, so reject a second platform of the same kind on a hub.""" + + def validator(config: ConfigType) -> ConfigType: + hub_id = str(config[CONF_USB_AUDIO_ID]) + count = sum( + 1 + for platform_config in fv.full_config.get().get(domain, []) + if platform_config.get(CONF_PLATFORM) == DOMAIN + and str(platform_config.get(CONF_USB_AUDIO_ID)) == hub_id + ) + if count > 1: + raise cv.Invalid( + f"Only one usb_audio {domain} per usb_audio component is supported", + path=[CONF_USB_AUDIO_ID], + ) + return config + + return validator + + +def max_packet_bytes(config: ConfigType) -> int: + """Largest isochronous packet of a platform's format: a 1 ms packet, plus one frame for rate adaptation.""" + return (-(-config[CONF_SAMPLE_RATE] // 1000) + 1) * bytes_per_frame(config) + + +def request_speaker_fifo(config: ConfigType) -> ConfigType: + """Ask usb_host for USB FIFO space for the speaker's isochronous OUT packets.""" + usb_host.request_periodic_fifo(out_bytes=max_packet_bytes(config)) + return config + + +def request_microphone_fifo(config: ConfigType) -> ConfigType: + """Ask usb_host for USB FIFO space for the microphone's isochronous IN packets.""" + usb_host.request_periodic_fifo(in_bytes=max_packet_bytes(config)) + return config + + +def stream_info(config: ConfigType) -> MockObj: + """The C++ AudioStreamInfo for a platform's configured format.""" + return AudioStreamInfo( + int(config[CONF_BITS_PER_SAMPLE]), + config[CONF_NUM_CHANNELS], + config[CONF_SAMPLE_RATE], + ) + + +async def to_code(config: ConfigType) -> None: + # Keep in sync with esphome/idf_component.yml. usb_host adds espressif/usb on IDF 6.0 and later. + esp32.add_idf_component(name="espressif/usb_host_uac", ref="1.5.0") + # 18 ms of transfers in flight instead of the driver's 9 ms, so a late transfer completion does not leave the bus + # idle while the ring buffer still holds audio. + esp32.set_idf_sdkconfig_default("CONFIG_UAC_NUM_ISOC_URBS", 6) + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add_define("USE_USB_AUDIO") diff --git a/esphome/components/usb_audio/microphone/__init__.py b/esphome/components/usb_audio/microphone/__init__.py new file mode 100644 index 00000000000..51ab3ac47ea --- /dev/null +++ b/esphome/components/usb_audio/microphone/__init__.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import microphone +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +from .. import ( + CONF_USB_AUDIO_ID, + apply_buffer_size, + bytes_per_frame, + final_validate_one_per_hub, + request_microphone_fifo, + set_stream_limits, + stream_info, + stream_schema, + usb_audio_ns, +) + +USBAudioMicrophone = usb_audio_ns.class_( + "USBAudioMicrophone", microphone.Microphone, cg.Component +) + +# Keep in sync with USBAudioMicrophone::READ_TIMEOUT_MS in usb_audio_microphone.h +READ_PERIOD_MS = 20 + + +def validate_buffer_size(config: ConfigType) -> ConfigType: + """The driver buffer must hold the audio that arrives between two reads, with room to spare.""" + bytes_per_second = config[CONF_SAMPLE_RATE] * bytes_per_frame(config) + min_size = bytes_per_second * 2 * READ_PERIOD_MS // 1000 + return apply_buffer_size(config, min_size, f"{2 * READ_PERIOD_MS} ms of audio") + + +CONFIG_SCHEMA = cv.All( + microphone.MICROPHONE_SCHEMA.extend(stream_schema(16000, 1)).extend( + { + cv.GenerateID(): cv.declare_id(USBAudioMicrophone), + } + ), + set_stream_limits, + validate_buffer_size, + request_microphone_fifo, +) + +FINAL_VALIDATE_SCHEMA = final_validate_one_per_hub("microphone") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_ID], stream_info(config), config[CONF_BUFFER_SIZE] + ) + await cg.register_component(var, config) + await microphone.register_microphone(var, config) + hub = await cg.get_variable(config[CONF_USB_AUDIO_ID]) + await cg.register_parented(var, hub) + cg.add(hub.register_microphone(var)) diff --git a/esphome/components/usb_audio/microphone/usb_audio_microphone.cpp b/esphome/components/usb_audio/microphone/usb_audio_microphone.cpp new file mode 100644 index 00000000000..9d699baccf2 --- /dev/null +++ b/esphome/components/usb_audio/microphone/usb_audio_microphone.cpp @@ -0,0 +1,182 @@ +#include "usb_audio_microphone.h" + +#ifdef USE_USB_AUDIO + +#include "esphome/core/log.h" + +#include +#include + +extern "C" { +#include "esp_err.h" +} + +namespace esphome::usb_audio { + +ESPHOME_LOG_TAG(TAG, "usb_audio.mic"); + +void USBAudioMicrophone::setup() { + this->frame_.reserve(this->audio_stream_info_.ms_to_bytes(2 * READ_TIMEOUT_MS)); + if (!this->mic_task_.create(&USBAudioMicrophone::mic_task, "usb_mic", MIC_TASK_STACK_SIZE, this, MIC_TASK_PRIORITY, + false)) { + this->mark_failed(LOG_STR("Failed to create microphone task")); + return; + } + // start() and stop() wake the loop. + this->disable_loop(); +} + +void USBAudioMicrophone::dump_config() { + ESP_LOGCONFIG(TAG, + "USB Microphone:\n" + " Sample rate: %" PRIu32 " Hz\n" + " Bits per sample: %u\n" + " Channels: %u\n" + " Buffer size: %" PRIu32, + this->audio_stream_info_.get_sample_rate(), this->audio_stream_info_.get_bits_per_sample(), + this->audio_stream_info_.get_channels(), this->buffer_size_); +} + +void USBAudioMicrophone::loop() { + const uint8_t commands = this->commands_.exchange(0); + if (commands & COMMAND_STOP) { + // Also when a failed start left the microphone STOPPED, so the next session's retries start from the minimum. + this->cancel_timeout(RETRY_START_ID); + this->retry_delay_ms_ = RETRY_MIN_MS; + } + if ((commands & COMMAND_STOP) && + (this->state_ == microphone::STATE_STARTING || this->state_ == microphone::STATE_RUNNING)) { + this->parent_->suspend(Endpoint::MICROPHONE); + // The task notices within one read timeout; the stop finishes below once it is idle. + this->running_ = false; + this->state_ = microphone::STATE_STOPPING; + } + if (this->state_ == microphone::STATE_STOPPING && this->task_idle_) { + this->state_ = microphone::STATE_STOPPED; + } + // A retry can race the last stop(), so only start while someone is still listening. + if ((commands & COMMAND_START) && this->listeners_ > 0) { + if (this->state_ == microphone::STATE_STOPPED) { + this->state_ = microphone::STATE_STARTING; + // Answers through on_stream_state() now or once the device connects. + this->parent_->resume(Endpoint::MICROPHONE); + } else if (this->state_ == microphone::STATE_STOPPING) { + // Retry once the task is idle. + this->commands_.fetch_or(COMMAND_START); + } + } + if (this->state_ != microphone::STATE_STOPPING) { + this->disable_loop(); + } +} + +void USBAudioMicrophone::request_(uint8_t set, uint8_t clear) { + this->commands_.fetch_and(static_cast(~clear)); + this->commands_.fetch_or(set); + this->enable_loop_soon_any_context(); +} + +// Every MicrophoneSource forwards its own start() and stop(), so capture runs while any of them is listening, like +// i2s_audio's listener count. +void USBAudioMicrophone::start() { + if (this->is_failed()) { + return; + } + uint8_t listeners = this->listeners_.load(); + do { + if (listeners >= MAX_LISTENERS) { + return; + } + } while (!this->listeners_.compare_exchange_weak(listeners, listeners + 1)); + if (listeners == 0) { + this->request_(COMMAND_START, COMMAND_STOP); + } +} + +void USBAudioMicrophone::stop() { + uint8_t listeners = this->listeners_.load(); + do { + if (listeners == 0) { + return; + } + } while (!this->listeners_.compare_exchange_weak(listeners, listeners - 1)); + if (listeners == 1) { + this->request_(COMMAND_STOP, COMMAND_START); + } +} + +void USBAudioMicrophone::on_stream_state(StreamEvent event) { + switch (event) { + case StreamEvent::RUNNING: + if (this->state_ == microphone::STATE_STARTING) { + this->task_idle_ = false; + this->running_ = true; + xTaskNotifyGive(this->mic_task_.get_handle()); + this->state_ = microphone::STATE_RUNNING; + this->retry_delay_ms_ = RETRY_MIN_MS; + } + break; + case StreamEvent::LOST: + // The hub restarts the stream on reconnect, since the microphone still wants it, so keep waiting in STARTING; + // this also covers a disconnect while the stream was still starting. + if (this->state_ == microphone::STATE_RUNNING) { + ESP_LOGW(TAG, "USB device disconnected, waiting for it to return"); + this->running_ = false; + this->state_ = microphone::STATE_STARTING; + } + break; + case StreamEvent::FAILED: + // Only a start or resume fails, so the task is idle. + if (this->state_ == microphone::STATE_STARTING) { + this->state_ = microphone::STATE_STOPPED; + // A MicrophoneSource calls start() once and then waits for data, and start() only asks for the stream when + // the first listener arrives, so retry here for the listeners still held. + if (this->listeners_ > 0) { + this->set_timeout(RETRY_START_ID, this->retry_delay_ms_, [this]() { + if (this->listeners_ > 0) { + this->request_(COMMAND_START, 0); + } + }); + this->retry_delay_ms_ = std::min(this->retry_delay_ms_ * 2, RETRY_MAX_MS); + } + } + break; + } +} + +void USBAudioMicrophone::mic_task(void *param) { static_cast(param)->mic_task_loop_(); } + +void USBAudioMicrophone::mic_task_loop_() { + while (true) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (this->running_) { + size_t bytes_read = 0; + this->frame_.resize(this->frame_.capacity()); + esp_err_t err = + this->parent_->read_microphone(this->frame_.data(), this->frame_.size(), &bytes_read, READ_TIMEOUT_MS); + if (!this->running_) { + break; + } + + if (err == ESP_OK && bytes_read > 0) { + this->frame_.resize(bytes_read); + this->data_callbacks_.call(this->frame_); + } else if (err == ESP_ERR_TIMEOUT) { + // The driver already waited READ_TIMEOUT_MS for data; read again. + } else if (err == ESP_ERR_INVALID_STATE) { + // The stream is starting or suspended. + vTaskDelay(pdMS_TO_TICKS(READ_TIMEOUT_MS)); + } else if (err != ESP_OK) { + ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err)); + vTaskDelay(pdMS_TO_TICKS(READ_TIMEOUT_MS)); + } + } + + this->task_idle_ = true; + } +} + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/microphone/usb_audio_microphone.h b/esphome/components/usb_audio/microphone/usb_audio_microphone.h new file mode 100644 index 00000000000..1b2ea744e0e --- /dev/null +++ b/esphome/components/usb_audio/microphone/usb_audio_microphone.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_USB_AUDIO + +#include "esphome/components/usb_audio/usb_audio.h" + +#include "esphome/components/audio/audio.h" +#include "esphome/components/microphone/microphone.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/static_task.h" + +#include +#include + +namespace esphome::usb_audio { + +class USBAudioMicrophone : public microphone::Microphone, + public Component, + public Parented, + public USBAudioStream { + public: + USBAudioMicrophone(const audio::AudioStreamInfo &stream_info, uint32_t buffer_size) : USBAudioStream(buffer_size) { + this->audio_stream_info_ = stream_info; + } + + void setup() override; + void dump_config() override; + void loop() override; + + // Safe from any task; loop() acts on the request. + void start() override; + void stop() override; + + audio::AudioStreamInfo get_usb_stream_info() override { return this->audio_stream_info_; } + void on_stream_state(StreamEvent event) override; + bool stream_idle() const override { return this->task_idle_; } + + protected: + static constexpr uint8_t COMMAND_START = 1 << 0; + static constexpr uint8_t COMMAND_STOP = 1 << 1; + // Same limit as i2s_audio's microphone. + static constexpr uint8_t MAX_LISTENERS = 16; + static constexpr uint32_t READ_TIMEOUT_MS = 20; + // A failed start is retried while listeners are held, backing off so a device that keeps failing does not flood the + // log. + static constexpr uint32_t RETRY_MIN_MS = 1000; + static constexpr uint32_t RETRY_MAX_MS = 30000; + // Scheduler ids. + static constexpr uint32_t RETRY_START_ID = 0; + static constexpr uint32_t MIC_TASK_STACK_SIZE = 4096; + static constexpr UBaseType_t MIC_TASK_PRIORITY = 18; + + static void mic_task(void *param); + void mic_task_loop_(); + void request_(uint8_t set, uint8_t clear); + + // The data callback takes a std::vector, so this is reserved once in setup() and only ever resized within its + // capacity by the mic task. + std::vector frame_; + // Created once in setup() and parked between streams. + StaticTask mic_task_; + std::atomic commands_{0}; + // start() calls not yet matched by a stop(); capture stops when the last listener stops. + std::atomic listeners_{0}; + std::atomic running_{false}; + std::atomic task_idle_{true}; + // Main loop only. + uint32_t retry_delay_ms_{RETRY_MIN_MS}; +}; + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/speaker/__init__.py b/esphome/components/usb_audio/speaker/__init__.py new file mode 100644 index 00000000000..62555f89d3c --- /dev/null +++ b/esphome/components/usb_audio/speaker/__init__.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components import speaker +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +from .. import ( + CONF_USB_AUDIO_ID, + apply_buffer_size, + bytes_per_frame, + final_validate_one_per_hub, + request_speaker_fifo, + set_stream_limits, + stream_info, + stream_schema, + usb_audio_ns, +) + +USBAudioSpeaker = usb_audio_ns.class_("USBAudioSpeaker", speaker.Speaker, cg.Component) + +CONF_WRITE_TIMEOUT = "write_timeout" + +# Keep in sync with the usb_host_uac default of CONFIG_UAC_NUM_PACKETS_PER_URB (1 ms packets per transfer) +PACKETS_PER_TRANSFER = 3 + + +def validate_buffer_size(config: ConfigType) -> ConfigType: + """The driver only sends complete transfers, so the buffer must hold two of them to keep audio flowing.""" + frames_per_packet = -(-config[CONF_SAMPLE_RATE] // 1000) + transfer_size = PACKETS_PER_TRANSFER * frames_per_packet * bytes_per_frame(config) + return apply_buffer_size(config, 2 * transfer_size, "two USB transfers") + + +CONFIG_SCHEMA = cv.All( + speaker.SPEAKER_SCHEMA.extend(stream_schema(48000, 2)).extend( + { + cv.GenerateID(): cv.declare_id(USBAudioSpeaker), + cv.Optional( + CONF_WRITE_TIMEOUT, default="20ms" + ): cv.positive_time_period_milliseconds, + } + ), + set_stream_limits, + validate_buffer_size, + request_speaker_fifo, +) + +FINAL_VALIDATE_SCHEMA = final_validate_one_per_hub("speaker") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_ID], + stream_info(config), + config[CONF_BUFFER_SIZE], + config[CONF_WRITE_TIMEOUT].total_milliseconds, + ) + await cg.register_component(var, config) + await speaker.register_speaker(var, config) + hub = await cg.get_variable(config[CONF_USB_AUDIO_ID]) + await cg.register_parented(var, hub) + cg.add(hub.register_speaker(var)) diff --git a/esphome/components/usb_audio/speaker/usb_audio_speaker.cpp b/esphome/components/usb_audio/speaker/usb_audio_speaker.cpp new file mode 100644 index 00000000000..2e1613b8ccb --- /dev/null +++ b/esphome/components/usb_audio/speaker/usb_audio_speaker.cpp @@ -0,0 +1,359 @@ +#include "usb_audio_speaker.h" + +#ifdef USE_USB_AUDIO + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esp_err.h" +#include "esp_timer.h" +#include "sdkconfig.h" + +namespace esphome::usb_audio { + +ESPHOME_LOG_TAG(TAG, "usb_audio.spk"); + +static constexpr size_t MAX_TIMEOUT_RETRIES = 4; +// Zeros are silence for the signed PCM the driver streams; 240 is a whole number of frames for every format. +static constexpr uint8_t SILENCE[240] = {}; + +void USBAudioSpeaker::setup() { + const auto &info = this->usb_stream_info_; + this->write_chunk_ = info.frames_to_bytes(std::max(info.bytes_to_frames(this->buffer_size_ / 4), 1)); + // The driver sends 1 ms packets, grouped into transfers it keeps in flight. + this->frame_bytes_ = info.frames_to_bytes(1); + this->packet_bytes_ = info.frames_to_bytes(info.get_sample_rate() / 1000); + this->packet_frac_ = info.get_sample_rate() % 1000; + this->transfer_bytes_ = info.frames_to_bytes((info.get_sample_rate() + 999) / 1000 * CONFIG_UAC_NUM_PACKETS_PER_URB); + this->transfer_us_ = CONFIG_UAC_NUM_PACKETS_PER_URB * 1000; + this->in_flight_us_ = CONFIG_UAC_NUM_ISOC_URBS * this->transfer_us_; + // About 10 ms per report, like the I2S speaker's DMA buffers. + this->report_frames_ = std::max(info.get_sample_rate() / 100, 1); + // start(), stop() and finish() wake the loop. + this->disable_loop(); +} + +void USBAudioSpeaker::dump_config() { + ESP_LOGCONFIG(TAG, + "USB Speaker:\n" + " Sample rate: %" PRIu32 " Hz\n" + " Bits per sample: %u\n" + " Channels: %u\n" + " Buffer size: %" PRIu32 "\n" + " Write timeout: %" PRIu32 " ms", + this->usb_stream_info_.get_sample_rate(), this->usb_stream_info_.get_bits_per_sample(), + this->usb_stream_info_.get_channels(), this->buffer_size_, this->write_timeout_ms_); +} + +void USBAudioSpeaker::loop() { + this->deliver_reports_(); + const uint8_t commands = this->commands_.exchange(0); + if (commands & COMMAND_STOP) { + this->stop_(); + } + if ((commands & COMMAND_START) && this->state_ == speaker::STATE_STOPPED) { + // Suspending flushed the driver's ring buffer, and resuming restarts its packet sizing. + portENTER_CRITICAL(&this->taken_lock_); + this->ring_bytes_ = 0; + this->ring_padding_ = 0; + // Resuming resets the driver's accumulator, then prepares every transfer, which advances it by their packets. + this->packet_frac_accum_ = CONFIG_UAC_NUM_ISOC_URBS * CONFIG_UAC_NUM_PACKETS_PER_URB * this->packet_frac_ % 1000; + this->unreported_frames_ = 0; + this->report_frames_pending_ = 0; + this->last_finish_us_ = 0; + portEXIT_CRITICAL(&this->taken_lock_); + this->state_ = speaker::STATE_STARTING; + // Answers through on_stream_state() now or once the device connects. + this->parent_->resume(Endpoint::SPEAKER); + } + + if (commands & COMMAND_FINISH) { + if (this->state_ == speaker::STATE_RUNNING) { + this->begin_finishing_(); + } else if (this->state_ == speaker::STATE_STARTING) { + // The stream is still starting; on_stream_state() finishes it once it runs. + this->finish_after_start_ = true; + } + } + if (this->finishing_ && this->pad_remaining_ > 0) { + this->pad_with_silence_(); + } + // has_buffered_data_() turns false once the driver has taken the last whole transfer and it has had time to play. + if (this->finishing_ && this->pad_remaining_ == 0 && !this->has_buffered_data_(esp_timer_get_time())) { + this->stop_(); + } + if (!this->finishing_) { + this->disable_loop(); + } +} + +void USBAudioSpeaker::request_(uint8_t set, uint8_t clear) { + this->commands_.fetch_and(static_cast(~clear)); + this->commands_.fetch_or(set); + this->enable_loop_soon_any_context(); +} + +void USBAudioSpeaker::start() { + if (this->state_ == speaker::STATE_STARTING || this->state_ == speaker::STATE_RUNNING) { + return; + } + this->request_(COMMAND_START, COMMAND_STOP); +} + +void USBAudioSpeaker::stop() { this->request_(COMMAND_STOP, COMMAND_START | COMMAND_FINISH); } + +void USBAudioSpeaker::finish() { this->request_(COMMAND_FINISH, 0); } + +void USBAudioSpeaker::begin_finishing_() { + if (this->finishing_) { + return; + } + this->finishing_ = true; + this->pad_remaining_ = this->transfer_bytes_; + // loop() pads the last transfer and stops once the audio has played. + this->enable_loop(); +} + +void USBAudioSpeaker::stop_() { + this->finishing_ = false; + this->finish_after_start_ = false; + this->pad_remaining_ = 0; + if (this->state_ == speaker::STATE_STOPPED) { + return; + } + this->parent_->suspend(Endpoint::SPEAKER); + this->state_ = speaker::STATE_STOPPED; +} + +void USBAudioSpeaker::on_stream_state(StreamEvent event) { + if (event == StreamEvent::RUNNING) { + if (this->state_ == speaker::STATE_STARTING) { + this->state_ = speaker::STATE_RUNNING; + if (this->finish_after_start_) { + this->finish_after_start_ = false; + this->begin_finishing_(); + } + } + return; + } + // Playback stops on a lost device as well as a failed start; the next start() opens it again. + if (event == StreamEvent::LOST && this->state_ == speaker::STATE_RUNNING) { + ESP_LOGW(TAG, "USB device disconnected, stopping playback"); + } + this->stop_(); +} + +size_t USBAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + // 0 ticks is one non-blocking pass; portMAX_DELAY is capped by the configured write timeout. + const uint32_t budget_ms = ticks_to_wait == portMAX_DELAY ? this->write_timeout_ms_ : pdTICKS_TO_MS(ticks_to_wait); + return this->play_internal_(data, length, budget_ms); +} + +// Callers such as voice_assistant and rtttl call this from loop(), so it never blocks. +size_t USBAudioSpeaker::play(const uint8_t *data, size_t length) { return this->play(data, length, 0); } + +size_t USBAudioSpeaker::play_internal_(const uint8_t *data, size_t length, uint32_t budget_ms) { + if (this->audio_stream_info_ != this->usb_stream_info_) { + // Playing it through the configured stream would change the speed and channel layout. + if (!this->format_mismatch_.exchange(true)) { + const auto &info = this->audio_stream_info_; + ESP_LOGE(TAG, + "Audio format (%" PRIu32 " Hz, %u bits, %u channels) does not match the USB stream; use a resampler " + "speaker", + info.get_sample_rate(), info.get_bits_per_sample(), info.get_channels()); + } + if (budget_ms > 0) { + vTaskDelay(pdMS_TO_TICKS(budget_ms)); + } + return 0; + } + this->format_mismatch_ = false; + + if (this->state_ != speaker::STATE_RUNNING) { + this->start(); + // Wait out the budget so a pipeline task retrying play() does not spin. + if (budget_ms > 0) { + vTaskDelay(pdMS_TO_TICKS(budget_ms)); + } + return 0; + } + + // Counted before the hub's handle is read, so the hub never closes the device under this call. + this->writes_in_flight_.fetch_add(1); + + // Runs on the caller's task, so the cached loop time does not apply. + const uint32_t start_ms = millis(); + uint32_t now = start_ms; + size_t written = 0; + size_t timeout_retries = 0; + while (written < length) { + const uint32_t elapsed = now - start_ms; + if (budget_ms > 0 && elapsed >= budget_ms) { + break; + } + const size_t chunk = std::min({length - written, this->write_chunk_, this->ring_room_()}); + if (chunk == 0) { + // Back-pressure: wait for the driver to take a transfer. + if (budget_ms == 0) { + break; + } + vTaskDelay(1); + now = millis(); + continue; + } + const uint32_t timeout_ms = budget_ms > 0 ? budget_ms - elapsed : 0; + const esp_err_t err = this->write_(data + written, chunk, timeout_ms, false); + now = millis(); + if (err == ESP_OK) { + written += chunk; + timeout_retries = 0; + } else if (err == ESP_ERR_INVALID_STATE) { + // The device went away. + break; + } else if (err != ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "Write error: %s", esp_err_to_name(err)); + break; + } else if (budget_ms == 0 || ++timeout_retries >= MAX_TIMEOUT_RETRIES) { + // A full driver buffer is back-pressure; the caller retries. + break; + } + } + this->writes_in_flight_.fetch_sub(1); + return written; +} + +size_t USBAudioSpeaker::ring_room_() const { + // Whole frames up to buffer_size_. ring_bytes_ is never below the real fill, so a write of this much does not wait. + const uint32_t ring = this->ring_bytes_; + const uint32_t room = ring < this->buffer_size_ ? this->buffer_size_ - ring : 0; + return room - room % this->frame_bytes_; +} + +esp_err_t USBAudioSpeaker::write_(const uint8_t *data, size_t length, uint32_t timeout_ms, bool padding) { + // Counted first: the driver submits transfers inside the write, and on_transfer_taken() must see these bytes then. + // A failed write stores nothing, since the driver only writes whole chunks. + this->ring_bytes_.fetch_add(length); + if (padding) { + this->ring_padding_.fetch_add(length); + } + const esp_err_t err = this->parent_->write_speaker(data, length, timeout_ms); + if (err != ESP_OK) { + this->ring_bytes_.fetch_sub(length); + if (padding) { + this->ring_padding_.fetch_sub(length); + } + } + return err; +} + +void USBAudioSpeaker::pad_with_silence_() { + if (this->state_ != speaker::STATE_RUNNING) { + this->pad_remaining_ = 0; + return; + } + // Counted before the hub's handle is read, so the hub never closes the device under this call. + this->writes_in_flight_.fetch_add(1); + const size_t piece = sizeof(SILENCE) - sizeof(SILENCE) % this->usb_stream_info_.frames_to_bytes(1); + while (this->pad_remaining_ > 0) { + const size_t chunk = std::min({this->pad_remaining_, piece, this->ring_room_()}); + if (chunk == 0 || this->write_(SILENCE, chunk, 0, true) != ESP_OK) { + // A full buffer; the rest is written on a later loop(). + break; + } + this->pad_remaining_ -= chunk; + } + this->writes_in_flight_.fetch_sub(1); +} + +void USBAudioSpeaker::on_transfer_taken() { + bool report = false; + portENTER_CRITICAL(&this->taken_lock_); + // Mirror the driver: the next transfer is CONFIG_UAC_NUM_PACKETS_PER_URB packets, and the driver takes it only when + // the ring buffer holds all of it; otherwise it parks the transfer until the next write. + uint32_t accum = this->packet_frac_accum_; + uint32_t size = 0; + for (int i = 0; i < CONFIG_UAC_NUM_PACKETS_PER_URB; i++) { + accum += this->packet_frac_; + size += this->packet_bytes_; + if (accum >= 1000) { + size += this->frame_bytes_; + accum -= 1000; + } + } + const uint32_t ring = this->ring_bytes_; + if (ring >= size) { + this->packet_frac_accum_ = accum; + this->ring_bytes_.fetch_sub(size); + // Padding sits at the end of the ring buffer and is not audio from the caller, so it is not reported. + const uint32_t padding = std::min(this->ring_padding_, ring); + const uint32_t audio = std::min(size, ring - padding); + if (audio < size) { + this->ring_padding_.fetch_sub(std::min(size - audio, padding)); + } + const int64_t now_us = esp_timer_get_time(); + this->last_taken_us_ = static_cast(now_us); + // The bus plays one packet per millisecond, so while it streams a transfer finishes one transfer after the one + // taken before it, however late this callback runs. It cannot finish later than now plus the transfers in flight, + // so a callback that runs on time pulls back any lateness the chain picked up. When the bus is idle, after a start + // (the driver submits nothing until the first write) or once it ran dry, the transfer plays right away. + const int64_t finish_us = this->last_finish_us_ < now_us + ? now_us + this->transfer_us_ + : std::min(this->last_finish_us_ + this->transfer_us_, now_us + this->in_flight_us_); + this->last_finish_us_ = finish_us; + this->unreported_frames_ += this->usb_stream_info_.bytes_to_frames(audio); + // Batched like the I2S speaker's DMA buffers: about every 10 ms, and once the caller's audio runs out. + if (this->unreported_frames_ > 0 && (this->unreported_frames_ >= this->report_frames_ || audio == ring - padding)) { + this->report_frames_pending_ += this->unreported_frames_; + this->report_finish_us_ = finish_us; + this->unreported_frames_ = 0; + report = true; + } + } + portEXIT_CRITICAL(&this->taken_lock_); + if (report) { + // The listeners can block, so loop() calls them; the frames and their finish time travel together, so the delay + // does not change what they compute. + this->enable_loop_soon_any_context(); + } +} + +void USBAudioSpeaker::deliver_reports_() { + portENTER_CRITICAL(&this->taken_lock_); + const uint32_t frames = this->report_frames_pending_; + const int64_t finish_us = this->report_finish_us_; + this->report_frames_pending_ = 0; + portEXIT_CRITICAL(&this->taken_lock_); + if (frames > 0) { + this->audio_output_callback_(frames, finish_us); + } +} + +bool USBAudioSpeaker::has_buffered_data_(int64_t now_us) const { + if (this->state_ != speaker::STATE_RUNNING) { + return false; + } + // The driver still holds a whole transfer to take, or one it took has not finished playing. Less than a transfer + // stays in the ring buffer until the next write. + return this->ring_bytes_ >= this->transfer_bytes_ || + static_cast(static_cast(now_us) - this->last_taken_us_) < + static_cast(this->in_flight_us_); +} + +bool USBAudioSpeaker::has_buffered_data() const { return this->has_buffered_data_(esp_timer_get_time()); } + +// The hub's worker sends volume and mute to the device. +void USBAudioSpeaker::set_volume(float volume) { + speaker::Speaker::set_volume(volume); + this->parent_->set_speaker_volume_level(volume); +} + +void USBAudioSpeaker::set_mute_state(bool mute_state) { + speaker::Speaker::set_mute_state(mute_state); + this->parent_->set_speaker_mute_state(mute_state); +} + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/speaker/usb_audio_speaker.h b/esphome/components/usb_audio/speaker/usb_audio_speaker.h new file mode 100644 index 00000000000..a4edda7e0ea --- /dev/null +++ b/esphome/components/usb_audio/speaker/usb_audio_speaker.h @@ -0,0 +1,112 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_USB_AUDIO + +#include "esphome/components/usb_audio/usb_audio.h" + +#include "esphome/components/audio/audio.h" +#include "esphome/components/speaker/speaker.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::usb_audio { + +class USBAudioSpeaker : public speaker::Speaker, + public Component, + public Parented, + public USBAudioStream { + public: + USBAudioSpeaker(const audio::AudioStreamInfo &stream_info, uint32_t buffer_size, uint32_t write_timeout_ms) + : USBAudioStream(buffer_size), usb_stream_info_(stream_info), write_timeout_ms_(write_timeout_ms) { + this->audio_stream_info_ = stream_info; + } + + void setup() override; + void dump_config() override; + void loop() override; + + // Safe from any task; loop() acts on the request. + void start() override; + void stop() override; + void finish() override; + + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + size_t play(const uint8_t *data, size_t length) override; + bool has_buffered_data() const override; + + void set_volume(float volume) override; + void set_mute_state(bool mute_state) override; + + audio::AudioStreamInfo get_usb_stream_info() override { return this->usb_stream_info_; } + void on_stream_state(StreamEvent event) override; + bool stream_idle() const override { return this->writes_in_flight_ == 0; } + void on_transfer_taken() override; + + protected: + static constexpr uint8_t COMMAND_START = 1 << 0; + static constexpr uint8_t COMMAND_STOP = 1 << 1; + static constexpr uint8_t COMMAND_FINISH = 1 << 2; + void request_(uint8_t set, uint8_t clear); + void deliver_reports_(); + void begin_finishing_(); + void stop_(); + size_t play_internal_(const uint8_t *data, size_t length, uint32_t budget_ms); + /// Write silence until a whole USB transfer is padded, so the driver sends a final partial transfer. + void pad_with_silence_(); + /// Write to the driver, counting the bytes in ring_bytes_ (and in ring_padding_ for silence padding). + esp_err_t write_(const uint8_t *data, size_t length, uint32_t timeout_ms, bool padding); + /// Bytes that can be written without filling the driver's ring buffer past buffer_size_. + size_t ring_room_() const; + bool has_buffered_data_(int64_t now_us) const; + + // The configured format the USB stream runs with. Callers such as the media pipeline set audio_stream_info_ for + // each item, so play() checks it against this instead of reopening the stream with another format. + const audio::AudioStreamInfo usb_stream_info_; + uint32_t write_timeout_ms_; + // Frame-aligned size of each driver write, set in setup(). + uint32_t write_chunk_{0}; + // The driver's packet sizing, mirrored so on_transfer_taken() knows how much each transfer takes: whole frames per + // 1 ms packet, plus a frame whenever the remainder of sample_rate / 1000 adds up. Set in setup(). + uint32_t frame_bytes_{0}; + uint32_t packet_bytes_{0}; + uint32_t packet_frac_{0}; + // Bytes in the largest USB transfer, how long one transfer plays and how long all the driver keeps in flight play, + // and the batch of frames reported at a time; set in setup(). + uint32_t transfer_bytes_{0}; + uint32_t transfer_us_{0}; + uint32_t in_flight_us_{0}; + uint32_t report_frames_{0}; + // Silence still to write while finishing. + uint32_t pad_remaining_{0}; + // Bytes in the driver's ring buffer: added before each write, since the driver can take a transfer inside it, and + // taken off for each transfer it takes. ring_padding_ is the silence padding at its end. + std::atomic ring_bytes_{0}; + std::atomic ring_padding_{0}; + // Low 32 bits of esp_timer_get_time() when the driver last took a transfer. + std::atomic last_taken_us_{0}; + // on_transfer_taken() runs on the USB driver's task, which must not block, and inside writes, so its state is + // guarded by a spinlock and the reports it collects are delivered from loop(). Reset while the stream is stopped. + portMUX_TYPE taken_lock_ = portMUX_INITIALIZER_UNLOCKED; + uint32_t packet_frac_accum_{0}; + uint32_t unreported_frames_{0}; + uint32_t report_frames_pending_{0}; + int64_t report_finish_us_{0}; + // When the last transfer taken finishes playing; reset on a start, so the next one starts the chain from now. + int64_t last_finish_us_{0}; + std::atomic commands_{0}; + // play() calls and silence padding that may be using the device handle; the hub closes a lost device only at zero. + std::atomic writes_in_flight_{0}; + // Set while play() rejects audio in a format other than usb_stream_info_, so the error is logged once. + std::atomic format_mismatch_{false}; + bool finishing_{false}; + // finish() arrived while the stream was still starting. + bool finish_after_start_{false}; +}; + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/usb_audio.cpp b/esphome/components/usb_audio/usb_audio.cpp new file mode 100644 index 00000000000..ec9537e1dce --- /dev/null +++ b/esphome/components/usb_audio/usb_audio.cpp @@ -0,0 +1,876 @@ +#include "usb_audio.h" + +#ifdef USE_USB_AUDIO + +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +extern "C" { +#include "esp_err.h" +} + +namespace esphome::usb_audio { + +ESPHOME_LOG_TAG(TAG, "usb_audio"); + +// The driver task completes and resubmits the speaker's transfers, with only CONFIG_UAC_NUM_ISOC_URBS of them +// queued on the bus, so it runs above the network stack like the I2S speaker task; each wakeup is short. +static constexpr UBaseType_t UAC_TASK_PRIORITY = 19; +static constexpr uint32_t UAC_TASK_STACK_SIZE = 4096; +// Below the driver's task, so its transfers are not held up by control requests. +static constexpr UBaseType_t WORKER_TASK_PRIORITY = 5; +static constexpr uint32_t WORKER_TASK_STACK_SIZE = 4096; +// The worker retries volume and mute up to this many times, this far apart. +static constexpr uint8_t MAX_CONTROL_ATTEMPTS = 5; +static constexpr uint32_t CONTROL_RETRY_MS = 100; +// A failed close is retried up to this many times, this far apart, before the handle is given up. +static constexpr uint8_t MAX_CLOSE_ATTEMPTS = 5; +static constexpr uint32_t CLOSE_RETRY_MS = 500; + +static const LogString *endpoint_name(Endpoint endpoint) { + return endpoint == Endpoint::SPEAKER ? LOG_STR("Speaker") : LOG_STR("Microphone"); +} + +static uint8_t discrete_rate_count(const uac_host_dev_alt_param_t &alt) { + return std::min(alt.sample_freq_type, UAC_FREQ_NUM_MAX); +} + +static bool alt_supports_rate(const uac_host_dev_alt_param_t &alt, uint32_t sample_rate) { + if (alt.sample_freq_type == 0) { + return sample_rate >= alt.sample_freq_lower && sample_rate <= alt.sample_freq_upper; + } + const uint8_t count = discrete_rate_count(alt); + return std::find(alt.sample_freq, alt.sample_freq + count, sample_rate) != alt.sample_freq + count; +} + +void USBAudioComponent::setup() { + this->event_queue_ = xQueueCreateStatic(EVENT_QUEUE_LENGTH, sizeof(USBAudioEvent), this->event_queue_storage_.data(), + &this->event_queue_struct_); + this->op_queue_ = + xQueueCreateStatic(OP_QUEUE_LENGTH, sizeof(Op), this->op_queue_storage_.data(), &this->op_queue_struct_); + + uac_host_driver_config_t uac_cfg = { + .create_background_task = true, + .task_priority = UAC_TASK_PRIORITY, + .stack_size = UAC_TASK_STACK_SIZE, + .core_id = 0, + .callback = &USBAudioComponent::driver_event_stub, + .callback_arg = this, + }; + + esp_err_t err = uac_host_install(&uac_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "uac_host_install failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + this->uac_installed_ = true; + + // Opening, starting, suspending and closing streams and setting volume are control transfers that can block for + // seconds, so they run here instead of on the main loop. + if (!this->worker_.create(&USBAudioComponent::worker_task, "usb_audio", WORKER_TASK_STACK_SIZE, this, + WORKER_TASK_PRIORITY, false)) { + this->mark_failed(LOG_STR("Failed to create worker task")); + return; + } + // The USB callbacks and the worker wake the loop when there is work. + this->disable_loop(); +} + +void USBAudioComponent::loop() { + this->process_events_(App.get_loop_component_start_time()); + + if (const uint32_t errors = this->transfer_errors_.exchange(0); errors != 0) { + ESP_LOGW(TAG, "%" PRIu32 " USB transfer errors", errors); + } + + // Stay awake while a lost device still waits to be closed. + if (std::any_of(this->endpoints_.begin(), this->endpoints_.end(), + [](const EndpointState &ep) { return ep.closing != nullptr; })) { + return; + } + this->disable_loop(); +} + +void USBAudioComponent::process_events_(uint32_t now) { + this->finish_closes_(now); + + USBAudioEvent event; + // Only the events already queued: one posted again while this runs waits for the next loop(). + for (UBaseType_t queued = uxQueueMessagesWaiting(this->event_queue_); + queued > 0 && xQueueReceive(this->event_queue_, &event, 0) == pdTRUE; queued--) { + this->process_event_(event, now); + } +} + +void USBAudioComponent::dump_config() { + const auto &speaker = this->endpoint_(Endpoint::SPEAKER); + const auto µphone = this->endpoint_(Endpoint::MICROPHONE); + ESP_LOGCONFIG(TAG, + "USB Audio:\n" + " Device connected: %s\n" + " Speaker registered: %s\n" + " Microphone registered: %s", + YESNO(speaker.handle.load() != nullptr || microphone.handle.load() != nullptr), + YESNO(speaker.platform != nullptr), YESNO(microphone.platform != nullptr)); +} + +bool USBAudioComponent::teardown() { + if (this->event_queue_ == nullptr) { + return true; + } + // Stop the platforms first, then let the worker close each device once its platform is idle. + this->begin_close_(Endpoint::SPEAKER); + this->begin_close_(Endpoint::MICROPHONE); + // The loop time does not advance while components tear down. + const uint32_t now = millis(); + this->process_events_(now); + if (!this->finish_closes_(now)) { + return false; + } + // Wait for the worker to finish whatever it was still running. + if (uxQueueMessagesWaiting(this->op_queue_) != 0 || this->driver_busy_) { + return false; + } + xQueueReset(this->event_queue_); + + if (this->uac_installed_) { + esp_err_t err = uac_host_uninstall(); + if (err == ESP_ERR_INVALID_STATE) { + return false; + } + if (err != ESP_OK) { + ESP_LOGW(TAG, "uac_host_uninstall returned %s", esp_err_to_name(err)); + } + this->uac_installed_ = false; + } + return true; +} + +void USBAudioComponent::resume(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + ep.active = true; + ep.request_seq++; + const uac_host_device_handle_t handle = ep.handle; + if (handle == nullptr) { + if (ep.open_failed && !ep.open_pending) { + // The device is still plugged in but its last open failed; try again for this start. + ep.open_failed = false; + this->open_stream_(endpoint, ep.failed_addr, ep.failed_iface); + return; + } + ESP_LOGD(TAG, "%s interface not connected yet", LOG_STR_ARG(endpoint_name(endpoint))); + return; + } + if (ep.start_pending) { + // The pending start reports back to the platform, and checks `active` first. + return; + } + if (!ep.stream_started) { + if (ep.start_rejected) { + // Already probed and logged for this device; a retrying caller must not flood the log. + this->stream_failed_(endpoint); + return; + } + this->start_stream_(endpoint); + return; + } + // Queued even while an earlier resume or suspend is pending: the worker runs them in order, so the driver ends in + // the latest requested state, and only this request's RESUMED reports back. + Op op; + op.type = Op::Type::RESUME; + op.endpoint = endpoint; + op.handle = handle; + op.seq = ep.request_seq; + if (!this->queue_op_(op)) { + this->stream_failed_(endpoint); + } +} + +void USBAudioComponent::suspend(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + ep.active = false; + ep.request_seq++; + const uac_host_device_handle_t handle = ep.handle; + // A start still running on the worker is suspended once its STARTED arrives. + if (!ep.stream_started || handle == nullptr) { + return; + } + Op op; + op.type = Op::Type::SUSPEND; + op.endpoint = endpoint; + op.handle = handle; + this->queue_op_(op); +} + +bool USBAudioComponent::enter_driver_(uint32_t &timeout_ms) { + const uint32_t start = millis(); + while (true) { + // Counted before the busy check, so the worker waits for this call to leave the driver before it takes it. + this->driver_users_.fetch_add(1); + if (!this->driver_busy_) { + const uint32_t waited = millis() - start; + timeout_ms = waited < timeout_ms ? timeout_ms - waited : 0; + return true; + } + this->driver_users_.fetch_sub(1); + // A control request takes a few milliseconds, so go on as soon as the worker is done with it. + if (millis() - start >= timeout_ms) { + return false; + } + vTaskDelay(1); + } +} + +esp_err_t USBAudioComponent::read_microphone(uint8_t *buffer, size_t size, size_t *bytes_read, uint32_t timeout_ms) { + *bytes_read = 0; + if (!this->enter_driver_(timeout_ms)) { + return ESP_ERR_TIMEOUT; + } + uac_host_device_handle_t handle = this->endpoint_(Endpoint::MICROPHONE).handle; + if (handle == nullptr) { + this->driver_users_.fetch_sub(1); + return ESP_ERR_INVALID_STATE; + } + // The driver collects up to `size` bytes for the whole timeout and returns what it got, or ESP_ERR_TIMEOUT when + // nothing arrived. + uint32_t actual = 0; + esp_err_t err = uac_host_device_read(handle, buffer, static_cast(size), &actual, pdMS_TO_TICKS(timeout_ms)); + this->driver_users_.fetch_sub(1); + *bytes_read = actual; + return err; +} + +esp_err_t USBAudioComponent::write_speaker(const uint8_t *data, size_t length, uint32_t timeout_ms) { + if (!this->enter_driver_(timeout_ms)) { + // A non-blocking caller retries later. + return ESP_ERR_TIMEOUT; + } + uac_host_device_handle_t handle = this->endpoint_(Endpoint::SPEAKER).handle; + if (handle == nullptr) { + this->driver_users_.fetch_sub(1); + return ESP_ERR_INVALID_STATE; + } + esp_err_t err = uac_host_device_write(handle, const_cast(data), static_cast(length), + pdMS_TO_TICKS(timeout_ms)); + this->driver_users_.fetch_sub(1); + if (err == ESP_FAIL || err == ESP_ERR_INVALID_STATE) { + // Ring buffer full or stream inactive; report as timeout so callers back off and retry. + err = ESP_ERR_TIMEOUT; + } + return err; +} + +void USBAudioComponent::process_event_(const USBAudioEvent &event, uint32_t now) { + switch (event.type) { + case USBAudioEvent::Type::CONNECTED: { + auto &ep = this->endpoint_(event.endpoint); + if (ep.closing != nullptr) { + // A device that came back waits until its old handle is closed; the queue keeps draining meanwhile, so the + // worker's CLOSED can arrive. + ep.connect_deferred = true; + ep.deferred_addr = event.addr; + ep.deferred_iface = event.iface_num; + break; + } + this->open_stream_(event.endpoint, event.addr, event.iface_num); + break; + } + case USBAudioEvent::Type::DISCONNECTED: + this->handle_disconnect_(event.handle); + break; + case USBAudioEvent::Type::OPENED: + this->on_opened_(event); + break; + case USBAudioEvent::Type::STARTED: + this->on_started_(event); + break; + case USBAudioEvent::Type::RESUMED: + this->on_resumed_(event); + break; + case USBAudioEvent::Type::CLOSED: + this->on_closed_(event, now); + break; + } +} + +void USBAudioComponent::open_stream_(Endpoint endpoint, uint8_t addr, uint8_t iface_num) { + auto &ep = this->endpoint_(endpoint); + const LogString *name = endpoint_name(endpoint); + if (ep.platform == nullptr) { + ESP_LOGD(TAG, "%s interface connected but not configured", LOG_STR_ARG(name)); + return; + } + if (ep.open_pending) { + // Either a duplicate, or a replacement for a device that went away during the pending open; on_opened_() tells + // them apart and replays a replacement. + ep.connect_deferred = true; + ep.deferred_addr = addr; + ep.deferred_iface = iface_num; + return; + } + if (ep.handle.load() != nullptr) { + ESP_LOGW(TAG, "%s interface already open, ignoring duplicate event", LOG_STR_ARG(name)); + return; + } + Op op; + op.type = Op::Type::OPEN; + op.endpoint = endpoint; + op.addr = addr; + op.iface_num = iface_num; + if (!this->queue_op_(op)) { + // The event is already off the queue, so post it again for a later loop() instead of losing the device. + USBAudioEvent event; + event.type = USBAudioEvent::Type::CONNECTED; + event.endpoint = endpoint; + event.addr = addr; + event.iface_num = iface_num; + this->post_event_(event); + return; + } + ep.open_pending = true; +} + +void USBAudioComponent::on_opened_(const USBAudioEvent &event) { + auto &ep = this->endpoint_(event.endpoint); + ep.open_pending = false; + bool lost_meanwhile = false; + for (auto &lost : this->lost_while_opening_) { + if (event.ok && lost == event.handle) { + lost = nullptr; + lost_meanwhile = true; + } + } + if (std::none_of(this->endpoints_.begin(), this->endpoints_.end(), + [](const EndpointState &other) { return other.open_pending; })) { + // Only a pending open can still claim an entry; a failed one reports no handle, so its entry would stay behind + // and could match a later device that reuses the freed handle. + this->lost_while_opening_.fill(nullptr); + } + if (!event.ok) { + if (ep.connect_deferred) { + // A newer connect arrived during the failed open; try that one. + ep.connect_deferred = false; + this->open_stream_(event.endpoint, ep.deferred_addr, ep.deferred_iface); + return; + } + // Keep where the device is, so the next start() retries the open without a replug. + ep.open_failed = true; + ep.failed_addr = event.addr; + ep.failed_iface = event.iface_num; + if (ep.active) { + this->stream_failed_(event.endpoint); + } + return; + } + ep.open_failed = false; + ep.handle = event.handle; + ep.iface = event.iface_num; + ESP_LOGI(TAG, "%s interface %u connected (addr=%u)", LOG_STR_ARG(endpoint_name(event.endpoint)), ep.iface, + event.addr); + if (lost_meanwhile) { + // It went away while the worker was opening it; a connect deferred meanwhile is opened once it is closed. + this->handle_disconnect_(event.handle); + return; + } + // The device that was being opened is still here, so a connect deferred meanwhile was a duplicate. + ep.connect_deferred = false; + if (ep.active) { + this->start_stream_(event.endpoint); + } +} + +void USBAudioComponent::start_stream_(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + Op op; + op.type = Op::Type::START; + op.endpoint = endpoint; + op.handle = ep.handle; + if (!this->queue_op_(op)) { + this->stream_failed_(endpoint); + return; + } + ep.start_pending = true; +} + +void USBAudioComponent::on_started_(const USBAudioEvent &event) { + auto &ep = this->endpoint_(event.endpoint); + ep.start_pending = false; + if (event.handle != ep.handle.load()) { + // The device went away meanwhile; the platform was already told. + return; + } + if (!event.ok) { + ep.start_rejected = event.rejected; + if (ep.active) { + this->stream_failed_(event.endpoint); + } + return; + } + ep.stream_started = true; + if (event.endpoint == Endpoint::SPEAKER) { + // Send the current volume and mute to the newly started stream. + this->volume_.pending = true; + this->mute_.pending = true; + this->queue_controls_(); + } + if (!ep.active) { + // The platform stopped while the stream was starting. + this->suspend(event.endpoint); + return; + } + ep.platform->on_stream_state(StreamEvent::RUNNING); +} + +void USBAudioComponent::on_resumed_(const USBAudioEvent &event) { + auto &ep = this->endpoint_(event.endpoint); + if (event.handle != ep.handle.load() || event.seq != ep.request_seq || !ep.active) { + // The device went away, or a later suspend or resume is queued behind this one and decides the state. + return; + } + if (!event.ok) { + this->stream_failed_(event.endpoint); + return; + } + ep.platform->on_stream_state(StreamEvent::RUNNING); +} + +void USBAudioComponent::stream_failed_(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + // The platform goes back to stopped, so a reconnect must not restart the stream; its next start() retries. + ep.active = false; + ep.platform->on_stream_state(StreamEvent::FAILED); +} + +void USBAudioComponent::set_speaker_volume_level(float volume) { + this->speaker_volume_ = static_cast(std::lround(std::clamp(volume, 0.0f, 1.0f) * 100.0f)); + this->volume_.pending = true; + this->queue_controls_(); +} + +void USBAudioComponent::set_speaker_mute_state(bool mute_state) { + this->speaker_muted_ = mute_state; + this->mute_.pending = true; + this->queue_controls_(); +} + +void USBAudioComponent::queue_controls_() { + if (this->op_queue_ == nullptr) { + return; + } + if (this->controls_queued_.exchange(true)) { + // The queued op sends the latest values, so lifecycle ops keep their queue slots. + return; + } + Op op; + op.type = Op::Type::CONTROLS; + if (xQueueSend(this->op_queue_, &op, 0) != pdTRUE) { + // The controls stay pending and the next start or change queues them again. + this->controls_queued_ = false; + } +} + +void USBAudioComponent::handle_disconnect_(uac_host_device_handle_t handle) { + for (size_t i = 0; i < this->endpoints_.size(); i++) { + auto &ep = this->endpoints_[i]; + if (handle != nullptr && ep.handle.load() == handle) { + const auto endpoint = static_cast(i); + ESP_LOGI(TAG, "%s interface %u disconnected", LOG_STR_ARG(endpoint_name(endpoint)), ep.iface); + this->begin_close_(endpoint); + return; + } + } + if (std::any_of(this->endpoints_.begin(), this->endpoints_.end(), + [](const EndpointState &ep) { return ep.open_pending; })) { + // Its open is still running on the worker; close it once it reports back. + for (auto &lost : this->lost_while_opening_) { + if (lost == nullptr || lost == handle) { + lost = handle; + return; + } + } + // Both endpoints are already waiting on a lost open; the oldest one is the least likely to still report back. + this->lost_while_opening_[0] = handle; + return; + } + ESP_LOGD(TAG, "Disconnect event for an interface with no active stream"); +} + +void USBAudioComponent::begin_close_(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + // Platform reads and writes see nullptr from here on and return at once. + const uac_host_device_handle_t handle = ep.handle.exchange(nullptr); + if (handle == nullptr) { + return; + } + ep.closing = handle; + ep.close_queued = false; + // `active` is kept, so the stream restarts when the device returns. + ep.platform->on_stream_state(StreamEvent::LOST); +} + +bool USBAudioComponent::finish_closes_(uint32_t now) { + bool done = true; + for (size_t i = 0; i < this->endpoints_.size(); i++) { + auto &ep = this->endpoints_[i]; + if (ep.closing == nullptr) { + continue; + } + done = false; + // Closing frees the driver's interface, so wait until no platform task can still be using the handle. + if (ep.close_queued || !ep.platform->stream_idle()) { + continue; + } + if (ep.close_attempts > 0 && static_cast(now - ep.close_retry_ms) < 0) { + // A failed close waits a moment before it is retried. + continue; + } + Op op; + op.type = Op::Type::CLOSE; + op.endpoint = static_cast(i); + op.handle = ep.closing; + op.stop = ep.stream_started; + ep.close_queued = this->queue_op_(op); + } + return done; +} + +void USBAudioComponent::on_closed_(const USBAudioEvent &event, uint32_t now) { + auto &ep = this->endpoint_(event.endpoint); + ep.close_queued = false; + if (!event.ok && ++ep.close_attempts < MAX_CLOSE_ATTEMPTS) { + // Keep the handle, so finish_closes_() retries the close. + ep.close_retry_ms = now + CLOSE_RETRY_MS; + return; + } + if (!event.ok) { + ESP_LOGE(TAG, "Giving up closing the %s interface after %u attempts", LOG_STR_ARG(endpoint_name(event.endpoint)), + ep.close_attempts); + } + ep.close_attempts = 0; + ep.closing = nullptr; + ep.stream_started = false; + ep.start_rejected = false; + ep.start_pending = false; + if (ep.connect_deferred) { + // The device came back while its old handle was closing. + ep.connect_deferred = false; + this->open_stream_(event.endpoint, ep.deferred_addr, ep.deferred_iface); + } +} + +bool USBAudioComponent::queue_op_(const Op &op) { + if (xQueueSend(this->op_queue_, &op, 0) != pdTRUE) { + ESP_LOGW(TAG, "USB audio work queue full, dropping %s request", LOG_STR_ARG(endpoint_name(op.endpoint))); + return false; + } + return true; +} + +void USBAudioComponent::post_event_(const USBAudioEvent &event) { + if (xQueueSend(this->event_queue_, &event, 0) != pdTRUE) { + ESP_LOGW(TAG, "USB audio event queue full, dropping event"); + } + this->enable_loop_soon_any_context(); +} + +void USBAudioComponent::worker_task(void *param) { + auto *self = static_cast(param); + Op op; + while (true) { + if (xQueueReceive(self->op_queue_, &op, portMAX_DELAY) != pdTRUE) { + continue; + } + self->lock_driver_(); + self->run_op_(op); + self->unlock_driver_(); + } +} + +void USBAudioComponent::lock_driver_() { + this->driver_busy_ = true; + // Reads and writes give up within their timeout; wait for those already inside the driver. + while (this->driver_users_ != 0) { + vTaskDelay(1); + } +} + +void USBAudioComponent::unlock_driver_() { this->driver_busy_ = false; } + +void USBAudioComponent::run_op_(const Op &op) { + const LogString *name = endpoint_name(op.endpoint); + USBAudioEvent result; + result.endpoint = op.endpoint; + result.handle = op.handle; + + switch (op.type) { + case Op::Type::OPEN: { + uint32_t buffer_size = this->endpoint_(op.endpoint).platform->get_buffer_size(); + uint32_t threshold = std::max(buffer_size / 4, 1U); + if (op.endpoint == Endpoint::SPEAKER) { + // The driver raises TX_DONE after taking a transfer only while the ring buffer holds at most the threshold, + // and it needs the threshold below the size. The speaker never fills past its buffer_size, so it hears about + // every transfer even when a waiting write lands right after one. + threshold = buffer_size; + buffer_size += SPEAKER_RING_SLACK; + } + uac_host_device_config_t dev_cfg = { + .addr = op.addr, + .iface_num = op.iface_num, + .buffer_size = buffer_size, + .buffer_threshold = threshold, + .callback = &USBAudioComponent::device_event_stub, + .callback_arg = this, + }; + uac_host_device_handle_t handle{nullptr}; + esp_err_t err = uac_host_device_open(&dev_cfg, &handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to open %s interface: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + result.type = USBAudioEvent::Type::OPENED; + result.ok = err == ESP_OK; + result.handle = handle; + result.addr = op.addr; + result.iface_num = op.iface_num; + this->post_event_(result); + return; + } + case Op::Type::START: { + result.type = USBAudioEvent::Type::STARTED; + uac_host_stream_config_t stm_cfg{}; + if (this->populate_stream_config_(op.endpoint, op.handle, stm_cfg, result.rejected)) { + esp_err_t err = uac_host_device_start(op.handle, &stm_cfg); + // Already started or active is ESP_OK; ESP_ERR_INVALID_STATE means the interface is not ready. + result.ok = err == ESP_OK; + // The host cannot allocate the endpoint's pipe, for example when its max packet size exceeds the USB FIFO. + result.rejected = err == ESP_ERR_NOT_SUPPORTED; + if (!result.ok) { + ESP_LOGW(TAG, "Failed to start %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + } + this->post_event_(result); + return; + } + case Op::Type::RESUME: { + esp_err_t err = uac_host_device_resume(op.handle); + result.type = USBAudioEvent::Type::RESUMED; + result.seq = op.seq; + // Already started or active is ESP_OK; ESP_ERR_INVALID_STATE means the interface is not ready. + result.ok = err == ESP_OK; + if (!result.ok) { + ESP_LOGW(TAG, "Failed to resume %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + this->post_event_(result); + return; + } + case Op::Type::SUSPEND: { + esp_err_t err = uac_host_device_suspend(op.handle); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "Failed to suspend %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + return; + } + case Op::Type::CLOSE: { + if (op.stop) { + esp_err_t err = uac_host_device_stop(op.handle); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "Failed to stop %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + } + esp_err_t err = uac_host_device_close(op.handle); + result.type = USBAudioEvent::Type::CLOSED; + result.ok = err == ESP_OK; + if (!result.ok) { + // The interface may still be held, so the main loop keeps the handle and retries. + ESP_LOGW(TAG, "uac_host_device_close failed: %s", esp_err_to_name(err)); + this->post_event_(result); + return; + } + if (op.endpoint == Endpoint::SPEAKER) { + // A reconnected device may support different controls. + for (ControlState *control : {&this->volume_, &this->mute_}) { + control->supported = true; + control->attempts = 0; + } + } + this->post_event_(result); + return; + } + case Op::Type::CONTROLS: { + // Cleared first, so a change made during the transfer queues another op. + this->controls_queued_ = false; + if (this->apply_pending_speaker_controls_()) { + // Give the device a moment before retrying; the queue keeps any other work in order. + this->unlock_driver_(); + vTaskDelay(pdMS_TO_TICKS(CONTROL_RETRY_MS)); + this->lock_driver_(); + this->queue_controls_(); + } + return; + } + } +} + +bool USBAudioComponent::populate_stream_config_(Endpoint endpoint, uac_host_device_handle_t handle, + uac_host_stream_config_t &stream_config, bool &rejected) { + const LogString *name = endpoint_name(endpoint); + const audio::AudioStreamInfo info = this->endpoint_(endpoint).platform->get_usb_stream_info(); + const uint8_t channels = info.get_channels(); + const uint8_t bits = info.get_bits_per_sample(); + const uint32_t sample_rate = info.get_sample_rate(); + + uac_host_dev_info_t dev_info{}; + esp_err_t err = uac_host_get_device_info(handle, &dev_info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to query %s interface info: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + return false; + } + + // uac_host_device_start() picks the alternate setting itself from channels, bit depth and rate: the last match with + // discrete rates, or the first match with a rate range. Find the one it will pick, so its subframe size is checked. + uint8_t selected_alt = 0; + uint8_t selected_subframe = 0; + for (uint8_t alt = 1; alt <= dev_info.iface_alt_num; alt++) { + uac_host_dev_alt_param_t alt_param{}; + if (uac_host_get_device_alt_param(handle, alt, &alt_param) != ESP_OK || alt_param.channels != channels || + alt_param.bit_resolution != bits || !alt_supports_rate(alt_param, sample_rate)) { + continue; + } + selected_alt = alt; + selected_subframe = alt_param.subframe_size; + if (alt_param.sample_freq_type == 0) { + break; + } + } + + // ESPHome's samples are packed, so a device that pads them (e.g. 24-bit samples in 4-byte subframes) cannot be + // streamed unchanged. + if (selected_alt != 0 && selected_subframe * 8 == bits) { + stream_config.channels = channels; + stream_config.bit_resolution = bits; + stream_config.sample_freq = sample_rate; + stream_config.flags = 0; + return true; + } + + rejected = true; + if (selected_alt != 0) { + ESP_LOGW(TAG, "%s interface %u would stream alt %u with %u-bit samples padded to %u bytes, which is not supported", + LOG_STR_ARG(name), dev_info.iface_num, selected_alt, bits, selected_subframe); + } + ESP_LOGW(TAG, "%s interface %u does not support requested format (channels=%u bits=%u sample_rate=%" PRIu32 " Hz)", + LOG_STR_ARG(name), dev_info.iface_num, channels, bits, sample_rate); + for (uint8_t alt = 1; alt <= dev_info.iface_alt_num; alt++) { + uac_host_dev_alt_param_t alt_param{}; + if (uac_host_get_device_alt_param(handle, alt, &alt_param) != ESP_OK) { + continue; + } + if (alt_param.sample_freq_type == 0) { + ESP_LOGI(TAG, " alt %u: channels=%u bits=%u subframe=%u bytes sample rate range=%" PRIu32 "-%" PRIu32 " Hz", alt, + alt_param.channels, alt_param.bit_resolution, alt_param.subframe_size, alt_param.sample_freq_lower, + alt_param.sample_freq_upper); + continue; + } + ESP_LOGI(TAG, " alt %u: channels=%u bits=%u subframe=%u bytes sample rates:", alt, alt_param.channels, + alt_param.bit_resolution, alt_param.subframe_size); + const uint8_t count = discrete_rate_count(alt_param); + for (uint8_t i = 0; i < count; i++) { + // Some devices list a rate more than once. + if (std::find(alt_param.sample_freq, alt_param.sample_freq + i, alt_param.sample_freq[i]) == + alt_param.sample_freq + i) { + ESP_LOGI(TAG, " %" PRIu32 " Hz", alt_param.sample_freq[i]); + } + } + } + return false; +} + +bool USBAudioComponent::apply_pending_speaker_controls_() { + const uac_host_device_handle_t handle = this->endpoint_(Endpoint::SPEAKER).handle; + if (handle == nullptr) { + // Sent once the stream starts. + return false; + } + bool retry = false; + if (this->volume_.pending.exchange(false) && this->volume_.supported) { + retry |= this->apply_control_(this->volume_, uac_host_device_set_volume(handle, this->speaker_volume_), + LOG_STR("volume")); + } + if (this->mute_.pending.exchange(false) && this->mute_.supported) { + retry |= this->apply_control_(this->mute_, uac_host_device_set_mute(handle, this->speaker_muted_), LOG_STR("mute")); + } + return retry; +} + +bool USBAudioComponent::apply_control_(ControlState &control, esp_err_t err, const LogString *name) { + if (err == ESP_OK) { + control.attempts = 0; + return false; + } + if (err == ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, "Speaker %s control not supported by device", LOG_STR_ARG(name)); + control.supported = false; + return false; + } + if (err == ESP_ERR_INVALID_STATE) { + // The stream is not started yet; starting it queues the controls again. + control.pending = true; + return false; + } + if (++control.attempts >= MAX_CONTROL_ATTEMPTS) { + ESP_LOGW(TAG, "Failed to set speaker %s: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + control.attempts = 0; + return false; + } + control.pending = true; + return true; +} + +void USBAudioComponent::driver_event_stub(uint8_t addr, uint8_t iface_num, const uac_host_driver_event_t event, + void *user_data) { + auto *self = static_cast(user_data); + USBAudioEvent evt; + switch (event) { + case UAC_HOST_DRIVER_EVENT_TX_CONNECTED: + evt.endpoint = Endpoint::SPEAKER; + break; + case UAC_HOST_DRIVER_EVENT_RX_CONNECTED: + evt.endpoint = Endpoint::MICROPHONE; + break; + default: + return; + } + evt.type = USBAudioEvent::Type::CONNECTED; + evt.addr = addr; + evt.iface_num = iface_num; + self->post_event_(evt); +} + +void USBAudioComponent::device_event_stub(uac_host_device_handle_t handle, const uac_host_device_event_t event, + void *user_data) { + auto *self = static_cast(user_data); + if (event == UAC_HOST_DEVICE_EVENT_TX_DONE) { + auto &speaker = self->endpoint_(Endpoint::SPEAKER); + // Only the speaker's own interface; the handle is cleared first when the device goes away. + if (speaker.platform != nullptr && handle == speaker.handle.load()) { + speaker.platform->on_transfer_taken(); + } + } else if (event == UAC_HOST_DEVICE_EVENT_TRANSFER_ERROR) { + self->transfer_errors_.fetch_add(1, std::memory_order_relaxed); + self->enable_loop_soon_any_context(); + } else if (event == UAC_HOST_DRIVER_EVENT_DISCONNECTED) { + USBAudioEvent evt; + evt.type = USBAudioEvent::Type::DISCONNECTED; + evt.handle = handle; + self->post_event_(evt); + } +} + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/usb_audio.h b/esphome/components/usb_audio/usb_audio.h new file mode 100644 index 00000000000..a283fb18f2e --- /dev/null +++ b/esphome/components/usb_audio/usb_audio.h @@ -0,0 +1,239 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_USB_AUDIO + +#include +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/core/component.h" +#include "esphome/core/static_task.h" + +extern "C" { +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "usb/uac_host.h" +} + +namespace esphome::usb_audio { + +enum class Endpoint : uint8_t { + SPEAKER, + MICROPHONE, +}; + +/// What the hub reports to a platform about its stream. +enum class StreamEvent : uint8_t { + // The stream runs. + RUNNING, + // The device went away; the hub restarts the stream when it returns while the platform still wants it. + LOST, + // The stream failed to start; the platform goes back to stopped and its next start() retries. + FAILED, +}; + +/// A speaker or microphone platform that streams through the hub. The hub calls it on the main loop only. +class USBAudioStream { + public: + explicit USBAudioStream(uint32_t buffer_size) : buffer_size_(buffer_size) {} + + uint32_t get_buffer_size() const { return this->buffer_size_; } + /// The fixed format the hub opens the stream with. + virtual audio::AudioStreamInfo get_usb_stream_info() = 0; + virtual void on_stream_state(StreamEvent event) = 0; + /// No platform task is inside a read or write call on the device; the hub closes a lost device only then. + virtual bool stream_idle() const = 0; + /// Speaker only: the driver tried to take its next transfer from the ring buffer, which it does each time a transfer + /// completes and on each write, and takes it only when the ring buffer holds all of it. Called on the USB driver's + /// task or inside a write. + virtual void on_transfer_taken() {} + + protected: + uint32_t buffer_size_; +}; + +/// Posted to the main loop, by the USB driver task (CONNECTED, DISCONNECTED) or the hub's worker (the results). +struct USBAudioEvent { + enum class Type : uint8_t { + CONNECTED, + // The driver reports disconnects through the device callback, not the driver callback. + DISCONNECTED, + OPENED, + STARTED, + RESUMED, + CLOSED, + }; + + uac_host_device_handle_t handle{nullptr}; + uint8_t addr{0}; + uint8_t iface_num{0}; + // RESUMED: the request it answers; only the latest request's result is acted on. + uint8_t seq{0}; + Type type{Type::CONNECTED}; + Endpoint endpoint{Endpoint::SPEAKER}; + bool ok{false}; + // STARTED: the start will fail the same way until the device reconnects: the device does not support the configured + // format, or the host cannot allocate its pipe. + bool rejected{false}; +}; + +class USBAudioComponent : public Component { + public: + void setup() override; + void dump_config() override; + void loop() override; + // Just after usb_host, which installs the USB host library that uac_host_install needs. + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } + bool teardown() override; + + void register_speaker(USBAudioStream *speaker) { this->endpoint_(Endpoint::SPEAKER).platform = speaker; } + void register_microphone(USBAudioStream *microphone) { this->endpoint_(Endpoint::MICROPHONE).platform = microphone; } + + /// Start streaming as soon as the device is connected; the platform hears back through on_stream_state(). + void resume(Endpoint endpoint); + void suspend(Endpoint endpoint); + + /// Safe from any task; the worker sends the values to the device. + void set_speaker_volume_level(float volume); + void set_speaker_mute_state(bool mute_state); + + // Safe from any task. While the worker holds the driver for a control transfer, these wait for it within timeout_ms + // and return ESP_ERR_TIMEOUT if it is still busy, instead of blocking on the driver's interface lock for seconds. + esp_err_t read_microphone(uint8_t *buffer, size_t size, size_t *bytes_read, uint32_t timeout_ms); + esp_err_t write_speaker(const uint8_t *data, size_t length, uint32_t timeout_ms); + + protected: + static constexpr size_t EVENT_QUEUE_LENGTH = 16; + static constexpr size_t OP_QUEUE_LENGTH = 8; + // Extra driver ring buffer space above the speaker's buffer_size, see Op::Type::OPEN. + static constexpr uint32_t SPEAKER_RING_SLACK = 4; + + struct EndpointState { + // Read by the platform tasks and the worker; cleared first when the device goes away. + std::atomic handle{nullptr}; + // A lost device, closed by the worker once the platform reports stream_idle(). + uac_host_device_handle_t closing{nullptr}; + USBAudioStream *platform{nullptr}; + uint8_t iface{0}; + bool stream_started{false}; + // The platform asked for the stream and has not stopped it. + bool active{false}; + // Starting the connected device failed for good (see USBAudioEvent::rejected); cleared when it is closed. + bool start_rejected{false}; + // An open or start is queued on the worker; its result event clears it. + bool open_pending{false}; + bool start_pending{false}; + bool close_queued{false}; + // Counts resume() and suspend() calls, so a RESUMED that a later suspend overtook is ignored. + uint8_t request_seq{0}; + // A connect that arrived while the old handle was still opening or closing; opened once that finishes. + bool connect_deferred{false}; + uint8_t deferred_addr{0}; + uint8_t deferred_iface{0}; + // The last open failed; the next resume() retries it at this address and interface. + bool open_failed{false}; + uint8_t failed_addr{0}; + uint8_t failed_iface{0}; + // A failed close keeps `closing` and is retried after this time, a limited number of times. + uint8_t close_attempts{0}; + uint32_t close_retry_ms{0}; + }; + + struct ControlState { + std::atomic pending{false}; + // Only touched by the worker, and reset while the speaker is closed. + bool supported{true}; + uint8_t attempts{0}; + }; + + /// A blocking driver call, run on the worker. + struct Op { + enum class Type : uint8_t { + OPEN, + START, + RESUME, + SUSPEND, + CLOSE, + CONTROLS, + }; + + uac_host_device_handle_t handle{nullptr}; + uint8_t addr{0}; + uint8_t iface_num{0}; + // RESUME: copied to the result, see EndpointState::request_seq. + uint8_t seq{0}; + Type type{Type::OPEN}; + Endpoint endpoint{Endpoint::SPEAKER}; + // CLOSE: stop the stream before closing. + bool stop{false}; + }; + + EndpointState &endpoint_(Endpoint endpoint) { return this->endpoints_[static_cast(endpoint)]; } + const EndpointState &endpoint_(Endpoint endpoint) const { return this->endpoints_[static_cast(endpoint)]; } + void process_events_(uint32_t now); + void process_event_(const USBAudioEvent &event, uint32_t now); + void open_stream_(Endpoint endpoint, uint8_t addr, uint8_t iface_num); + void on_opened_(const USBAudioEvent &event); + void on_started_(const USBAudioEvent &event); + void on_resumed_(const USBAudioEvent &event); + void on_closed_(const USBAudioEvent &event, uint32_t now); + void start_stream_(Endpoint endpoint); + void stream_failed_(Endpoint endpoint); + void handle_disconnect_(uac_host_device_handle_t handle); + void begin_close_(Endpoint endpoint); + bool finish_closes_(uint32_t now); + bool queue_op_(const Op &op); + void queue_controls_(); + void post_event_(const USBAudioEvent &event); + + // Worker side: the only place that makes blocking driver calls after setup(). + static void worker_task(void *param); + void run_op_(const Op &op); + /// Keep platform reads and writes out of the driver while the worker uses it. + void lock_driver_(); + /// Platform side: wait, within timeout_ms, until the worker is not using the driver, and count the caller as a + /// driver user; timeout_ms is reduced by the wait. Returns false when the worker still holds it. + bool enter_driver_(uint32_t &timeout_ms); + void unlock_driver_(); + bool populate_stream_config_(Endpoint endpoint, uac_host_device_handle_t handle, + uac_host_stream_config_t &stream_config, bool &rejected); + /// Send pending volume and mute; returns true when one should be retried later. + bool apply_pending_speaker_controls_(); + bool apply_control_(ControlState &control, esp_err_t err, const LogString *name); + + static void driver_event_stub(uint8_t addr, uint8_t iface_num, uac_host_driver_event_t event, void *user_data); + static void device_event_stub(uac_host_device_handle_t handle, uac_host_device_event_t event, void *user_data); + + StaticTask worker_; + // Filled from the USB driver task and the worker, drained on the main loop. + QueueHandle_t event_queue_{nullptr}; + // Filled on the main loop (and by volume/mute changes from any task), drained by the worker. + QueueHandle_t op_queue_{nullptr}; + std::array endpoints_{}; + // Disconnects for devices whose open is still running on the worker, one per endpoint since a speakerphone opens + // two interfaces; each is closed once its OPENED arrives. + std::array lost_while_opening_{}; + // Counted on the USB driver task, logged from loop(). + std::atomic transfer_errors_{0}; + StaticQueue_t event_queue_struct_{}; + StaticQueue_t op_queue_struct_{}; + std::array event_queue_storage_{}; + std::array op_queue_storage_{}; + ControlState volume_{}; + ControlState mute_{}; + std::atomic speaker_volume_{100}; + std::atomic speaker_muted_{false}; + // A CONTROLS op is queued; the pending flags carry the latest values, so one is enough. + std::atomic controls_queued_{false}; + // Set while the worker runs a driver call; platform reads and writes counted in driver_users_ back off. + std::atomic driver_busy_{false}; + std::atomic driver_users_{0}; + bool uac_installed_{false}; +}; + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 931ccf3c789..70838d897c8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -557,6 +557,11 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #define USE_SERIAL_PROXY_USB_IDENTITY #endif +// usb_host_uac supports only these variants +#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) +#define USE_USB_AUDIO +#endif #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 12) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index dd837d61632..2c12eec6d47 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -108,6 +108,11 @@ dependencies: version: "1.4.1" rules: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" + # Keep in sync with esphome/components/usb_audio/__init__.py + espressif/usb_host_uac: + version: "1.5.0" + rules: + - if: "target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 # sendspin is ESP-IDF only (it needs noise-c as an ESP-IDF component, see below) diff --git a/tests/component_tests/usb_audio/__init__.py b/tests/component_tests/usb_audio/__init__.py new file mode 100644 index 00000000000..284b0c6d298 --- /dev/null +++ b/tests/component_tests/usb_audio/__init__.py @@ -0,0 +1 @@ +"""Tests for the usb_audio platforms' config validation.""" diff --git a/tests/component_tests/usb_audio/test_validation.py b/tests/component_tests/usb_audio/test_validation.py new file mode 100644 index 00000000000..c7b0300b3a1 --- /dev/null +++ b/tests/component_tests/usb_audio/test_validation.py @@ -0,0 +1,132 @@ +"""Tests for the usb_audio buffer floors, the one-platform-per-hub check and the USB FIFO requests.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import usb_audio, usb_host +from esphome.components.usb_audio import ( + CONF_USB_AUDIO_ID, + microphone as usb_microphone, + speaker as usb_speaker, +) +from esphome.config import Config +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_BUFFER_SIZE, + CONF_NUM_CHANNELS, + CONF_PLATFORM, + CONF_SAMPLE_RATE, + PlatformFramework, +) +from esphome.core import CORE, ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _stream(sample_rate: int, channels: int, bits: int, buffer_size: int) -> ConfigType: + return { + CONF_SAMPLE_RATE: sample_rate, + CONF_NUM_CHANNELS: channels, + CONF_BITS_PER_SAMPLE: float(bits), + CONF_BUFFER_SIZE: buffer_size, + } + + +def _entry(hub: str, platform: str = "usb_audio") -> ConfigType: + return {CONF_PLATFORM: platform, CONF_USB_AUDIO_ID: ID(hub)} + + +def _set_full_config( + set_core_config: SetCoreConfigCallable, **domains: list[ConfigType] +) -> None: + full = Config() + full.update(domains) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + + +def test_speaker_buffer_floor() -> None: + # Two transfers of 3 packets x 48 frames x 4 bytes at 48 kHz 16-bit stereo. + usb_speaker.validate_buffer_size(_stream(48000, 2, 16, 1152)) + with pytest.raises(cv.Invalid, match="at least 1152 bytes"): + usb_speaker.validate_buffer_size(_stream(48000, 2, 16, 1151)) + + +def test_speaker_floor_rounds_up_fractional_rates() -> None: + # 44.1 kHz packets carry up to 45 frames. + with pytest.raises(cv.Invalid, match="at least 1080 bytes"): + usb_speaker.validate_buffer_size(_stream(44100, 2, 16, 1079)) + + +def test_speaker_default_buffer_meets_floor() -> None: + usb_speaker.validate_buffer_size(_stream(96000, 2, 32, 6400)) + + +def test_microphone_buffer_floor() -> None: + # 16 kHz 16-bit mono is 32000 bytes/s, so two read periods (40 ms) are 1280 bytes. + usb_microphone.validate_buffer_size(_stream(16000, 1, 16, 1280)) + with pytest.raises(cv.Invalid, match="at least 1280 bytes"): + usb_microphone.validate_buffer_size(_stream(16000, 1, 16, 1279)) + + +def _unset_buffer(sample_rate: int, channels: int, bits: int) -> ConfigType: + config = _stream(sample_rate, channels, bits, 0) + del config[CONF_BUFFER_SIZE] + return config + + +def test_default_buffer_size() -> None: + assert ( + usb_speaker.validate_buffer_size(_unset_buffer(48000, 2, 16))[CONF_BUFFER_SIZE] + == 6400 + ) + assert ( + usb_microphone.validate_buffer_size(_unset_buffer(16000, 1, 16))[ + CONF_BUFFER_SIZE + ] + == 6400 + ) + + +def test_default_buffer_size_grows_to_the_floor() -> None: + # 48 kHz 16-bit stereo is 192000 bytes/s, so 40 ms need 7680 bytes, more than the 6400 default. + assert ( + usb_microphone.validate_buffer_size(_unset_buffer(48000, 2, 16))[ + CONF_BUFFER_SIZE + ] + == 7680 + ) + + +def test_one_speaker_per_hub_accepted(set_core_config: SetCoreConfigCallable) -> None: + entries = [_entry("hub_a"), _entry("hub_b"), _entry("hub_a", "i2s_audio")] + _set_full_config(set_core_config, speaker=entries) + usb_speaker.FINAL_VALIDATE_SCHEMA(entries[0]) + usb_speaker.FINAL_VALIDATE_SCHEMA(entries[1]) + + +def test_two_speakers_on_one_hub_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + entries = [_entry("hub_a"), _entry("hub_a")] + _set_full_config(set_core_config, speaker=entries) + with pytest.raises(cv.Invalid, match="Only one usb_audio speaker"): + usb_speaker.FINAL_VALIDATE_SCHEMA(entries[0]) + + +def test_two_microphones_on_one_hub_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + entries = [_entry("hub_a"), _entry("hub_a")] + _set_full_config(set_core_config, microphone=entries, speaker=[_entry("hub_a")]) + with pytest.raises(cv.Invalid, match="Only one usb_audio microphone"): + usb_microphone.FINAL_VALIDATE_SCHEMA(entries[0]) + + +def test_platforms_request_usb_fifo_space( + set_core_config: SetCoreConfigCallable, +) -> None: + # 48 kHz 16-bit stereo packets carry up to 49 frames of 4 bytes; 16 kHz 16-bit mono up to 17 frames of 2 bytes. + set_core_config(PlatformFramework.ESP32_IDF) + usb_audio.request_speaker_fifo(_stream(48000, 2, 16, 6400)) + usb_audio.request_microphone_fifo(_stream(16000, 1, 16, 6400)) + assert CORE.data[usb_host.DOMAIN][usb_host.KEY_PERIODIC_FIFO] == [34, 196] diff --git a/tests/components/usb_audio/common.yaml b/tests/components/usb_audio/common.yaml new file mode 100644 index 00000000000..7e60ca0da02 --- /dev/null +++ b/tests/components/usb_audio/common.yaml @@ -0,0 +1,21 @@ +usb_audio: + id: usb_audio_id + +microphone: + - platform: usb_audio + id: usb_mic + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 16bit + num_channels: 2 + buffer_size: 9600 + +speaker: + - platform: usb_audio + id: usb_speaker + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 16bit + num_channels: 2 + buffer_size: 6400 + write_timeout: 20ms diff --git a/tests/components/usb_audio/test.esp32-p4-idf.yaml b/tests/components/usb_audio/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..53588849d1a --- /dev/null +++ b/tests/components/usb_audio/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + usb_audio: !include common.yaml diff --git a/tests/components/usb_audio/test.esp32-s3-idf.yaml b/tests/components/usb_audio/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..53588849d1a --- /dev/null +++ b/tests/components/usb_audio/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + usb_audio: !include common.yaml From 2addbaaa7fecd990f2eb75f977c264bf748fb3ea Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:31:16 -1000 Subject: [PATCH 639/651] [ci] Refresh integration test durations (#21105) --- .../integration_test_durations.json | 373 +++++++++--------- 1 file changed, 195 insertions(+), 178 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 75bf0b48427..c7169c1d6db 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,180 +1,197 @@ { - "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 + "tests/integration/test_action_concurrent_reentry.py": 14.84, + "tests/integration/test_addressable_light_transition.py": 17.96, + "tests/integration/test_alarm_control_panel_state_transitions.py": 16.42, + "tests/integration/test_api_action_metadata.py": 12.08, + "tests/integration/test_api_action_responses.py": 16.62, + "tests/integration/test_api_action_timeout.py": 17.38, + "tests/integration/test_api_conditional_memory.py": 12.24, + "tests/integration/test_api_custom_services.py": 10.53, + "tests/integration/test_api_decode_wire_types.py": 17.97, + "tests/integration/test_api_empty_message_roundtrip.py": 12.54, + "tests/integration/test_api_encode_boundaries.py": 14.46, + "tests/integration/test_api_get_time_response_timezone.py": 16.43, + "tests/integration/test_api_homeassistant.py": 17.44, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 11.63, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.61, + "tests/integration/test_api_homeassistant_entities.py": 10.45, + "tests/integration/test_api_list_entities_backpressure.py": 13.7, + "tests/integration/test_api_message_size_batching.py": 9.04, + "tests/integration/test_api_outgoing_connection.py": 26.32, + "tests/integration/test_api_reboot_timeout.py": 11.82, + "tests/integration/test_api_string_lambda.py": 11.94, + "tests/integration/test_api_vv_logging.py": 13.47, + "tests/integration/test_api_zero_psk_provisioning.py": 27.07, + "tests/integration/test_areas_and_devices.py": 9.9, + "tests/integration/test_automation_wait_actions.py": 13.22, + "tests/integration/test_automations.py": 26.92, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 14.21, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 13.7, + "tests/integration/test_binary_sensor_invalidate_state.py": 13.52, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 12.48, + "tests/integration/test_branch_resume_async.py": 16.18, + "tests/integration/test_build_info.py": 11.15, + "tests/integration/test_camera_mock.py": 13.74, + "tests/integration/test_climate_control_action.py": 14.33, + "tests/integration/test_climate_custom_modes.py": 13.85, + "tests/integration/test_continuation_actions.py": 12.9, + "tests/integration/test_cover_control_action.py": 10.2, + "tests/integration/test_crc8_helper.py": 11.98, + "tests/integration/test_device_id_in_state.py": 25.5, + "tests/integration/test_duplicate_entities.py": 14.91, + "tests/integration/test_entity_icon.py": 15.33, + "tests/integration/test_entity_missing_state.py": 8.06, + "tests/integration/test_fan_turn_on_action.py": 13.45, + "tests/integration/test_fnv1_hash_object_id.py": 10.16, + "tests/integration/test_fnv1a_hash.py": 9.94, + "tests/integration/test_gpio_expander_cache.py": 10.52, + "tests/integration/test_host_logger_thread_safety.py": 11.38, + "tests/integration/test_host_mode_basic.py": 10.96, + "tests/integration/test_host_mode_batch_delay.py": 13.46, + "tests/integration/test_host_mode_climate_basic_state.py": 7.78, + "tests/integration/test_host_mode_climate_control.py": 14.03, + "tests/integration/test_host_mode_empty_string_options.py": 12.45, + "tests/integration/test_host_mode_entity_fields.py": 15.66, + "tests/integration/test_host_mode_fan_preset.py": 9.7, + "tests/integration/test_host_mode_many_entities.py": 23.55, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 14.87, + "tests/integration/test_host_mode_noise_encryption.py": 29.31, + "tests/integration/test_host_mode_reconnect.py": 7.15, + "tests/integration/test_host_mode_sensor.py": 12.09, + "tests/integration/test_host_ota.py": 307.27, + "tests/integration/test_host_preferences.py": 13.14, + "tests/integration/test_host_preferences_suspend_resume.py": 14.41, + "tests/integration/test_host_write_after_peer_close.py": 13.85, + "tests/integration/test_improv_serial_uart.py": 14.8, + "tests/integration/test_in_range_conditions.py": 13.63, + "tests/integration/test_ir_rf_transmit_complete.py": 17.32, + "tests/integration/test_large_message_batching.py": 15.13, + "tests/integration/test_legacy_area.py": 11.23, + "tests/integration/test_legacy_climate_compat.py": 13.99, + "tests/integration/test_legacy_fan_compat.py": 8.48, + "tests/integration/test_light_automations.py": 8.52, + "tests/integration/test_light_binary_effect_off_phase.py": 20.04, + "tests/integration/test_light_calls.py": 16.91, + "tests/integration/test_light_constant_brightness.py": 10.63, + "tests/integration/test_light_control_action.py": 14.43, + "tests/integration/test_light_dim_relative_action.py": 16.59, + "tests/integration/test_light_effect_zero_brightness.py": 17.36, + "tests/integration/test_light_initial_state.py": 13.38, + "tests/integration/test_light_repeat_effect.py": 13.04, + "tests/integration/test_light_restore_state.py": 14.38, + "tests/integration/test_light_resume_effect.py": 17.23, + "tests/integration/test_light_toggle_action.py": 15.35, + "tests/integration/test_light_transition_state_publish_interval.py": 44.06, + "tests/integration/test_lock_automations.py": 13.01, + "tests/integration/test_logger_buffered_recursion_guard.py": 8.93, + "tests/integration/test_loop_disable_enable.py": 12.38, + "tests/integration/test_loop_interval_decoupling.py": 15.54, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.16, + "tests/integration/test_lvgl_color_picker_render.py": 53.51, + "tests/integration/test_lvgl_headless_render.py": 58.96, + "tests/integration/test_micros_to_millis.py": 2.84, + "tests/integration/test_multi_click_trigger.py": 8.88, + "tests/integration/test_multi_device_preferences.py": 14.72, + "tests/integration/test_noise_encryption_key_protection.py": 21.32, + "tests/integration/test_object_id_api_verification.py": 12.83, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 7.81, + "tests/integration/test_object_id_no_friendly_name.py": 24.12, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 25.21, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 25.14, + "tests/integration/test_online_image_bmp.py": 16.77, + "tests/integration/test_online_image_stalled_download.py": 17.06, + "tests/integration/test_oversized_payloads.py": 37.98, + "tests/integration/test_preference_key_stability.py": 13.43, + "tests/integration/test_runtime_stats.py": 12.02, + "tests/integration/test_safe_mode_loop_runs.py": 11.39, + "tests/integration/test_scheduler_background_wake.py": 17.85, + "tests/integration/test_scheduler_blocking_warning.py": 30.7, + "tests/integration/test_scheduler_bulk_cleanup.py": 12.73, + "tests/integration/test_scheduler_defer_cancel.py": 13.05, + "tests/integration/test_scheduler_defer_cancel_regular.py": 12.12, + "tests/integration/test_scheduler_defer_fifo_simple.py": 12.73, + "tests/integration/test_scheduler_defer_stress.py": 12.9, + "tests/integration/test_scheduler_heap_stress.py": 13.61, + "tests/integration/test_scheduler_internal_id_no_collision.py": 9.06, + "tests/integration/test_scheduler_interval_reschedule.py": 12.19, + "tests/integration/test_scheduler_interval_zero_coerced.py": 3.33, + "tests/integration/test_scheduler_loop_phase_timeout.py": 10.81, + "tests/integration/test_scheduler_null_name.py": 3.45, + "tests/integration/test_scheduler_numeric_id_test.py": 13.88, + "tests/integration/test_scheduler_pool.py": 13.86, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.54, + "tests/integration/test_scheduler_recursive_timeout.py": 4.32, + "tests/integration/test_scheduler_removed_item_race.py": 13.06, + "tests/integration/test_scheduler_self_keyed.py": 4.29, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 12.79, + "tests/integration/test_scheduler_string_test.py": 8.51, + "tests/integration/test_script_array_params.py": 8.05, + "tests/integration/test_script_delay_params.py": 14.57, + "tests/integration/test_script_on_stop_and_choose.py": 14.11, + "tests/integration/test_script_queued.py": 15.68, + "tests/integration/test_script_queued_idle_loop.py": 8.46, + "tests/integration/test_script_wait_on_boot.py": 12.91, + "tests/integration/test_sdl_headless_screenshot.py": 15.05, + "tests/integration/test_select_stringref_trigger.py": 11.48, + "tests/integration/test_sensor_filters_delta.py": 13.28, + "tests/integration/test_sensor_filters_ring_buffer.py": 13.95, + "tests/integration/test_sensor_filters_sliding_window.py": 40.27, + "tests/integration/test_sensor_filters_value_list.py": 14.95, + "tests/integration/test_sensor_raw_state.py": 24.93, + "tests/integration/test_sensor_timeout_filter.py": 14.93, + "tests/integration/test_sensor_timeout_throttle_filter.py": 16.23, + "tests/integration/test_set_internal_at_boot.py": 12.69, + "tests/integration/test_snapshot_display.py": 15.83, + "tests/integration/test_socket_ipv4_resolve.py": 10.41, + "tests/integration/test_socket_tcp_client_link.py": 12.96, + "tests/integration/test_socket_wake_gate_tcp.py": 11.61, + "tests/integration/test_socket_wake_gate_udp.py": 10.45, + "tests/integration/test_status_flags.py": 20.81, + "tests/integration/test_status_momentary.py": 15.46, + "tests/integration/test_strftime_to.py": 12.97, + "tests/integration/test_syslog.py": 21.29, + "tests/integration/test_tcp_uart_bridge.py": 16.41, + "tests/integration/test_tcp_uart_server.py": 13.43, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.34, + "tests/integration/test_template_climate_basic.py": 12.78, + "tests/integration/test_template_climate_custom_modes.py": 14.81, + "tests/integration/test_template_climate_nonoptimistic.py": 8.48, + "tests/integration/test_template_climate_on_control_ordering.py": 7.38, + "tests/integration/test_template_climate_publish_all_fields.py": 8.89, + "tests/integration/test_template_climate_sensor_push.py": 15.73, + "tests/integration/test_template_climate_set_actions.py": 11.23, + "tests/integration/test_template_climate_two_point_temperature.py": 9.47, + "tests/integration/test_template_text_save.py": 13.65, + "tests/integration/test_template_time.py": 9.1, + "tests/integration/test_text_command.py": 12.29, + "tests/integration/test_text_sensor_raw_state.py": 13.19, + "tests/integration/test_uart_mock_bl0906_stray_rx.py": 13.99, + "tests/integration/test_uart_mock_dfrobot_sen0465.py": 20.14, + "tests/integration/test_uart_mock_dfrobot_sen0609.py": 10.57, + "tests/integration/test_uart_mock_ld2410.py": 31.61, + "tests/integration/test_uart_mock_ld2410s.py": 49.83, + "tests/integration/test_uart_mock_ld2412.py": 89.93, + "tests/integration/test_uart_mock_ld2420.py": 20.19, + "tests/integration/test_uart_mock_ld2450.py": 16.45, + "tests/integration/test_uart_mock_ld2450_has_target_only.py": 17.25, + "tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py": 10.7, + "tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py": 15.95, + "tests/integration/test_uart_mock_ld2450_polygon_zones.py": 11.68, + "tests/integration/test_uart_mock_ld2460.py": 15.46, + "tests/integration/test_uart_mock_modbus.py": 214.58, + "tests/integration/test_uart_mock_pylontech.py": 14.66, + "tests/integration/test_uart_mock_ufm01.py": 70.02, + "tests/integration/test_uart_tcp_bridge.py": 10.46, + "tests/integration/test_udp.py": 9.38, + "tests/integration/test_udp_ipv6.py": 8.74, + "tests/integration/test_udp_multicast.py": 11.71, + "tests/integration/test_use_address_runtime.py": 15.24, + "tests/integration/test_valve_control_action.py": 13.75, + "tests/integration/test_varint_five_byte_device_id.py": 13.56, + "tests/integration/test_wait_until_mid_loop_timing.py": 8.57, + "tests/integration/test_wait_until_on_boot.py": 9.51, + "tests/integration/test_wait_until_ordering.py": 4.48, + "tests/integration/test_wait_until_reentrant_restart.py": 5.63, + "tests/integration/test_wake_loop_forces_phase_b.py": 9.4, + "tests/integration/test_water_heater_template.py": 28.16 } From 4498ad2c777e5c21db0cee9c022375e38993c186 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:38:47 +1100 Subject: [PATCH 640/651] [core] Warn when the .esphome build tree is tracked by git (#18973) --- esphome/__main__.py | 1 + esphome/writer.py | 87 ++++++++++- tests/unit_tests/test_main.py | 48 +++++++ tests/unit_tests/test_writer.py | 246 ++++++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 82ef585e1a9..39451849bc2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -791,6 +791,7 @@ def write_cpp(config: ConfigType) -> int: if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() + writer.check_build_tree_not_tracked() generate_cpp_contents(config) return write_cpp_file() diff --git a/esphome/writer.py b/esphome/writer.py index 934595ef7e8..e6c0c0e2e90 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -4,6 +4,7 @@ import logging import os from pathlib import Path import re +import subprocess import time from esphome import loader @@ -16,6 +17,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE, LINE_DIRECTIVE_RESET, EsphomeError, line_directive +from esphome.git import _GIT_REPO_SCOPING_ENV from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -722,7 +724,11 @@ def clean_all(configuration: list[str]): _LOGGER.info("Cleaning %s", dir) # Don't remove storage or .json files which are needed by the dashboard for item in dir.iterdir(): - if item.is_file() and not item.name.endswith(".json"): + if ( + item.is_file() + and not item.name.endswith(".json") + and item.name != ".gitkeep" + ): item.unlink() elif item.is_dir() and item.name != "storage": rmtree(item) @@ -764,3 +770,82 @@ def write_gitignore(): path = CORE.relative_config_path(".gitignore") if not path.is_file(): path.write_text(GITIGNORE_CONTENT, encoding="utf-8") + + +def check_build_tree_not_tracked() -> None: + """Warn if the build tree is tracked by git. + + ``write_gitignore`` never touches an existing ``.gitignore``, so a build + tree that was committed before it was excluded (or on a config predating + ESPHome's auto-generated ``.gitignore``) stays tracked forever: adding a + pattern to ``.gitignore`` does not untrack files already in the index. + Every subsequent build would then keep changing the regenerated files + under version control. + """ + if os.environ.get("CI"): + # CI checkouts build fixture configs, not a user's own repository, so + # there is nothing meaningful to warn about -- skip the extra process + # spawn on every single build. + return + data_dir = CORE.data_dir + if (data_dir / ".gitkeep").is_file(): + # The user deliberately placed a .gitkeep file, opting the build + # directory into version control on purpose. + return + + try: + relative_data_dir = data_dir.relative_to(CORE.config_dir) + except ValueError: + # Outside the config directory (e.g. the Home Assistant add-on's + # /data, or ESPHOME_DATA_DIR pointing elsewhere) -- can't be part of + # this repository. + return + + # Strip the same repo-scoping variables run_git_command() strips (see + # esphome/git.py): a git hook or CI wrapper exporting GIT_DIR/GIT_INDEX_FILE + # would otherwise redirect this read-only query to its own repository. + env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_SCOPING_ENV} + try: + result = subprocess.run( + ["git", "ls-files", "-z", "--", str(relative_data_dir)], + cwd=CORE.config_dir, + capture_output=True, + check=False, + env=env, + timeout=5, + ) + except FileNotFoundError: + # No git installed, so the directory cannot be tracked. + return + except (OSError, subprocess.TimeoutExpired) as err: + # This is a purely advisory check -- an unusable or slow git must + # never turn into a hard failure of the build itself. + _LOGGER.debug( + "Could not check whether %s is tracked by git: %s", relative_data_dir, err + ) + return + if result.returncode != 0: + if result.stderr: + _LOGGER.debug( + "git ls-files failed (%d): %s", + result.returncode, + result.stderr.decode("utf-8", errors="replace").strip(), + ) + return + if not result.stdout: + return + + tracked_count = sum(1 for name in result.stdout.split(b"\0") if name) + _LOGGER.warning( + "%s is tracked by git (%d file%s). This is the ESPHome build " + "directory; it holds generated files that may contain your secrets " + "(e.g. Wi-Fi credentials or API keys) in plain text, and should not " + "be committed. Add it to your .gitignore, then remove it from " + "version control with: git rm -r --cached %s " + "(if you really want to keep this directory in git, add a file " + "named .gitkeep to it to silence this warning)", + relative_data_dir, + tracked_count, + "" if tracked_count == 1 else "s", + data_dir, + ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 801eda4496a..88a91e38cf9 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -69,6 +69,7 @@ from esphome.__main__ import ( upload_using_esptool, upload_using_picotool, upload_using_platformio, + write_cpp, ) from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult @@ -108,6 +109,7 @@ from esphome.const import ( CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, + ENV_NOGITIGNORE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, @@ -4861,6 +4863,52 @@ def test_run_esphome_dashboard_redirects_to_device_builder( assert "esphome-device-builder" in caplog.text +def test_write_cpp_writes_gitignore_and_checks_build_tree( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """write_cpp writes the .gitignore and checks the build tree isn't tracked.""" + monkeypatch.delenv(ENV_NOGITIGNORE, raising=False) + + with ( + patch("esphome.writer.update_storage_json") as mock_update_storage, + patch("esphome.writer.write_gitignore") as mock_write_gitignore, + patch("esphome.writer.check_build_tree_not_tracked") as mock_check_tracked, + patch("esphome.__main__.generate_cpp_contents") as mock_generate, + patch("esphome.__main__.write_cpp_file", return_value=0) as mock_write_file, + ): + result = write_cpp({}) + + assert result == 0 + mock_update_storage.assert_called_once() + mock_write_gitignore.assert_called_once() + mock_check_tracked.assert_called_once() + mock_generate.assert_called_once_with({}) + mock_write_file.assert_called_once() + + +def test_write_cpp_skips_gitignore_but_still_checks_build_tree( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ESPHOME_NOGITIGNORE skips writing .gitignore. + + The (read-only) tracked-file check still runs -- it isn't tied to + whether ESPHome is allowed to write files. + """ + monkeypatch.setenv(ENV_NOGITIGNORE, "true") + + with ( + patch("esphome.writer.update_storage_json"), + patch("esphome.writer.write_gitignore") as mock_write_gitignore, + patch("esphome.writer.check_build_tree_not_tracked") as mock_check_tracked, + patch("esphome.__main__.generate_cpp_contents"), + patch("esphome.__main__.write_cpp_file", return_value=0), + ): + write_cpp({}) + + mock_write_gitignore.assert_not_called() + mock_check_tracked.assert_called_once() + + def test_command_config_hash( tmp_path: Path, capfd: CaptureFixture[str], diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 67acfa6a189..f03a42b6e8d 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -5,10 +5,12 @@ from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime import json +import logging import os from pathlib import Path import re import stat +import subprocess from typing import Any from unittest.mock import MagicMock, patch @@ -32,6 +34,7 @@ from esphome.writer import ( CPP_INCLUDE_BEGIN, CPP_INCLUDE_END, GITIGNORE_CONTENT, + check_build_tree_not_tracked, clean_all, clean_build, clean_cmake_cache, @@ -874,6 +877,244 @@ def test_write_gitignore_skips_existing_file( assert gitignore_path.read_text() == existing_content +@patch("esphome.writer.subprocess.run") +def test_check_build_tree_not_tracked_silent_in_ci( + mock_run: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CI builds fixture configs, not a user's repo -- skip the git spawn entirely.""" + monkeypatch.setenv("CI", "true") + + check_build_tree_not_tracked() + + mock_run.assert_not_called() + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_warns_when_tracked( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked warns when the build dir is tracked by git.""" + monkeypatch.delenv("CI", raising=False) + data_dir = tmp_path / ".esphome" + mock_core.config_dir = tmp_path + mock_core.data_dir = data_dir + mock_run.return_value = MagicMock( + returncode=0, + stdout=b".esphome/build/node/main.cpp\0.esphome/node.json\0", + stderr=b"", + ) + + with ( + patch.dict(os.environ, {"GIT_DIR": "/somewhere/else/.git"}), + caplog.at_level("WARNING"), + ): + check_build_tree_not_tracked() + + assert "is tracked by git" in caplog.text + assert "2 files" in caplog.text + assert "secrets" in caplog.text + assert ".gitkeep" in caplog.text + # The remediation command must use the absolute path so it also works + # when pasted from the repository root of a nested config layout. + assert f"git rm -r --cached {data_dir}" in caplog.text + + mock_run.assert_called_once() + args, kwargs = mock_run.call_args + assert args[0] == ["git", "ls-files", "-z", "--", ".esphome"] + assert kwargs["cwd"] == tmp_path + assert kwargs["capture_output"] is True + assert kwargs["check"] is False + assert kwargs["timeout"] == 5 + # Repo-scoping env vars must be stripped so a hook/CI wrapper can't + # redirect this read-only query to a different repository. + assert "GIT_DIR" not in kwargs["env"] + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_gitkeep_present( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked is skipped when .gitkeep opts in.""" + monkeypatch.delenv("CI", raising=False) + data_dir = tmp_path / ".esphome" + data_dir.mkdir() + (data_dir / ".gitkeep").touch() + mock_core.config_dir = tmp_path + mock_core.data_dir = data_dir + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + mock_run.assert_not_called() + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_untracked( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked stays silent when nothing is tracked.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.return_value = MagicMock(returncode=0, stdout=b"", stderr=b"") + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_outside_git_repo( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked stays silent when not inside a git repo.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.return_value = MagicMock( + returncode=128, stdout=b"", stderr=b"fatal: not a git repository\n" + ) + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_on_failure_without_stderr( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-zero exit with no stderr must not raise trying to log the reason.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.return_value = MagicMock(returncode=1, stdout=b"", stderr=b"") + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_git_missing( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked stays silent when git isn't installed.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.side_effect = FileNotFoundError() + + with caplog.at_level("DEBUG"): + check_build_tree_not_tracked() + + assert caplog.text == "" + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_on_other_os_error( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-executable git on PATH (PermissionError) must not abort the build.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.side_effect = PermissionError("git is not executable") + + with caplog.at_level("DEBUG"): + check_build_tree_not_tracked() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + assert "Could not check whether" in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_on_timeout( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A hung git process must not block the build indefinitely.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5) + + with caplog.at_level("DEBUG"): + check_build_tree_not_tracked() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + assert "Could not check whether" in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_data_dir_outside_config_dir( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked skips the check for an external data dir. + + Covers the Home Assistant add-on (/data) and ESPHOME_DATA_DIR cases, where + the build directory can't be part of the config's git repository. + """ + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path / "config" + mock_core.data_dir = tmp_path / "data" + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + mock_run.assert_not_called() + assert "tracked by git" not in caplog.text + + @patch("esphome.writer.write_file_if_changed") # Mock to capture output @patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex @patch("esphome.writer.CORE") @@ -1373,6 +1614,7 @@ def test_clean_all( build_dir2.mkdir() (build_dir1 / "dummy.txt").write_text("x") (build_dir2 / "dummy.txt").write_text("x") + (build_dir1 / ".gitkeep").touch() # Create PlatformIO directories pio_cache = tmp_path / "pio_cache" @@ -1415,6 +1657,10 @@ def test_clean_all( # Verify that files in .esphome were removed assert not (build_dir1 / "dummy.txt").exists() assert not (build_dir2 / "dummy.txt").exists() + + # .gitkeep is the opt-out check_build_tree_not_tracked() advertises; it + # must survive clean-all or the warning comes right back on next compile. + assert (build_dir1 / ".gitkeep").exists() assert not pio_cache.exists() assert not pio_packages.exists() assert not pio_platforms.exists() From b919b692f7f3687d161ad020c47ee384993abfc6 Mon Sep 17 00:00:00 2001 From: zerafachris Date: Mon, 19 Oct 2026 09:54:33 +0200 Subject: [PATCH 641/651] [scd4x] Fix ambient_pressure_compensation unit handling (#19888) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/scd4x/sensor.py | 13 ++++++++++++- tests/components/scd4x/common.yaml | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 4b67319efb1..f1c7beaea8b 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -42,6 +42,15 @@ MEASUREMENT_MODE_OPTIONS = MeasurementMode.options( ) +def pressure_hpa(value: str | float) -> float: + """Pressure in hPa. A bare number is hPa, matching what the C++ sends to the sensor.""" + try: + return cv.float_(value) + except cv.Invalid: + pass + return cv.pressure(value) * 1000 # bar, mbar, mBar + + CONFIG_SCHEMA = ( cv.Schema( { @@ -72,7 +81,9 @@ CONFIG_SCHEMA = ( cv.float_with_unit("altitude", "(m|m a.s.l.|MAMSL|MASL)"), cv.int_range(min=0, max=0xFFFF, max_included=False), ), - cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.pressure, + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.All( + pressure_hpa, cv.Any(cv.float_range(min=700, max=1200), 0) + ), cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature_delta, cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( sensor.Sensor diff --git a/tests/components/scd4x/common.yaml b/tests/components/scd4x/common.yaml index cedb88977ea..e875cb0cc57 100644 --- a/tests/components/scd4x/common.yaml +++ b/tests/components/scd4x/common.yaml @@ -14,3 +14,19 @@ sensor: ambient_pressure_compensation: 961mBar temperature_offset: 4.2C update_interval: 15s + - platform: scd4x + i2c_id: i2c_bus + co2: + name: SCD4X CO2 (bare hPa) + ambient_pressure_compensation: 961 + - platform: scd4x + i2c_id: i2c_bus + co2: + name: SCD4X CO2 (bar) + ambient_pressure_compensation: 0.961bar + - platform: scd4x + i2c_id: i2c_bus + co2: + name: SCD4X CO2 (altitude only) + altitude_compensation: 500m + ambient_pressure_compensation: 0 From 2087d2868469a1ac179ffaece6cf5623ea39e87b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:07:50 +0000 Subject: [PATCH 642/651] Bump aioesphomeapi from 46.12.0 to 46.14.0 (#21114) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 13625818ad8..7d48235966c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.12.0 +aioesphomeapi==46.14.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 2d0e3faa3d1282d1ec6928d4e76b62db3d37434c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:57:05 +1100 Subject: [PATCH 643/651] [st7305] Add reflective LCD display driver (#21099) --- CODEOWNERS | 1 + esphome/components/st7305/__init__.py | 2 + esphome/components/st7305/display.py | 224 +++++++++++++++ esphome/components/st7305/models/__init__.py | 0 esphome/components/st7305/models/waveshare.py | 60 ++++ esphome/components/st7305/st7305.cpp | 258 ++++++++++++++++++ esphome/components/st7305/st7305.h | 127 +++++++++ tests/component_tests/st7305/test_st7305.py | 74 +++++ tests/components/st7305/common.h | 77 ++++++ tests/components/st7305/common.yaml | 6 + .../components/st7305/display/test_st7305.cpp | 255 +++++++++++++++++ .../components/st7305/test.esp32-s3-idf.yaml | 8 + .../validate-custom-landscape.esp32-idf.yaml | 21 ++ .../st7305/validate-custom.esp32-idf.yaml | 22 ++ 14 files changed, 1135 insertions(+) create mode 100644 esphome/components/st7305/__init__.py create mode 100644 esphome/components/st7305/display.py create mode 100644 esphome/components/st7305/models/__init__.py create mode 100644 esphome/components/st7305/models/waveshare.py create mode 100644 esphome/components/st7305/st7305.cpp create mode 100644 esphome/components/st7305/st7305.h create mode 100644 tests/component_tests/st7305/test_st7305.py create mode 100644 tests/components/st7305/common.h create mode 100644 tests/components/st7305/common.yaml create mode 100644 tests/components/st7305/display/test_st7305.cpp create mode 100644 tests/components/st7305/test.esp32-s3-idf.yaml create mode 100644 tests/components/st7305/validate-custom-landscape.esp32-idf.yaml create mode 100644 tests/components/st7305/validate-custom.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 3bb01931381..9f9956930d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -602,6 +602,7 @@ esphome/components/ssd1331_spi/* @kbx81 esphome/components/ssd1351_base/* @kbx81 esphome/components/ssd1351_spi/* @kbx81 esphome/components/st7123/* @miniskipper +esphome/components/st7305/* @clydebarrow esphome/components/st7567_base/* @latonita esphome/components/st7567_i2c/* @latonita esphome/components/st7567_spi/* @latonita diff --git a/esphome/components/st7305/__init__.py b/esphome/components/st7305/__init__.py new file mode 100644 index 00000000000..6b48f6a9504 --- /dev/null +++ b/esphome/components/st7305/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@clydebarrow"] +DOMAIN = "st7305" diff --git a/esphome/components/st7305/display.py b/esphome/components/st7305/display.py new file mode 100644 index 00000000000..09ca8cdf7c0 --- /dev/null +++ b/esphome/components/st7305/display.py @@ -0,0 +1,224 @@ +import importlib +import pkgutil + +from esphome import pins +import esphome.codegen as cg +from esphome.components import display, spi +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.mipi import ( + DriverChip, + flatten_sequence, + map_sequence, + model_schema_extractor, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, + CONF_CS_PIN, + CONF_DATA_RATE, + CONF_DC_PIN, + CONF_HEIGHT, + CONF_ID, + CONF_INIT_SEQUENCE, + CONF_LAMBDA, + CONF_MODEL, + CONF_PAGES, + CONF_RESET_PIN, + CONF_ROTATION, + CONF_WIDTH, +) +from esphome.types import ConfigType + +from . import models + +DEPENDENCIES = ["spi"] + +CONF_COLUMN_START = "column_start" +CONF_COLUMN_END = "column_end" +CONF_PORTRAIT = "portrait" +CONF_ROW_START = "row_start" +CONF_ROW_END = "row_end" + +st7305_ns = cg.esphome_ns.namespace("st7305") +ST7305 = st7305_ns.class_("ST7305", display.DisplayBuffer, spi.SPIDevice) + +Orientation = st7305_ns.enum("Orientation") + +# CUSTOM must be registered before the model modules are imported. +DriverChip("CUSTOM") +for module_info in pkgutil.iter_modules(models.__path__): + importlib.import_module(f".models.{module_info.name}", package=__package__) +MODELS = DriverChip.get_models() + +DEFAULT_MODEL = "WAVESHARE-400X300" + + +def _model_schema(model: DriverChip) -> cv.Schema: + iseqconf = ( + cv.Required(CONF_INIT_SEQUENCE) + if model.initsequence is None + else cv.Optional(CONF_INIT_SEQUENCE) + ) + return ( + display.FULL_DISPLAY_SCHEMA.extend( + spi.spi_device_schema( + cs_pin_required=False, + default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), + default_mode="MODE0", + ) + ) + .extend(cv.COMPONENT_SCHEMA) + .extend( + { + cv.GenerateID(): cv.declare_id(ST7305), + cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), + model.option(CONF_CS_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + model.option(CONF_DC_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + cv.Optional(CONF_WIDTH): cv.int_range(min=8, max=1024), + cv.Optional(CONF_HEIGHT): cv.int_range(min=8, max=1024), + cv.Optional(CONF_PORTRAIT): cv.boolean, + cv.Optional(CONF_COLUMN_START): cv.int_range(min=0, max=255), + cv.Optional(CONF_COLUMN_END): cv.int_range(min=0, max=255), + cv.Optional(CONF_ROW_START): cv.int_range(min=0, max=255), + cv.Optional(CONF_ROW_END): cv.int_range(min=0, max=255), + iseqconf: cv.ensure_list(map_sequence), + } + ) + ) + + +def _resolve(config: ConfigType, model: DriverChip) -> None: + """Fill in model defaults and validate the geometry and address window.""" + if (width := config.get(CONF_WIDTH)) is None: + if (width := model.get_default(CONF_WIDTH, None)) is None: + raise cv.Invalid("'width' is required for this model", path=[CONF_WIDTH]) + config[CONF_WIDTH] = width + if (height := config.get(CONF_HEIGHT)) is None: + if (height := model.get_default(CONF_HEIGHT, None)) is None: + raise cv.Invalid("'height' is required for this model", path=[CONF_HEIGHT]) + config[CONF_HEIGHT] = height + + if config.get(CONF_PORTRAIT) is None: + if (default := model.get_default(CONF_PORTRAIT, None)) is None: + raise cv.Invalid( + "'portrait' is required for this model", path=[CONF_PORTRAIT] + ) + config[CONF_PORTRAIT] = default + + if config[CONF_PORTRAIT]: + if width % 12 or height % 2: + raise cv.Invalid( + "portrait panels require a width divisible by 12 and a height divisible by 2" + ) + columns = width // 12 + rows = height // 2 + else: + if width % 2 or height % 12: + raise cv.Invalid( + "landscape panels require a width divisible by 2 and a height divisible by 12" + ) + columns = height // 12 + rows = width // 2 + + column_start = config.get( + CONF_COLUMN_START, model.get_default(CONF_COLUMN_START, 0) + ) + row_start = config.get(CONF_ROW_START, model.get_default(CONF_ROW_START, 0)) + column_end = config.get( + CONF_COLUMN_END, model.get_default(CONF_COLUMN_END, column_start + columns - 1) + ) + row_end = config.get( + CONF_ROW_END, model.get_default(CONF_ROW_END, row_start + rows - 1) + ) + if column_end < column_start or row_end < row_start: + raise cv.Invalid("address window end must not be before its start") + if not ( + 0 <= column_start <= 255 + and 0 <= column_end <= 255 + and 0 <= row_start <= 255 + and 0 <= row_end <= 255 + ): + raise cv.Invalid("address window values must be between 0 and 255") + if (column_end - column_start + 1) * (row_end - row_start + 1) * 3 < ( + width * height // 8 + ): + raise cv.Invalid("address window is too small for the display dimensions") + config[CONF_COLUMN_START] = column_start + config[CONF_COLUMN_END] = column_end + config[CONF_ROW_START] = row_start + config[CONF_ROW_END] = row_end + + if config.get(CONF_INIT_SEQUENCE) is None: + config[CONF_INIT_SEQUENCE] = model.initsequence + + for key, label in ((CONF_CS_PIN, "cs_pin"), (CONF_DC_PIN, "dc_pin")): + if key not in config: + raise cv.Invalid(f"'{label}' is required for this model", path=[key]) + + +def model_schema(config: ConfigType) -> cv.Schema: + """Return the schema for the model selected in the config.""" + return _model_schema(MODELS[config[CONF_MODEL]]) + + +@model_schema_extractor(MODELS, model_schema) +def customise_schema(config: ConfigType) -> ConfigType: + config = cv.Schema( + { + cv.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.one_of( + *MODELS, upper=True + ) + }, + extra=cv.ALLOW_EXTRA, + )(config) + model = MODELS[config[CONF_MODEL]] + config = _model_schema(model)(config) + _resolve(config, model) + + display.add_metadata( + config[CONF_ID], + config[CONF_WIDTH], + config[CONF_HEIGHT], + has_hardware_rotation=False, + byte_order=cv.UNDEFINED, + has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True + or config.get(CONF_PAGES) is not None + or config.get(CONF_LAMBDA) is not None + or config.get(CONF_SHOW_TEST_CARD) is True, + rotation=config.get(CONF_ROTATION, 0), + ) + return config + + +CONFIG_SCHEMA = customise_schema + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "st7305", require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + sequence = flatten_sequence(config[CONF_INIT_SEQUENCE]) + table = cg.shared_progmem_array("st7305_init_sequence", cg.uint8, sequence) + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_MODEL], + config[CONF_WIDTH], + config[CONF_HEIGHT], + Orientation.ORIENTATION_PORTRAIT + if config[CONF_PORTRAIT] + else Orientation.ORIENTATION_LANDSCAPE, + config[CONF_COLUMN_START], + config[CONF_COLUMN_END], + config[CONF_ROW_START], + config[CONF_ROW_END], + table, + len(sequence), + ) + + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=True) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await display.add_writer(var, config) diff --git a/esphome/components/st7305/models/__init__.py b/esphome/components/st7305/models/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/st7305/models/waveshare.py b/esphome/components/st7305/models/waveshare.py new file mode 100644 index 00000000000..e9cde5879a2 --- /dev/null +++ b/esphome/components/st7305/models/waveshare.py @@ -0,0 +1,60 @@ +from esphome.components.mipi import DriverChip, delay + +# Waveshare ESP32-S3-RLCD-4.2, 4.2" reflective panel. The same controller command set is +# used by the 300x400 ST7306 panels. Values follow the vendor demo code, which is the sequence +# proven on this panel; the datasheet's referential sequence is close but not identical. +DriverChip( + "WAVESHARE-400X300", + width=400, + height=300, + portrait=False, + cs_pin=40, + dc_pin=5, + reset_pin=41, + column_start=0x12, + column_end=0x2A, + row_start=0x00, + row_end=0xC7, + initsequence=( + (0xD6, 0x17, 0x02), # NVM load control + (0xD1, 0x01), # booster enable + (0xC0, 0x11, 0x04), # gate voltage + (0xC1, 0x69, 0x69, 0x69, 0x69), # VSHP + (0xC2, 0x19, 0x19, 0x19, 0x19), # VSLP + (0xC4, 0x4B, 0x4B, 0x4B, 0x4B), # VSHN + (0xC5, 0x19, 0x19, 0x19, 0x19), # VSLN + (0xD8, 0x80, 0xE9), # oscillator + (0xB2, 0x02), # frame rate + ( + 0xB3, + 0xE5, + 0xF6, + 0x05, + 0x46, + 0x77, + 0x77, + 0x77, + 0x77, + 0x76, + 0x45, + ), # HPM gate EQ + (0xB4, 0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45), # LPM gate EQ + (0x62, 0x32, 0x03, 0x1F), # gate timing + (0xB7, 0x13), # source EQ + (0xB0, 0x64), # gate line setting, 400 lines + (0x11,), # sleep out + delay(200), + (0xC9, 0x00), # source voltage select + (0x36, 0x48), # MADCTL: MX=1, DO=1 + (0x3A, 0x11), # 1-bit data format, 3 bytes per 24-pixel block + (0xB9, 0x20), # gamma, mono + (0xB8, 0x29), # panel setting + (0x21,), # display inversion on + (0x2A, 0x12, 0x2A), # column address window + (0x2B, 0x00, 0xC7), # row address window + (0x35, 0x00), # tearing effect line on + (0xD0, 0xFF), # auto power down + (0x38,), # high power mode + (0x29,), # display on + ), +) diff --git a/esphome/components/st7305/st7305.cpp b/esphome/components/st7305/st7305.cpp new file mode 100644 index 00000000000..36b58fdb240 --- /dev/null +++ b/esphome/components/st7305/st7305.cpp @@ -0,0 +1,258 @@ +#include "st7305.h" + +#include +#include + +#include "esphome/core/log.h" + +namespace esphome::st7305 { + +ESPHOME_LOG_TAG(TAG, "st7305"); + +void ST7305::setup() { + this->spi_setup(); + this->dc_pin_->setup(); + if (this->reset_pin_ != nullptr) + this->reset_pin_->setup(); + + if (!this->init_buffer_()) + return; + + this->reset_(); +} + +bool ST7305::init_buffer_() { + this->init_internal_(this->buffer_length_); + if (this->buffer_ == nullptr) { + this->mark_failed(LOG_STR("Buffer allocation failed")); + return false; + } + memset(this->buffer_, 0x00, this->buffer_length_); + this->clear_dirty_(); + return true; +} + +void ST7305::reset_() { + static constexpr uint32_t RESET_HIGH_MS = 50; + static constexpr uint32_t RESET_LOW_MS = 20; + static constexpr uint32_t RESET_SETTLE_MS = 50; + static constexpr uint32_t SOFT_RESET_MS = 5; + + if (this->reset_pin_ == nullptr) { + this->enable(); + this->send_command_(0x01); // software reset + this->disable(); + this->set_timeout(STARTUP_TIMEOUT_ID, SOFT_RESET_MS, [this]() { this->send_init_sequence_(); }); + return; + } + this->reset_pin_->digital_write(true); + this->set_timeout(STARTUP_TIMEOUT_ID, RESET_HIGH_MS, [this]() { + this->reset_pin_->digital_write(false); + this->set_timeout(STARTUP_TIMEOUT_ID, RESET_LOW_MS, [this]() { + this->reset_pin_->digital_write(true); + this->set_timeout(STARTUP_TIMEOUT_ID, RESET_SETTLE_MS, [this]() { this->send_init_sequence_(); }); + }); + }); +} + +void ST7305::send_init_sequence_() { + while (this->init_index_ < this->init_sequence_length_) { + if (this->init_sequence_length_ - this->init_index_ < 2) { + this->mark_failed(LOG_STR("Malformed init sequence")); + return; + } + const uint8_t command = progmem_read_byte(&this->init_sequence_[this->init_index_++]); + const uint8_t count = progmem_read_byte(&this->init_sequence_[this->init_index_++]); + if (count == 0xFF) { + // Resume the sequence after the delay instead of blocking the main loop. + this->set_timeout(STARTUP_TIMEOUT_ID, command, [this]() { this->send_init_sequence_(); }); + return; + } + if (this->init_sequence_length_ - this->init_index_ < count) { + this->mark_failed(LOG_STR("Malformed init sequence")); + return; + } + this->enable(); + this->send_command_(command); + if (count != 0) { + this->dc_pin_->digital_write(true); + for (uint8_t i = 0; i < count; i++) + this->write_byte(progmem_read_byte(&this->init_sequence_[this->init_index_ + i])); + } + this->disable(); + this->init_index_ += count; + } + this->finish_init_(); +} + +void ST7305::finish_init_() { + this->write_full_frame_(); + this->clear_dirty_(); + this->ready_ = true; +} + +void ST7305::write_full_frame_() { + const uint8_t caset[] = {this->column_start_, this->column_end_}; + const uint8_t raset[] = {this->row_start_, this->row_end_}; + this->enable(); + this->send_command_(0x2A); + this->send_data_(caset, sizeof(caset)); + this->send_command_(0x2B); + this->send_data_(raset, sizeof(raset)); + this->send_command_(0x2C); + this->send_data_(this->buffer_, this->buffer_length_); + this->disable(); +} + +ST7305::Window ST7305::compute_window_() const { + Window window{}; + if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) { + // RASET rows are x pairs; CASET columns are 12-pixel bands of y blocks. + window.stride = static_cast(this->height_ / 4); + window.outer_start = static_cast(this->x_low_ >> 1); + window.outer_end = static_cast(this->x_high_ >> 1); + const int inv_min = this->height_ - 1 - this->y_high_; + const int inv_max = this->height_ - 1 - this->y_low_; + window.block_start = static_cast((inv_min >> 2) / 3 * 3); + window.block_end = + static_cast(std::min(((inv_max >> 2) / 3) * 3 + 2, static_cast(window.stride) - 1)); + } else { + // RASET rows are y pairs; CASET columns are 12-pixel bands of x blocks. + window.stride = static_cast(this->width_ / 4); + window.outer_start = static_cast(this->y_low_ >> 1); + window.outer_end = static_cast(this->y_high_ >> 1); + const int block_min = this->x_low_ >> 2; + const int block_max = this->x_high_ >> 2; + window.block_start = static_cast(block_min / 3 * 3); + window.block_end = static_cast(std::min((block_max / 3) * 3 + 2, static_cast(window.stride) - 1)); + } + window.caset_start = static_cast(this->column_start_ + window.block_start / 3); + window.caset_end = static_cast(this->column_start_ + window.block_end / 3); + return window; +} + +void ST7305::write_window_(const Window &window) { + const uint8_t caset[] = {window.caset_start, window.caset_end}; + const uint8_t raset[] = {static_cast(this->row_start_ + window.outer_start), + static_cast(this->row_start_ + window.outer_end)}; + this->enable(); + this->send_command_(0x2A); + this->send_data_(caset, sizeof(caset)); + this->send_command_(0x2B); + this->send_data_(raset, sizeof(raset)); + this->send_command_(0x2C); + if (window.block_start == 0 && window.block_end == window.stride - 1) { + // Full-height window: every outer unit's data is contiguous with the next. + this->send_data_(this->buffer_ + window.outer_start * window.stride, + static_cast(window.outer_end - window.outer_start + 1) * window.stride); + } else { + const size_t length = window.block_end - window.block_start + 1; + for (uint16_t outer = window.outer_start; outer <= window.outer_end; outer++) + this->send_data_(this->buffer_ + outer * window.stride + window.block_start, length); + } + this->disable(); +} + +void ST7305::update() { + if (!this->ready_) + return; + + this->do_update_(); + if (!this->is_dirty_()) + return; + + const Window window = this->compute_window_(); + if (window.data_length() > this->buffer_length_ / 2) { + this->write_full_frame_(); + } else { + this->write_window_(window); + } + this->clear_dirty_(); +} + +void HOT ST7305::draw_absolute_pixel_internal(int x, int y, Color color) { + if (x < 0 || y < 0 || x >= this->width_ || y >= this->height_) + return; + size_t index; + uint8_t mask; + if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) { + const int inv_y = this->height_ - 1 - y; + index = static_cast(x >> 1) * (this->height_ / 4) + (inv_y >> 2); + mask = static_cast(1 << (7 - (((inv_y & 3) << 1) | (x & 1)))); + } else { + index = static_cast(y >> 1) * (this->width_ / 4) + (x >> 2); + mask = static_cast(1 << (7 - (((x & 3) << 1) | (y & 1)))); + } + // The init sequence enables INVON, so a set bit is white and a clear bit is black. + uint8_t &value = this->buffer_[index]; + const uint8_t previous = value; + if (color.is_on()) { + value |= mask; + } else { + value &= ~mask; + } + if (value == previous) + return; + this->extend_dirty_(x, y, x, y); +} + +void ST7305::fill(Color color) { + if (this->get_clipping().is_set()) { + display::Display::fill(color); + return; + } + if (this->buffer_ == nullptr) + return; + + const uint8_t value = color.is_on() ? 0xFF : 0x00; + size_t first = this->buffer_length_; + size_t last = 0; + for (size_t index = 0; index < this->buffer_length_; index++) { + if (this->buffer_[index] == value) + continue; + this->buffer_[index] = value; + if (first == this->buffer_length_) + first = index; + last = index; + } + if (first == this->buffer_length_) + return; + + // Widen the changed index range to whole outer units, and the other axis to the full + // display. The extra pixels are current in the buffer, so sending them is harmless. + if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) { + const int stride = this->height_ / 4; + this->extend_dirty_(static_cast(first / stride) * 2, 0, static_cast(last / stride) * 2 + 1, + this->height_ - 1); + } else { + const int stride = this->width_ / 4; + this->extend_dirty_(0, static_cast(first / stride) * 2, this->width_ - 1, + static_cast(last / stride) * 2 + 1); + } +} + +void ST7305::extend_dirty_(int x0, int y0, int x1, int y1) { + if (x0 < this->x_low_) + this->x_low_ = x0; + if (x1 > this->x_high_) + this->x_high_ = x1; + if (y0 < this->y_low_) + this->y_low_ = y0; + if (y1 > this->y_high_) + this->y_high_ = y1; +} + +void ST7305::dump_config() { + LOG_DISPLAY("", "ST7305", this); + ESP_LOGCONFIG(TAG, " Model: %s", this->model_); + ESP_LOGCONFIG(TAG, " Orientation: %s", + this->orientation_ == Orientation::ORIENTATION_LANDSCAPE ? LOG_STR_LITERAL("landscape") + : LOG_STR_LITERAL("portrait")); + ESP_LOGCONFIG(TAG, " Window: columns %u..%u, rows %u..%u", this->column_start_, this->column_end_, this->row_start_, + this->row_end_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::st7305 diff --git a/esphome/components/st7305/st7305.h b/esphome/components/st7305/st7305.h new file mode 100644 index 00000000000..02b00b729df --- /dev/null +++ b/esphome/components/st7305/st7305.h @@ -0,0 +1,127 @@ +#pragma once + +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::st7305 { + +enum Orientation : uint8_t { + ORIENTATION_LANDSCAPE = 0, + ORIENTATION_PORTRAIT = 1, +}; + +/// Sitronix ST7305/ST7306 reflective monochrome LCD, 1 bit per pixel. +/// +/// The controller RAM is addressed in 12-column x 2-row blocks (3 bytes per address). With a +/// landscape panel one byte covers 2 pixels in x and 4 in y; with a portrait panel it covers +/// 4 pixels in x and 2 in y. +class ST7305 : public display::DisplayBuffer, + public spi::SPIDevice { + public: + ST7305(const char *model, int width, int height, Orientation orientation, uint8_t column_start, uint8_t column_end, + uint8_t row_start, uint8_t row_end, const uint8_t *init_sequence, size_t init_sequence_length) + : model_(model), + width_(width), + height_(height), + orientation_(orientation), + column_start_(column_start), + column_end_(column_end), + row_start_(row_start), + row_end_(row_end), + buffer_length_(static_cast(width) * static_cast(height) / 8), + init_sequence_(init_sequence), + init_sequence_length_(init_sequence_length) {} + + void setup() override; + void update() override; + void dump_config() override; + + void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + + void fill(Color color) override; + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_BINARY; } + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + protected: + /// An addressable write window. RASET addresses are the outer index (x pairs in landscape, + /// y pairs in portrait); CASET addresses are 12-pixel bands of the inner index. + struct Window { + uint16_t outer_start; + uint16_t outer_end; + uint16_t block_start; + uint16_t block_end; + uint16_t stride; + uint8_t caset_start; + uint8_t caset_end; + + size_t data_length() const { + return static_cast(this->outer_end - this->outer_start + 1) * + static_cast(this->block_end - this->block_start + 1); + } + }; + + void draw_absolute_pixel_internal(int x, int y, Color color) override; + int get_width_internal() override { return this->width_; } + int get_height_internal() override { return this->height_; } + + bool init_buffer_(); + void reset_(); + void send_init_sequence_(); + void finish_init_(); + void write_full_frame_(); + void write_window_(const Window &window); + Window compute_window_() const; + + /// Scheduler id for the non-blocking reset and init sequence. The steps are sequential, + /// so one id is enough; a new timeout replaces any pending one. + static constexpr uint32_t STARTUP_TIMEOUT_ID = 0; + + void send_command_(uint8_t command) { + this->dc_pin_->digital_write(false); + this->write_byte(command); + } + void send_data_(const uint8_t *data, size_t length) { + this->dc_pin_->digital_write(true); + this->write_array(data, length); + } + + bool is_dirty_() const { return this->x_high_ >= this->x_low_ && this->y_high_ >= this->y_low_; } + void clear_dirty_() { + this->x_low_ = this->width_; + this->x_high_ = -1; + this->y_low_ = this->height_; + this->y_high_ = -1; + } + void extend_dirty_(int x0, int y0, int x1, int y1); + + GPIOPin *dc_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + + const char *model_; + int width_; + int height_; + Orientation orientation_; + uint8_t column_start_; + uint8_t column_end_; + uint8_t row_start_; + uint8_t row_end_; + size_t buffer_length_; + const uint8_t *init_sequence_; + size_t init_sequence_length_; + size_t init_index_{0}; + bool ready_{false}; + + int x_low_{0}; + int x_high_{-1}; + int y_low_{0}; + int y_high_{-1}; +}; + +} // namespace esphome::st7305 diff --git a/tests/component_tests/st7305/test_st7305.py b/tests/component_tests/st7305/test_st7305.py new file mode 100644 index 00000000000..016252cdf28 --- /dev/null +++ b/tests/component_tests/st7305/test_st7305.py @@ -0,0 +1,74 @@ +"""Tests for the st7305 configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.st7305.display import ( + CONF_COLUMN_END, + CONF_COLUMN_START, + CONF_PORTRAIT, + CONF_ROW_END, + MODELS, + _resolve, +) +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, CONF_HEIGHT, CONF_WIDTH + + +def _config(**overrides): + config = { + CONF_WIDTH: 400, + CONF_HEIGHT: 300, + CONF_PORTRAIT: False, + CONF_CS_PIN: 1, + CONF_DC_PIN: 2, + } + config.update(overrides) + return config + + +def test_landscape_derives_window() -> None: + config = _config() + _resolve(config, MODELS["CUSTOM"]) + assert config[CONF_COLUMN_START] == 0 + assert config[CONF_COLUMN_END] == 24 + assert config[CONF_ROW_END] == 199 + + +@pytest.mark.parametrize("height", [160, 200, 296, 308]) +def test_landscape_rejects_inner_not_multiple_of_12(height: int) -> None: + with pytest.raises(cv.Invalid, match="height divisible by 12"): + _resolve(_config(**{CONF_HEIGHT: height}), MODELS["CUSTOM"]) + + +def test_portrait_derives_window() -> None: + config = _config( + **{ + CONF_WIDTH: 168, + CONF_HEIGHT: 384, + CONF_PORTRAIT: True, + CONF_COLUMN_START: 0x17, + } + ) + _resolve(config, MODELS["CUSTOM"]) + assert config[CONF_COLUMN_END] == 0x24 + assert config[CONF_ROW_END] == 191 + + +@pytest.mark.parametrize("width", [160, 200, 208]) +def test_portrait_rejects_inner_not_multiple_of_12(width: int) -> None: + with pytest.raises(cv.Invalid, match="width divisible by 12"): + _resolve( + _config( + **{ + CONF_WIDTH: width, + CONF_HEIGHT: 384, + CONF_PORTRAIT: True, + } + ), + MODELS["CUSTOM"], + ) + + +def test_window_ends_must_fit_in_a_byte() -> None: + with pytest.raises(cv.Invalid, match="between 0 and 255"): + _resolve(_config(**{CONF_WIDTH: 1024, CONF_HEIGHT: 300}), MODELS["CUSTOM"]) diff --git a/tests/components/st7305/common.h b/tests/components/st7305/common.h new file mode 100644 index 00000000000..856e1b83b01 --- /dev/null +++ b/tests/components/st7305/common.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/spi/spi.h" +#include "esphome/components/st7305/st7305.h" +#include "esphome/core/hal.h" + +namespace esphome::st7305::testing { + +class TestableST7305 : public ST7305 { + public: + TestableST7305(int width = 400, int height = 300, Orientation orientation = Orientation::ORIENTATION_LANDSCAPE, + uint8_t column_start = 0x12, uint8_t column_end = 0x2A, uint8_t row_start = 0x00, + uint8_t row_end = 0xC7) + : ST7305("TEST", width, height, orientation, column_start, column_end, row_start, row_end, nullptr, 0) {} + + bool init_buffer() { return this->init_buffer_(); } + void clear_dirty() { this->clear_dirty_(); } + bool is_dirty() const { return this->is_dirty_(); } + int x_low() const { return this->x_low_; } + int x_high() const { return this->x_high_; } + int y_low() const { return this->y_low_; } + int y_high() const { return this->y_high_; } + Window compute_window() const { return this->compute_window_(); } + void write_window() { this->write_window_(this->compute_window_()); } + uint8_t *buffer() { return this->buffer_; } + void attach_spi(spi::SPIDelegate *delegate, GPIOPin *dc_pin) { + this->delegate_ = delegate; + this->set_dc_pin(dc_pin); + } + + size_t buffer_length() const { return this->buffer_length_; } + int width() const { return this->width_; } + int height() const { return this->height_; } +}; + +/// GPIO pin that remembers the last level written to it. +class RecordingPin : public GPIOPin { + public: + void setup() override {} + void pin_mode(gpio::Flags flags) override {} + gpio::Flags get_flags() const override { return gpio::Flags::FLAG_NONE; } + bool digital_read() override { return false; } + void digital_write(bool value) override { this->level = value; } + size_t dump_summary(char *buffer, size_t len) const override { return snprintf(buffer, len, "recording"); } + + bool level{true}; +}; + +/// SPI delegate that records every payload and whether it was a command or data phase. +class RecordingDelegate : public spi::SPIDelegate { + public: + explicit RecordingDelegate(const RecordingPin *dc) + : spi::SPIDelegate(10'000'000, spi::BIT_ORDER_MSB_FIRST, spi::MODE0, nullptr), dc_(dc) {} + + uint8_t transfer(uint8_t data) override { + this->record_(&data, 1); + return 0; + } + void write_array(const uint8_t *ptr, size_t length) override { this->record_(ptr, length); } + + std::vector bytes; + std::vector> chunks; // (data phase, length) + + protected: + void record_(const uint8_t *ptr, size_t length) { + this->chunks.emplace_back(this->dc_->level, length); + this->bytes.insert(this->bytes.end(), ptr, ptr + length); + } + + const RecordingPin *dc_; +}; + +} // namespace esphome::st7305::testing diff --git a/tests/components/st7305/common.yaml b/tests/components/st7305/common.yaml new file mode 100644 index 00000000000..064ddcb45aa --- /dev/null +++ b/tests/components/st7305/common.yaml @@ -0,0 +1,6 @@ +display: + - platform: st7305 + model: WAVESHARE-400X300 + id: st7305_display + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/st7305/display/test_st7305.cpp b/tests/components/st7305/display/test_st7305.cpp new file mode 100644 index 00000000000..e447e09e14d --- /dev/null +++ b/tests/components/st7305/display/test_st7305.cpp @@ -0,0 +1,255 @@ +#include + +#include "../common.h" + +namespace esphome::st7305::testing { + +class ST7305Test : public ::testing::Test { + protected: + void SetUp() override { ASSERT_TRUE(this->display_.init_buffer()); } + + void reset_screen_() { + this->display_.fill(Color::BLACK); + this->display_.clear_dirty(); + } + + TestableST7305 display_; +}; + +TEST_F(ST7305Test, FillSetsAllBytes) { + this->display_.fill(Color::WHITE); + for (size_t i = 0; i < this->display_.buffer_length(); i++) + EXPECT_EQ(this->display_.buffer()[i], 0xFF) << "byte " << i; + + this->display_.fill(Color::BLACK); + for (size_t i = 0; i < this->display_.buffer_length(); i++) + EXPECT_EQ(this->display_.buffer()[i], 0x00) << "byte " << i; +} + +TEST_F(ST7305Test, PixelMapping) { + struct PixelCase { + int x; + int y; + size_t index; + uint8_t mask; + }; + const PixelCase cases[] = { + {0, 299, 0, 0x80}, {1, 299, 0, 0x40}, {0, 298, 0, 0x20}, {0, 297, 0, 0x08}, {0, 296, 0, 0x02}, + {2, 299, 75, 0x80}, {0, 0, 74, 0x02}, {1, 0, 74, 0x01}, {200, 150, 7537, 0x20}, {399, 0, 14999, 0x01}, + }; + + for (const auto &pixel : cases) { + this->display_.draw_pixel_at(pixel.x, pixel.y, Color::WHITE); + EXPECT_EQ(this->display_.buffer()[pixel.index], pixel.mask) << "pixel " << pixel.x << "," << pixel.y; + this->display_.draw_pixel_at(pixel.x, pixel.y, Color::BLACK); + EXPECT_EQ(this->display_.buffer()[pixel.index], 0x00) << "pixel " << pixel.x << "," << pixel.y; + } +} + +TEST_F(ST7305Test, ClearingOnePixelLeavesNeighbours) { + this->display_.draw_pixel_at(0, 299, Color::WHITE); + this->display_.draw_pixel_at(1, 299, Color::WHITE); + EXPECT_EQ(this->display_.buffer()[0], 0xC0); + + this->display_.draw_pixel_at(0, 299, Color::BLACK); + EXPECT_EQ(this->display_.buffer()[0], 0x40); +} + +TEST_F(ST7305Test, DirtyBoundsFollowChanges) { + EXPECT_FALSE(this->display_.is_dirty()); + + this->display_.draw_pixel_at(10, 20, Color::WHITE); + EXPECT_TRUE(this->display_.is_dirty()); + EXPECT_EQ(this->display_.x_low(), 10); + EXPECT_EQ(this->display_.x_high(), 10); + EXPECT_EQ(this->display_.y_low(), 20); + EXPECT_EQ(this->display_.y_high(), 20); + + this->display_.clear_dirty(); + this->display_.draw_pixel_at(10, 20, Color::WHITE); + EXPECT_FALSE(this->display_.is_dirty()); + + this->display_.draw_pixel_at(10, 20, Color::BLACK); + EXPECT_TRUE(this->display_.is_dirty()); + + this->display_.clear_dirty(); + this->display_.draw_pixel_at(10, 20, Color::WHITE); + this->display_.draw_pixel_at(100, 200, Color::WHITE); + EXPECT_EQ(this->display_.x_low(), 10); + EXPECT_EQ(this->display_.x_high(), 100); + EXPECT_EQ(this->display_.y_low(), 20); + EXPECT_EQ(this->display_.y_high(), 200); +} + +TEST_F(ST7305Test, FillOnlyDirtiesChangedBytes) { + this->display_.clear_dirty(); + this->display_.fill(Color::BLACK); + EXPECT_FALSE(this->display_.is_dirty()); + + this->display_.fill(Color::WHITE); + EXPECT_TRUE(this->display_.is_dirty()); + EXPECT_EQ(this->display_.x_low(), 0); + EXPECT_EQ(this->display_.x_high(), 399); + EXPECT_EQ(this->display_.y_low(), 0); + EXPECT_EQ(this->display_.y_high(), 299); + + this->display_.clear_dirty(); + this->display_.fill(Color::WHITE); + EXPECT_FALSE(this->display_.is_dirty()); +} + +TEST_F(ST7305Test, WindowAlignsToAddressBlocks) { + this->reset_screen_(); + this->display_.draw_pixel_at(0, 299, Color::WHITE); + this->display_.draw_pixel_at(1, 299, Color::WHITE); + + const auto window = this->display_.compute_window(); + EXPECT_EQ(window.outer_start, 0); + EXPECT_EQ(window.outer_end, 0); + EXPECT_EQ(window.block_start, 0); + EXPECT_EQ(window.block_end, 2); + EXPECT_EQ(window.caset_start, 0x12); + EXPECT_EQ(window.caset_end, 0x12); + EXPECT_EQ(window.data_length(), 3u); + + this->reset_screen_(); + this->display_.draw_pixel_at(200, 150, Color::WHITE); + + const auto middle = this->display_.compute_window(); + EXPECT_EQ(middle.outer_start, 100); + EXPECT_EQ(middle.outer_end, 100); + EXPECT_EQ(middle.block_start, 36); + EXPECT_EQ(middle.block_end, 38); + EXPECT_EQ(middle.caset_start, 0x1E); + EXPECT_EQ(middle.caset_end, 0x1E); + EXPECT_EQ(middle.data_length(), 3u); + + this->reset_screen_(); + this->display_.draw_pixel_at(0, 0, Color::WHITE); + this->display_.draw_pixel_at(0, 299, Color::WHITE); + + const auto full_height = this->display_.compute_window(); + EXPECT_EQ(full_height.outer_start, 0); + EXPECT_EQ(full_height.outer_end, 0); + EXPECT_EQ(full_height.block_start, 0); + EXPECT_EQ(full_height.block_end, 74); + EXPECT_EQ(full_height.caset_start, 0x12); + EXPECT_EQ(full_height.caset_end, 0x2A); + EXPECT_EQ(full_height.data_length(), 75u); +} + +TEST_F(ST7305Test, WriteWindowEmitsWindowCommands) { + for (size_t i = 0; i < this->display_.buffer_length(); i++) + this->display_.buffer()[i] = static_cast(i & 0xFF); + + RecordingPin dc_pin; + RecordingDelegate delegate(&dc_pin); + this->display_.attach_spi(&delegate, &dc_pin); + + this->display_.draw_pixel_at(200, 151, Color::WHITE); + this->display_.write_window(); + + const uint8_t expected[] = { + 0x2A, + 0x1E, + 0x1E, + 0x2B, + 100, + 100, + 0x2C, + this->display_.buffer()[100 * 75 + 36], + this->display_.buffer()[100 * 75 + 37], + this->display_.buffer()[100 * 75 + 38], + }; + ASSERT_EQ(delegate.bytes.size(), sizeof(expected)); + for (size_t i = 0; i < sizeof(expected); i++) + EXPECT_EQ(delegate.bytes[i], expected[i]) << "byte " << i; + + ASSERT_EQ(delegate.chunks.size(), 6u); + EXPECT_EQ(delegate.chunks[0], std::make_pair(false, size_t{1})); + EXPECT_EQ(delegate.chunks[1], std::make_pair(true, size_t{2})); + EXPECT_EQ(delegate.chunks[2], std::make_pair(false, size_t{1})); + EXPECT_EQ(delegate.chunks[3], std::make_pair(true, size_t{2})); + EXPECT_EQ(delegate.chunks[4], std::make_pair(false, size_t{1})); + EXPECT_EQ(delegate.chunks[5], std::make_pair(true, size_t{3})); +} + +TEST_F(ST7305Test, PortraitPixelMapping) { + TestableST7305 portrait(168, 384, Orientation::ORIENTATION_PORTRAIT, 0x17, 0x24, 0x00, 0xBF); + ASSERT_TRUE(portrait.init_buffer()); + ASSERT_EQ(portrait.get_width(), 168); + ASSERT_EQ(portrait.get_height(), 384); + + struct PixelCase { + int x; + int y; + size_t index; + uint8_t mask; + }; + const PixelCase cases[] = { + {0, 0, 0, 0x80}, {1, 0, 0, 0x20}, {2, 0, 0, 0x08}, {3, 0, 0, 0x02}, + {0, 1, 0, 0x40}, {4, 0, 1, 0x80}, {0, 2, 42, 0x80}, {167, 383, 8063, 0x01}, + }; + + for (const auto &pixel : cases) { + portrait.draw_pixel_at(pixel.x, pixel.y, Color::WHITE); + EXPECT_EQ(portrait.buffer()[pixel.index], pixel.mask) << "pixel " << pixel.x << "," << pixel.y; + portrait.draw_pixel_at(pixel.x, pixel.y, Color::BLACK); + EXPECT_EQ(portrait.buffer()[pixel.index], 0x00) << "pixel " << pixel.x << "," << pixel.y; + } +} + +TEST_F(ST7305Test, PortraitWindowAlignsToAddressBlocks) { + TestableST7305 portrait(168, 384, Orientation::ORIENTATION_PORTRAIT, 0x17, 0x24, 0x00, 0xBF); + ASSERT_TRUE(portrait.init_buffer()); + + portrait.draw_pixel_at(84, 192, Color::WHITE); + const auto window = portrait.compute_window(); + EXPECT_EQ(window.outer_start, 96); + EXPECT_EQ(window.outer_end, 96); + EXPECT_EQ(window.stride, 42); + EXPECT_EQ(window.block_start, 21); + EXPECT_EQ(window.block_end, 23); + EXPECT_EQ(window.caset_start, 0x1E); + EXPECT_EQ(window.caset_end, 0x1E); + EXPECT_EQ(window.data_length(), 3u); + + portrait.fill(Color::BLACK); + portrait.clear_dirty(); + portrait.draw_pixel_at(0, 0, Color::WHITE); + portrait.draw_pixel_at(3, 1, Color::WHITE); + const auto corner = portrait.compute_window(); + EXPECT_EQ(corner.outer_start, 0); + EXPECT_EQ(corner.outer_end, 0); + EXPECT_EQ(corner.block_start, 0); + EXPECT_EQ(corner.block_end, 2); + EXPECT_EQ(corner.caset_start, 0x17); + EXPECT_EQ(corner.caset_end, 0x17); + EXPECT_EQ(corner.data_length(), 3u); + + portrait.fill(Color::BLACK); + portrait.clear_dirty(); + portrait.draw_pixel_at(167, 200, Color::WHITE); + const auto right_edge = portrait.compute_window(); + EXPECT_EQ(right_edge.outer_start, 100); + EXPECT_EQ(right_edge.outer_end, 100); + EXPECT_EQ(right_edge.block_start, 39); + EXPECT_EQ(right_edge.block_end, 41); + EXPECT_EQ(right_edge.caset_start, 0x24); + EXPECT_EQ(right_edge.caset_end, 0x24); + EXPECT_EQ(right_edge.data_length(), 3u); + + portrait.fill(Color::BLACK); + portrait.clear_dirty(); + portrait.draw_pixel_at(167, 383, Color::WHITE); + const auto bottom_right = portrait.compute_window(); + EXPECT_EQ(bottom_right.outer_start, 191); + EXPECT_EQ(bottom_right.outer_end, 191); + EXPECT_EQ(bottom_right.block_start, 39); + EXPECT_EQ(bottom_right.block_end, 41); + EXPECT_EQ(bottom_right.caset_start, 0x24); + EXPECT_EQ(bottom_right.caset_end, 0x24); + EXPECT_EQ(bottom_right.data_length(), 3u); +} + +} // namespace esphome::st7305::testing diff --git a/tests/components/st7305/test.esp32-s3-idf.yaml b/tests/components/st7305/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..0cc297c380e --- /dev/null +++ b/tests/components/st7305/test.esp32-s3-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + clk_pin: GPIO18 + mosi_pin: GPIO17 + miso_pin: GPIO16 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + st7305: !include common.yaml diff --git a/tests/components/st7305/validate-custom-landscape.esp32-idf.yaml b/tests/components/st7305/validate-custom-landscape.esp32-idf.yaml new file mode 100644 index 00000000000..55314946e14 --- /dev/null +++ b/tests/components/st7305/validate-custom-landscape.esp32-idf.yaml @@ -0,0 +1,21 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: st7305 + model: CUSTOM + width: 400 + height: 300 + portrait: false + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + init_sequence: + - [0x01] + - [0x11, 0x00] + - [0x29] + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/st7305/validate-custom.esp32-idf.yaml b/tests/components/st7305/validate-custom.esp32-idf.yaml new file mode 100644 index 00000000000..c29874aa190 --- /dev/null +++ b/tests/components/st7305/validate-custom.esp32-idf.yaml @@ -0,0 +1,22 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: st7305 + model: CUSTOM + width: 168 + height: 384 + portrait: true + column_start: 0x17 + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + init_sequence: + - [0x01] + - [0x11, 0x00] + - [0x29] + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); From 15b988be9c6c65970daec59f30d5b5a16df5cb8d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:44:22 +1100 Subject: [PATCH 644/651] [cst9220] Fix long press reported as several short presses (#21034) --- .../touchscreen/cst9220_touchscreen.cpp | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp index 6d869ec5ea7..766bc38929c 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -5,6 +5,10 @@ namespace esphome::cst9220 { +// Id 0 is the base Touchscreen class touch timeout. +static constexpr uint32_t REREAD_TIMEOUT_ID = 1; +static constexpr uint32_t REREAD_DELAY_MS = 20; + void CST9220Touchscreen::setup() { if (this->reset_pin_ != nullptr) { this->reset_pin_->setup(); @@ -92,15 +96,15 @@ void CST9220Touchscreen::update_touches() { return; } - // Acknowledge the report so the controller can prepare the next one. - uint8_t ack = TOUCH_ACK; - this->write_register16(REG_TOUCH_DATA, &ack, 1); - - // A valid report carries the ACK marker at offset 6; offset 0 holds the first - // point and must be neither the ACK marker nor empty. Anything else means no - // valid touch data this cycle, which we report as zero touches (not a skip). - if (data[0] == TOUCH_ACK || data[0] == 0x00 || data[6] != TOUCH_ACK) + // A valid report carries the marker at offset 6. Writing an acknowledge back makes the controller return + // invalid frames during a long press, so none is written. An invalid frame keeps the previous points; while + // a finger is down, read again shortly so a lost release frame does not leave the touch held. + if (data[6] != TOUCH_ACK) { + this->skip_update_ = true; + if (!this->touches_.empty()) + this->set_timeout(REREAD_TIMEOUT_ID, REREAD_DELAY_MS, [this] { this->store_.touched = true; }); return; + } uint8_t num_touches = data[5] & 0x7F; if (num_touches > CST9220_MAX_TOUCHES) From 1cd56419007e2ddb269a04f7c0fcf02bcce062c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 06:25:30 -1000 Subject: [PATCH 645/651] [web_server] Keep the remaining ESP8266 web server strings in flash (#21113) --- .../captive_portal/captive_portal.cpp | 8 ++--- esphome/components/web_server/web_server.cpp | 35 +++++-------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 16ebd407625..d169dcbf6ca 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -58,8 +58,8 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { request->send(stream); } void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { - const auto &ssid = request->arg("ssid"); - const auto &psk = request->arg("psk"); + const auto &ssid = request->arg(ESPHOME_F("ssid")); + const auto &psk = request->arg(ESPHOME_F("psk")); ESP_LOGI(TAG, "Requested WiFi Settings Change:\n" " SSID='%s'\n" @@ -117,11 +117,7 @@ void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { // All other requests get the captive portal page // This includes OS captive portal detection endpoints which will trigger // the captive portal when they don't receive their expected responses -#ifndef USE_ESP8266 auto *response = req->beginResponse(200, ESPHOME_F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#else - auto *response = req->beginResponse_P(200, ESPHOME_F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#endif #ifdef USE_CAPTIVE_PORTAL_GZIP response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip")); #else diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 48a2ce4a63b..4546ead98b2 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -350,7 +350,7 @@ void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, size_t } void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServerRequest *request) { - DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, "/events"); + DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, ESPHOME_F("/events")); this->push_back(es); es->onConnect([this, es](AsyncEventSourceClient *client) { this->on_client_connect_(es); }); @@ -533,11 +533,7 @@ float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f #ifdef USE_WEBSERVER_LOCAL void WebServer::handle_index_request(AsyncWebServerRequest *request) { -#ifndef USE_ESP8266 - AsyncWebServerResponse *response = request->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); -#else - AsyncWebServerResponse *response = request->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); -#endif + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); #ifdef USE_WEBSERVER_GZIP response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip")); #else @@ -547,13 +543,8 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #elif USE_WEBSERVER_VERSION >= 2 void WebServer::handle_index_request(AsyncWebServerRequest *request) { -#ifndef USE_ESP8266 - AsyncWebServerResponse *response = - request->beginResponse(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); -#else - AsyncWebServerResponse *response = - request->beginResponse_P(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); -#endif + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("text/html"), ESPHOME_WEBSERVER_INDEX_HTML, + ESPHOME_WEBSERVER_INDEX_HTML_SIZE); // No gzip header here because the HTML file is so small request->send(response); } @@ -625,13 +616,8 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_CSS_INCLUDE void WebServer::handle_css_request(AsyncWebServerRequest *request) { -#ifndef USE_ESP8266 - AsyncWebServerResponse *response = - request->beginResponse(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); -#else - AsyncWebServerResponse *response = - request->beginResponse_P(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); -#endif + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("text/css"), ESPHOME_WEBSERVER_CSS_INCLUDE, + ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip")); request->send(response); } @@ -639,13 +625,8 @@ void WebServer::handle_css_request(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_JS_INCLUDE void WebServer::handle_js_request(AsyncWebServerRequest *request) { -#ifndef USE_ESP8266 - AsyncWebServerResponse *response = - request->beginResponse(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); -#else - AsyncWebServerResponse *response = - request->beginResponse_P(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); -#endif + AsyncWebServerResponse *response = request->beginResponse( + 200, ESPHOME_F("text/javascript"), ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip")); request->send(response); } From 8ffdbfd4ce1028015e2d8f15529e26afd1fc453d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:48:32 -1000 Subject: [PATCH 646/651] [homeassistant] Judge a light report against the transition it arrived during (#21100) --- .../light/homeassistant_light.cpp | 51 +++-- .../homeassistant/light/homeassistant_light.h | 20 +- .../test_api_homeassistant_light.py | 206 ++++++++++-------- 3 files changed, 161 insertions(+), 116 deletions(-) diff --git a/esphome/components/homeassistant/light/homeassistant_light.cpp b/esphome/components/homeassistant/light/homeassistant_light.cpp index 6ebe0cadbfb..21823bb9097 100644 --- a/esphome/components/homeassistant/light/homeassistant_light.cpp +++ b/esphome/components/homeassistant/light/homeassistant_light.cpp @@ -131,9 +131,7 @@ void HomeassistantLight::setup() { ESP_LOGW(TAG, "'%s': Can't parse '%s' as state!", this->entity_id_, value.c_str()); return; } - const bool state = on_off == PARSE_ON; - this->on_report_(this->imported_.state != state); - this->imported_.state = state; + this->import_(this->imported_.state, optional(on_off == PARSE_ON)); }); server->subscribe_home_assistant_state(this->entity_id_, "color_mode", [this](StringRef value) { if (is_unset(value)) { @@ -144,19 +142,16 @@ void HomeassistantLight::setup() { ESP_LOGE(TAG, "'%s': Can't parse 'color_mode' value '%s' as color mode!", this->entity_id_, value.c_str()); return; } - this->on_report_(this->imported_.color_mode != *mode); - this->imported_.color_mode = *mode; + this->import_(this->imported_.color_mode, *mode); }); server->subscribe_home_assistant_state(this->entity_id_, "brightness", [this](StringRef value) { if (auto brightness = this->parse_integer_("brightness", value, 255)) { - this->on_report_(this->imported_.brightness != *brightness); - this->imported_.brightness = *brightness; + this->import_(this->imported_.brightness, static_cast(*brightness)); } }); server->subscribe_home_assistant_state(this->entity_id_, "color_temp_kelvin", [this](StringRef value) { if (auto kelvin = this->parse_integer_("color_temp_kelvin", value, UINT16_MAX)) { - this->on_report_(this->imported_.color_temp_kelvin != *kelvin); - this->imported_.color_temp_kelvin = *kelvin; + this->import_(this->imported_.color_temp_kelvin, *kelvin); } }); server->subscribe_home_assistant_state(this->entity_id_, "rgb_color", [this](StringRef value) { @@ -202,11 +197,19 @@ optional HomeassistantLight::parse_integer_(const char *name, StringRe void HomeassistantLight::on_report_(bool changed) { // A report that changed an attribute answers the change that armed the echo timeout; a repeated // value (or a trait) does not, so a refused change still gets its warning - if (changed && this->echo_pending_) { - this->echo_pending_ = false; - this->cancel_timeout(ECHO_TIMEOUT_ID); + if (changed) { + if (this->echo_pending_) { + this->echo_pending_ = false; + this->cancel_timeout(ECHO_TIMEOUT_ID); + } + // Noted as the report arrives, not when the deferred apply runs: a call read from the same loop + // pass before this report is older than it, one read after it is newer and must not pass for + // the transition the report is held for + this->report_in_transition_ = this->state_->is_transitioning(); + if (this->report_in_transition_) { + this->held_hash_ = state_hash(this->state_->remote_values); + } } - this->report_changed_ |= changed; this->schedule_apply_(); } @@ -255,9 +258,8 @@ void HomeassistantLight::apply_imported_() { if (!this->state_->set_external_state(values)) { // A report that changed something during a transition or flash is applied once it ends, in // place of the values it ends at; a trait or a repeated report has nothing newer to say - if (this->state_->is_transitioning() && this->report_changed_) { + if (this->report_in_transition_) { this->report_held_ = true; - this->held_hash_ = state_hash(this->state_->remote_values); return; } // A local change still waits for LightState::loop() to send it. Home Assistant reports again @@ -266,7 +268,7 @@ void HomeassistantLight::apply_imported_() { ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_); return; } - this->report_changed_ = false; + this->report_in_transition_ = false; ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_); } @@ -289,15 +291,17 @@ void HomeassistantLight::update_state(LightState *state) { } this->initial_seen_ = true; } - // A call without a transition replaces whatever ran, so a report held for it is older than the call + // A call without a transition replaces whatever ran, so a report held for it, or still waiting to + // be applied, is older than the call if (!state->is_transitioning()) { this->report_held_ = false; + this->report_in_transition_ = false; } } bool HomeassistantLight::is_imported_(const Attributes &outgoing) const { const Attributes &imported = this->imported_; - if (!imported.state.has_value() || !outgoing.state.has_value() || *imported.state != *outgoing.state) { + if (!outgoing.state.has_value() || imported.state != outgoing.state) { return false; } if (!*outgoing.state) { @@ -346,7 +350,7 @@ void HomeassistantLight::write_state(LightState *state) { const bool transitioned = this->transitioned_; this->report_held_ = false; this->transitioned_ = false; - this->report_changed_ = false; // this change is newer than any report so far, held or dropped + this->report_in_transition_ = false; // this change is newer than any report so far, held or dropped // Home Assistant changed meanwhile; its report is newer than what the transition ends at or // what the flash goes back to if (held) { @@ -430,10 +434,10 @@ void HomeassistantLight::write_state(LightState *state) { entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); - auto add_number = [&](ProgmemStringRef key, auto &buf, unsigned number) { + auto add_number = [&](ProgmemStringRef key, char *buf, size_t size, unsigned number) { auto &entry = resp.data.emplace_back(); entry.key = key; - entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, sizeof(buf), 0, "%u", number))); + entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, size, 0, "%u", number))); }; if (!*outgoing.state) { resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_OFF); @@ -443,7 +447,7 @@ void HomeassistantLight::write_state(LightState *state) { // The level goes under "white" in that mode, which also selects the mode add_number(outgoing.color_mode == ColorMode::WHITE ? ProgmemStringRef::from_lit(WHITE_KEY) : ProgmemStringRef::from_lit(BRIGHTNESS_KEY), - level_buf, outgoing.brightness); + level_buf, sizeof(level_buf), outgoing.brightness); } // Home Assistant converts the color to the light's own channels, which loses the white balance // of a cold/warm white light, so the color goes only when it or the mode changed (or may have, @@ -451,7 +455,8 @@ void HomeassistantLight::write_state(LightState *state) { const Attributes &imported = this->imported_; const bool new_color = this->echo_pending_ || outgoing.color_mode != imported.color_mode; if (outgoing.color_temp_kelvin != 0 && (new_color || outgoing.color_temp_kelvin != imported.color_temp_kelvin)) { - add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, outgoing.color_temp_kelvin); + add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, sizeof(color_temp_buf), + outgoing.color_temp_kelvin); } if (outgoing.has_rgb && (new_color || !imported.has_rgb || outgoing.rgb != imported.rgb)) { // A list, which only a template renders as one diff --git a/esphome/components/homeassistant/light/homeassistant_light.h b/esphome/components/homeassistant/light/homeassistant_light.h index a7f316a22f8..a90a39f2c0a 100644 --- a/esphome/components/homeassistant/light/homeassistant_light.h +++ b/esphome/components/homeassistant/light/homeassistant_light.h @@ -4,6 +4,7 @@ #include "esphome/components/light/light_output.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -42,6 +43,11 @@ class HomeassistantLight final : public light::LightOutput, public Component { optional parse_integer_(const char *name, StringRef value, uint16_t max); /// A state or attribute report: answers a pending echo when it changed something, and schedules the apply void on_report_(bool changed); + /// Takes over a reported attribute, reporting whether it changed + template void import_(T &field, const T &value) { + this->on_report_(field != value); + field = value; + } /// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call static uint32_t state_hash(const light::LightColorValues &values); /// Schedules apply_imported_() once for the attributes of one update @@ -56,15 +62,15 @@ class HomeassistantLight final : public light::LightOutput, public Component { light::LightState *state_{nullptr}; light::LightTraits traits_{}; Attributes imported_{}; - uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at bool apply_scheduled_ : 1 {false}; - bool initial_write_ : 1 {true}; // the light core's own first write is not a local change - bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state - bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back - bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends - bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent - bool report_changed_ : 1 {false}; // the reports since the last apply changed an attribute + bool initial_write_ : 1 {true}; // the light core's own first write is not a local change + bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state + bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back + bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends + bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent + bool report_in_transition_ : 1 {false}; // the last changed report, not yet applied, arrived during a transition uint8_t report_timeout_s_{10}; + uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at }; } // namespace esphome::homeassistant diff --git a/tests/integration/test_api_homeassistant_light.py b/tests/integration/test_api_homeassistant_light.py index 2cb9b4d6d05..93eefb8dbb4 100644 --- a/tests/integration/test_api_homeassistant_light.py +++ b/tests/integration/test_api_homeassistant_light.py @@ -12,12 +12,16 @@ from aioesphomeapi import ( EntityState, HomeassistantServiceCall, LightColorCapability, + LightInfo, LightState, UserService, ) +from aioesphomeapi.api_pb2 import HomeAssistantStateResponse, LightCommandRequest +from google.protobuf import message import pytest from .log_utils import LineWaiter +from .state_utils import require_entity from .types import APIClientConnectedFactory, RunCompiledFunction ENTITY = "light.kitchen" @@ -43,6 +47,19 @@ RGB = ( ) +def command_msg(key: int, **fields: float | bool) -> LightCommandRequest: + """A light command with has_ set for each field given.""" + return LightCommandRequest( + key=key, **fields, **{f"has_{name}": True for name in fields} + ) + + +def report_msg(attribute: str, value: str) -> HomeAssistantStateResponse: + return HomeAssistantStateResponse( + entity_id=ENTITY, attribute=attribute, state=value + ) + + @dataclass class FakeHomeAssistant: """The light entity as the device sees it over the API, and what the device sends back.""" @@ -57,6 +74,19 @@ class FakeHomeAssistant: async def next_call(self) -> HomeassistantServiceCall: return await asyncio.wait_for(self.calls.get(), timeout=5.0) + async def expect_call( + self, service: str = "light.turn_on", **data: str + ) -> HomeassistantServiceCall: + """The next action sent; its data is checked in full when given.""" + call = await self.next_call() + assert call.service == service + if data: + assert call.data == {"entity_id": ENTITY, **data} + return call + + def command(self, **kwargs: float | bool | tuple[float, float, float]) -> None: + self.client.light_command(self.key, **kwargs) + async def next_state(self, matches: Callable[[LightState], bool]) -> LightState: async with asyncio.timeout(5.0): while not matches(state := await self.states.get()): @@ -66,6 +96,14 @@ class FakeHomeAssistant: def report(self, attribute: str | None, value: str) -> None: self.client.send_home_assistant_state(ENTITY, attribute, value) + async def report_off(self) -> None: + self.report(None, "off") + await self.next_state(lambda s: not s.state) + + def send_together(self, *messages: message.Message) -> None: + """One write, so the device reads all of them in one loop pass.""" + self.client._get_connection().send_messages(messages) + @asynccontextmanager async def light_entity( @@ -96,7 +134,7 @@ async def light_entity( api_client_connected() as client, ): entities, services = await client.list_entities_services() - key = next(e.key for e in entities if e.name == "HA Light") + key = require_entity(entities, "ha_light", LightInfo).key client.subscribe_service_calls(calls.put_nowait) client.subscribe_states(on_state) client.subscribe_home_assistant_states(on_state_sub) @@ -163,15 +201,11 @@ async def test_api_homeassistant_light( # Local changes go to Home Assistant with its attribute names and scales; the color # temperature it reported is not repeated - ha.client.light_command(ha.key, brightness=0.5) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == {"entity_id": ENTITY, "brightness": "128"} + ha.command(brightness=0.5) + call = await ha.expect_call(brightness="128") assert not call.data_template - ha.client.light_command(ha.key, rgb=(0.0, 0.0, 1.0)) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == {"entity_id": ENTITY, "brightness": "128"} + ha.command(rgb=(0.0, 0.0, 1.0)) + call = await ha.expect_call(brightness="128") assert call.data_template == {"rgb_color": "[0,0,255]"} # Home Assistant echoes the result; that is not sent back. Returning to the state it @@ -182,25 +216,24 @@ async def test_api_homeassistant_light( await ha.waiter.wait_for( "turned off", timeout=5.0 ) # a reported state runs the triggers too - ha.client.light_command(ha.key, state=True) - assert (await ha.next_call()).service == "light.turn_on" - ha.client.light_command(ha.key, state=False) - assert (await ha.next_call()).service == "light.turn_off" + ha.command(state=True) + await ha.expect_call() + ha.command(state=False) + await ha.expect_call("light.turn_off") # Without a report of the change within report_timeout, the light goes back to the reported state ha.report(None, "on") await ha.next_state(lambda s: s.state) - ha.client.light_command(ha.key, state=False) - assert (await ha.next_call()).service == "light.turn_off" + ha.command(state=False) + await ha.expect_call("light.turn_off") await ha.next_state(lambda s: s.state) await ha.waiter.wait_for("did not report the change back", timeout=5.0) - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) + await ha.report_off() # The fallback also drops the levels of the unsent change: the light keeps the brightness # Home Assistant reported last, which a later turn_on without a brightness would send - ha.client.light_command(ha.key, state=True, brightness=0.4) - call = await ha.next_call() + ha.command(state=True, brightness=0.4) + call = await ha.expect_call() assert call.data["brightness"] == "102" state = await ha.next_state(lambda s: not s.state) assert state.brightness == pytest.approx(200 / 255) @@ -208,19 +241,18 @@ async def test_api_homeassistant_light( # An automation changing the light while a report is applied is a local change too await ha.client.execute_service(set_force_off, {"value": True}) ha.report(None, "on") - assert (await ha.next_call()).service == "light.turn_off" + await ha.expect_call("light.turn_off") await ha.client.execute_service(set_force_off, {"value": False}) - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) + await ha.report_off() # A change that leaves Home Assistant as it is gets no report, so none is sent - ha.client.light_command(ha.key, state=False) + ha.command(state=False) await ha.waiter.wait_for("already has this state", timeout=5.0) # A trait that arrives while a change waits for its report is not that report, and does # not bring the old state back early either - ha.client.light_command(ha.key, state=True) - assert (await ha.next_call()).service == "light.turn_on" + ha.command(state=True) + await ha.expect_call() await ha.next_state(lambda s: s.state) ha.report("max_color_temp_kelvin", "6500") await ha.next_state(lambda s: not s.state) @@ -230,7 +262,7 @@ async def test_api_homeassistant_light( ) # A report that arrives during a flash is applied when the flash ends instead of being undone - ha.client.light_command(ha.key, state=True, flash_length=1.0) + ha.command(state=True, flash_length=1.0) ha.report(None, "on") ha.report("brightness", "77") state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255)) @@ -239,84 +271,96 @@ async def test_api_homeassistant_light( # A trait or a repeated report during a local transition is nothing new, so the change the # transition ends at is still sent - ha.client.light_command(ha.key, state=False, transition_length=1.0) + ha.command(state=False, transition_length=1.0) ha.report("max_color_temp_kelvin", "6500") ha.report(None, "on") - assert (await ha.next_call()).service == "light.turn_off" - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) + await ha.expect_call("light.turn_off") + await ha.report_off() # A newer local command wins over a report held during a transition - ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.command(state=True, transition_length=1.0) ha.report("brightness", "50") await asyncio.sleep(0.1) # held before the command arrives - ha.client.light_command(ha.key, state=True, brightness=1.0) - call = await ha.next_call() - assert call.service == "light.turn_on" + ha.command(state=True, brightness=1.0) + call = await ha.expect_call() assert call.data["brightness"] == "255" ha.report(None, "on") ha.report("brightness", "255") await ha.next_state(lambda s: s.state and s.brightness == 1.0) # Also when it changes only the color, at the level the transition was heading for - ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.command(brightness=0.5, transition_length=1.0) ha.report("brightness", "50") await asyncio.sleep(0.1) - ha.client.light_command(ha.key, color_temperature=400.0) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == { - "entity_id": ENTITY, - "brightness": "128", - "color_temp_kelvin": "2500", - } + ha.command(color_temperature=400.0) + await ha.expect_call(brightness="128", color_temp_kelvin="2500") ha.report("brightness", "128") ha.report("color_temp_kelvin", "2500") await ha.next_state(lambda s: s.color_temperature == 400.0) # Or when it is a transition itself, replacing the one the report was held for - ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) + ha.command(brightness=1.0, transition_length=1.0) ha.report("brightness", "50") await asyncio.sleep(0.1) - ha.client.light_command(ha.key, color_temperature=250.0, transition_length=1.0) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == { - "entity_id": ENTITY, - "brightness": "255", - "color_temp_kelvin": "4000", - } + ha.command(color_temperature=250.0, transition_length=1.0) + await ha.expect_call(brightness="255", color_temp_kelvin="4000") ha.report("brightness", "255") ha.report("color_temp_kelvin", "4000") await ha.next_state(lambda s: s.color_temperature == 250.0) # Or a transition back to where the one the report was held for started - ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.command(brightness=0.5, transition_length=1.0) ha.report("brightness", "50") await asyncio.sleep(0.1) - ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) - call = await ha.next_call() - assert call.service == "light.turn_on" + ha.command(brightness=1.0, transition_length=1.0) + call = await ha.expect_call() assert call.data["brightness"] == "255" ha.report("brightness", "255") await ha.next_state(lambda s: s.brightness == 1.0) # And when it goes back to the state the transition started from - ha.client.light_command(ha.key, state=False) - assert (await ha.next_call()).service == "light.turn_off" - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) - ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.command(state=False) + await ha.expect_call("light.turn_off") + await ha.report_off() + ha.command(state=True, transition_length=1.0) ha.report(None, "on") ha.report("brightness", "60") await asyncio.sleep(0.1) - ha.client.light_command(ha.key, state=False) - assert (await ha.next_call()).service == "light.turn_off" - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) + ha.command(state=False) + await ha.expect_call("light.turn_off") + await ha.report_off() + + # Also when the report and the call that replaces the transition are read in the same loop + # pass: the report is applied after the call, but it is older + ha.send_together( + command_msg(ha.key, state=True, brightness=0.5, transition_length=1000), + report_msg("brightness", "50"), + command_msg(ha.key, brightness=1.0, transition_length=1000), + ) + call = await ha.expect_call() + assert call.data["brightness"] == "255" + ha.report(None, "on") + ha.report("brightness", "255") + await ha.next_state(lambda s: s.state and s.brightness == 1.0) + + # And a report read after that call in the same pass is newer than it: the hold follows the + # last changed report, even when a trait opened the batch, so it is applied at the end + ha.send_together( + report_msg("max_color_temp_kelvin", "6500"), + command_msg(ha.key, brightness=0.5, transition_length=1000), + report_msg("brightness", "50"), + command_msg(ha.key, brightness=0.8, transition_length=1000), + report_msg("brightness", "77"), + ) + state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255)) + assert state.state + assert ha.calls.empty() + ha.command(state=False) + await ha.expect_call("light.turn_off") + await ha.report_off() # A flash ends where it began, which Home Assistant has, so nothing is sent and the user is told - ha.client.light_command(ha.key, state=True, flash_length=0.5) + ha.command(state=True, flash_length=0.5) await ha.waiter.wait_for("nothing to send (a flash never is)", timeout=5.0) assert ha.calls.empty() @@ -347,45 +391,35 @@ async def test_api_homeassistant_light_rgbww( # A level change sends the level alone; Home Assistant derived the color it reports from # the white balance and would derive a different balance from the color sent back - ha.client.light_command(ha.key, brightness=0.5) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == {"entity_id": ENTITY, "brightness": "128"} + ha.command(brightness=0.5) + call = await ha.expect_call(brightness="128") assert not call.data_template ha.report("brightness", "128") await ha.next_state(lambda s: s.brightness == pytest.approx(128 / 255)) # A new color goes along - ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) - call = await ha.next_call() - assert call.data == {"entity_id": ENTITY, "brightness": "128"} + ha.command(rgb=(0.0, 1.0, 0.0)) + call = await ha.expect_call(brightness="128") assert call.data_template == {"rgb_color": "[0,255,0]"} ha.report("rgb_color", "(0, 255, 0)") await ha.next_state(lambda s: s.green == 1.0 and s.red == 0.0) # Switching to color temperature sends it, and the level alone once there - ha.client.light_command(ha.key, color_temperature=250.0) - call = await ha.next_call() - assert call.data == { - "entity_id": ENTITY, - "brightness": "128", - "color_temp_kelvin": "4000", - } + ha.command(color_temperature=250.0) + call = await ha.expect_call(brightness="128", color_temp_kelvin="4000") assert not call.data_template ha.report("color_mode", "color_temp") ha.report("color_temp_kelvin", "4000") state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE) assert state.color_temperature == 250.0 - ha.client.light_command(ha.key, brightness=1.0) - call = await ha.next_call() - assert call.data == {"entity_id": ENTITY, "brightness": "255"} + ha.command(brightness=1.0) + await ha.expect_call(brightness="255") ha.report("brightness", "255") await ha.next_state(lambda s: s.brightness == 1.0) # Back to the color Home Assistant still reports: the mode changed, so the color is sent - ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) - call = await ha.next_call() - assert call.data == {"entity_id": ENTITY, "brightness": "255"} + ha.command(rgb=(0.0, 1.0, 0.0)) + call = await ha.expect_call(brightness="255") assert call.data_template == {"rgb_color": "[0,255,0]"} ha.report("color_mode", "rgbww") await ha.next_state(lambda s: s.color_mode == RGB) From 6868011336500574117eab014a0e04f60390f9d2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:49:05 -0400 Subject: [PATCH 647/651] [combination][speaker_source][const] Share CONF_SOURCES from components/const (#21129) --- esphome/components/combination/sensor.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/speaker_source/media_player.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/combination/sensor.py b/esphome/components/combination/sensor.py index 608ebac4d6a..a61d88e126e 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_SOURCES import esphome.config_validation as cv from esphome.const import ( CONF_ACCURACY_DECIMALS, @@ -59,7 +60,6 @@ CONF_MEDIAN = "median" CONF_MIN = "min" CONF_MOST_RECENTLY_UPDATED = "most_recently_updated" CONF_PROCESS_STD_DEV = "process_std_dev" -CONF_SOURCES = "sources" CONF_STD_DEV = "std_dev" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index a15df707e60..06e8d80103f 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -69,6 +69,7 @@ CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SERIAL_NUMBER = "serial_number" CONF_SHA256 = "sha256" CONF_SLOT = "slot" +CONF_SOURCES = "sources" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 4f150e87fff..d69ee1881b8 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -2,6 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import audio, media_player, media_source, speaker from esphome.components.const import ( + CONF_SOURCES, CONF_VOLUME_INCREMENT, CONF_VOLUME_INITIAL, CONF_VOLUME_MAX, @@ -29,7 +30,6 @@ CONF_ON_MUTE = "on_mute" CONF_PIPELINE = "pipeline" CONF_ON_UNMUTE = "on_unmute" CONF_ON_VOLUME = "on_volume" -CONF_SOURCES = "sources" speaker_source_ns = cg.esphome_ns.namespace("speaker_source") From 384c2ec26c70ba04c97a3cd0f1d39a2b83703ff3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:49:34 -1000 Subject: [PATCH 648/651] [ci] Extend the hardware-test skill with the audio, globals, outgoing connection and Home Assistant light flows (#21104) --- .agents/skills/hardware-test/SKILL.md | 13 +- .../hardware-test/reference/recipes/README.md | 5 +- .../hardware-test/reference/recipes/api.md | 23 +++ .../reference/recipes/globals.md | 15 ++ .../reference/recipes/homeassistant.md | 35 +++++ .../reference/recipes/usb_audio.md | 52 +++++++ .../hardware-test/scripts/api_button.py | 33 ++++ .../scripts/api_outgoing/cycles.py | 73 +++++++++ .../scripts/api_outgoing/wificycles.py | 107 +++++++++++++ .../scripts/homeassistant_light/bulb.py | 78 ++++++++++ .../hardware-test/scripts/serial_capture.py | 30 ++-- .../scripts/usb_audio/playurl.py | 30 ++++ .../templates/aux-2-preprod-stock-dut.yaml | 23 +++ .../aux-2-preprod-usb-audio-dut.yaml | 39 +++++ .../cast-pro-1-sendspin-control.yaml | 27 ++++ .../templates/deep-sleep-outgoing-dut.yaml | 46 ++++++ .../templates/globals-storage-dut.yaml | 66 ++++++++ .../templates/homeassistant-light-dut.yaml | 145 ++++++++++++++++++ .../templates/outgoing-wifi-drop-dut.yaml | 55 +++++++ .../usb-audio-sendspin-s3-eth-dut.yaml | 76 +++++++++ .../usb-audio-waveshare-s3-eth-dut.yaml | 142 +++++++++++++++++ 21 files changed, 1097 insertions(+), 16 deletions(-) create mode 100644 .agents/skills/hardware-test/reference/recipes/globals.md create mode 100644 .agents/skills/hardware-test/reference/recipes/homeassistant.md create mode 100644 .agents/skills/hardware-test/reference/recipes/usb_audio.md create mode 100644 .agents/skills/hardware-test/scripts/api_button.py create mode 100644 .agents/skills/hardware-test/scripts/api_outgoing/cycles.py create mode 100644 .agents/skills/hardware-test/scripts/api_outgoing/wificycles.py create mode 100644 .agents/skills/hardware-test/scripts/homeassistant_light/bulb.py create mode 100644 .agents/skills/hardware-test/scripts/usb_audio/playurl.py create mode 100644 .agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml create mode 100644 .agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/globals-storage-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/homeassistant-light-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md index d5e994a82f0..ec841c97612 100644 --- a/.agents/skills/hardware-test/SKILL.md +++ b/.agents/skills/hardware-test/SKILL.md @@ -93,13 +93,15 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA - `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`, `--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and `--serial-of PORT` (the serial number behind a path). -- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped - capture, ANSI stripped, `LOG_SECRET` text written as ``, reopens a vanished port, exits at the +- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append] [--ms]`: bounded timestamped + capture (`--ms` adds milliseconds for timing work), ANSI stripped, `LOG_SECRET` text written as ``, reopens a vanished port, exits at the deadline. OUT is truncated unless `--append` is given, so counts over it cover one run. - `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not. - `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now and then; retry up to three times. +- `api_button.py HOST KEY list|press NAME [...]`: list a device's template buttons or press them over the API; the + recipes drive their test configs with it. - `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`: the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0), @@ -113,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/ (host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set, API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and -capture decoder). +capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `homeassistant_light/` (direct bulb driver), `usb_audio/` (URL player). ## Templates in `$SKILL/templates` @@ -125,7 +127,10 @@ capture decoder). - Per-topic device configs, each described in `reference/recipes/.md`: `online-image-dut.yaml`, `usb-cdc-acm-dongle-dut.yaml`, `nrf52-preferences-dut.yaml`, `rtsp-waveshare-s3-eth-dut.yaml`, `waveshare-s3-eth-camdiag.yaml`, `s31-cse7766-dut.yaml`, `nrf52-coredump-crash-dut.yaml`, - `bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`. + `bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`, + `homeassistant-light-dut.yaml`, `deep-sleep-outgoing-dut.yaml`, `outgoing-wifi-drop-dut.yaml`, + `globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`, + `cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`. ## When something looks wrong diff --git a/.agents/skills/hardware-test/reference/recipes/README.md b/.agents/skills/hardware-test/reference/recipes/README.md index 7c7871f45ce..3db5e05f05a 100644 --- a/.agents/skills/hardware-test/reference/recipes/README.md +++ b/.agents/skills/hardware-test/reference/recipes/README.md @@ -4,7 +4,7 @@ Each flow names the pull requests it was used on, so their result tables show wh One file per component, named after it: -- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266 +- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop - [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer - [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API - [`espnow.md`](espnow.md): ESP8266 broadcast receive @@ -24,3 +24,6 @@ One file per component, named after it: - [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle - [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards - [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy +- [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind +- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant +- [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin diff --git a/.agents/skills/hardware-test/reference/recipes/api.md b/.agents/skills/hardware-test/reference/recipes/api.md index fa6733a72c6..4776fe72dc4 100644 --- a/.agents/skills/hardware-test/reference/recipes/api.md +++ b/.agents/skills/hardware-test/reference/recipes/api.md @@ -5,3 +5,26 @@ Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with `espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml. + +## Outgoing connection wake timing on a deep sleep build (#21077) + +`templates/deep-sleep-outgoing-dut.yaml`: classic ESP32 ESP-IDF, `wifi: fast_connect`, `api: encryption` with a bare +`outgoing_connection:`, `deep_sleep` 15 s run and 15 s sleep, a `homeassistant` text sensor, and `millis()` logged +from `wifi: on_connect`, `api: on_client_connected` and the sensor's first `on_value`. Home Assistant's dial-in +listener is on 6054; reuse a device name it already has. Flash by USB, `serial_capture.py PORT 360 OUT --reset` for +12 cycles, then `scripts/api_outgoing/cycles.py OUT LABEL` prints per cycle the wifi, client and state millis with +the client-wifi and state-client gaps. Wi-Fi takes 4 to 8 s after a wake, so compare the client-wifi gap, and run +the PR twice around one base run because the spread is a few hundred ms. +Results: https://github.com/esphome/esphome/pull/21077#issuecomment-6254098014 + +## Outgoing connection on a mains build after a Wi-Fi drop (#21085) + +`templates/outgoing-wifi-drop-dut.yaml`: classic ESP32, `outgoing_connection: delay: 2s`, a "Wifi cycle" button doing +`wifi.disable`, 4 s, `wifi.enable`, a `debug` loop-time sensor, millis from the wifi and api triggers. Flash by USB, +`serial_capture.py PORT SECONDS OUT --ms`, press the button N times 40 s apart with `api_button.py DEV_IP KEY press +"Wifi cycle"`, then `wificycles.py CAPTURE LABEL` prints per drop the Wi-Fi-to-dial gap, dials and dials closed, who +connected first, and the loop-time maxima. For the loop cost of polling, repeat with an unreachable +`host: 192.0.2.1`. Home Assistant refuses a dial-in while its own session is READY (`Refusing dial-in adoption`), +so after a drop every dial is closed and HA's incoming reconnect wins 11 to 33 s later; the measurable effect is the +first dial landing in the loop the network flag flips. +Results: https://github.com/esphome/esphome/pull/21085#issuecomment-6254742791 diff --git a/.agents/skills/hardware-test/reference/recipes/globals.md b/.agents/skills/hardware-test/reference/recipes/globals.md new file mode 100644 index 00000000000..05617e32606 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/globals.md @@ -0,0 +1,15 @@ +# globals + +## Restoring globals with storage: rtc and flash (#13954) + +`templates/globals-storage-dut.yaml` is the shared package: five restoring globals (rtc int, flash int, default int, +rtc `std::string`, rtc `uint8_t[2]`), bumped and logged with the reset reason on every boot, saved and synced after +2 s, odd boots `App.safe_reboot()`. Platform files add the board (`packages: dut: !include globals-storage-dut.yaml`), the `debug` reset-reason text sensor and, on the +ESP32, `deep_sleep: run_duration: 12s, sleep_duration: 8s` (even boots sleep). No network needed: flash by USB, +`serial_capture.py PORT 110 OUT --reset` gives the reset pulse plus a run of soft restarts and sleeps; the ESP32's +EN pulse is a `POWERON_RESET` that empties RTC memory, the D1 mini's RST pulse (`rst cause:2`) keeps it, so the +ESP8266 needs a real power cut by hand; arm long `--released` captures on both ports first (the capture +reopens the port when it comes back, the very first boot lines are still lost, read the next boot's restored +values). Unchanged-build check: the config with every `storage:` removed, built on the PR and on the dev merge +commit under the PR's own commits (not the merge base, which is older dev), `cmp -l` plus an `nm -S` symbol diff; +only the template names differ. Results: https://github.com/esphome/esphome/pull/13954#issuecomment-6254794712 diff --git a/.agents/skills/hardware-test/reference/recipes/homeassistant.md b/.agents/skills/hardware-test/reference/recipes/homeassistant.md new file mode 100644 index 00000000000..61f0614973b --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/homeassistant.md @@ -0,0 +1,35 @@ +# homeassistant + +## homeassistant light platform against a local Home Assistant (#11354) + +A local Home Assistant dev install (`hass -c config`, https://127.0.0.1:8123 with a self-signed cert); its ESPHome +entries and entity ids are in `config/.storage/`. Two ESPHome RGBCW bulbs with a plaintext API are the lights, so an +"HA side" change is made directly on a bulb with `scripts/homeassistant_light/bulb.py BULB_IP - on|off|state|restart`. +Add the test devices to HA by hand with the shared `api_key` and "Allow the device to perform Home Assistant actions". + +`templates/homeassistant-light-dut.yaml` is a package: two mirrors, buttons for each change, a "Log state" button, a +30 s alive line, an `on_boot` `light.turn_on` for the change-before-first-write path, and VERBOSE logging with the +noisy tags at DEBUG (a tag cannot exceed the global level). A platform file sets the board and includes it: + +```yaml +esphome: + name: ha-light-dut +esp32: + board: esp32dev + framework: + type: esp-idf +packages: + dut: !include homeassistant-light-dut.yaml +``` + +Flash by USB, start a bounded `esphome logs` capture per device, then press buttons with +`scripts/api_button.py DEV_IP KEY press "Button"` and read the bulb with `bulb.py`. Cases: each +button against the bulb and the device log; a direct bulb change that both mirrors follow; restart the bulb, send a +change, expect the 5 s `did not report the change back` and the fallback (with an off and with an on last report); +OTA reboot with a serial capture running to see the boot-time change (the API log client reconnects too late). + +Lessons: the light core clamps mireds to the range HA reported; a flash sends nothing; a change sent while HA is +connected but not yet subscribed is dropped by the api server (`client has not subscribed to actions (yet)`); after a +fallback to an off report the mirror keeps the unsent brightness; a mirror that never saw its light on since boot has +no levels to restore. Results: https://github.com/esphome/esphome/pull/11354#issuecomment-6253630336 and +https://github.com/esphome/esphome/pull/11354#issuecomment-6254161409 diff --git a/.agents/skills/hardware-test/reference/recipes/usb_audio.md b/.agents/skills/hardware-test/reference/recipes/usb_audio.md new file mode 100644 index 00000000000..b990a263326 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/usb_audio.md @@ -0,0 +1,52 @@ +# usb_audio + +## USB audio on the Waveshare ESP32-S3-ETH (#21081, #11728) + +`templates/usb-audio-waveshare-s3-eth-dut.yaml`: ESP-IDF, PoE, logs over the API on Ethernet (the native USB port is +the host port, so there is no console), `usb_audio` hub, a 48 kHz 16-bit microphone, a 48 kHz 24-bit speaker (many +speakerphones offer only 24-bit; a wrong format logs the device's alternates), buttons for capture start and stop, +a non-blocking beep fed from an 8 ms interval with `play(buf, len, 0)` (a blocking `play()` on the main loop trips +the task watchdog), and a status interval with mic bytes, speaker state and heap. Drive the buttons with +`scripts/api_button.py IP KEY press "Beep"`. Expect 192 kB/s microphone bytes and the FIFO split +RX 80 / NPTX 16 / PTX 104 in the build's `defines.h`; with the balanced split the 294-byte OUT pipe is refused +(`EP MPS (294) exceeds supported limit (128)`). A device whose OUT endpoint is larger than 600 bytes cannot be +claimed on an S3 at all. + +## Music Assistant through sendspin + +`templates/usb-audio-sendspin-s3-eth-dut.yaml` overlays the template above with the Apollo CAST-1 wiring: resampler +speakers into a mixer whose inputs never time out, `speaker_source` media player with the sendspin source, and +`sendspin: unpaired_access: true`. A `media_player: platform: speaker` is not a media-source consumer and gives +"Player over its buffer", no audio. Music Assistant finds the device by mDNS within 15 s of boot. + +Read the result from the device, not only by ear: sendspin's sync task logs `Lost sync (N us off)` (DEBUG, tag +`SsSync`) for every correction over 5 ms, a positive N inserts that much silence, and each one is an audible click. +For the chunk lead histogram, edit the managed library in the build directory, +`.esphome/build//managed_components/sendspin__sendspin-cpp/src/sync_task.cpp` (an `SS_LOGD` before the +late-chunk skip in `decode_chunk()` printing the chunk's lead against `new_audio_client_playtime`), update only that +file's `size` and sha256 `hash` in the component's `CHECKSUMS.json`, and compile again; deleting `.component_hash` or +recomputing it makes the manager refuse or refetch. Check the edit is in the image with `strings firmware.ota.bin`. +A lead below -5 ms makes the library skip the whole chunk; an I2S speaker keeps it within 0.1 ms of zero, which is +the figure a USB speaker has to match. + +Gotchas: wait two minutes after a reboot before the measured stream (safe mode's boot counter write stalls the cores +for about 140 ms once, 60 to 75 s after boot); with the serial console on, a DEBUG log line per report saturates +UART0 and trips the task watchdog, so use `logger: baud_rate: 0` and the API log; two overlapping `esphome logs` +captures double every count; and every audio test must include a pause and resume cycle, since a stream end that +leaves less than one transfer parked in the driver was what hung the speaker after #11728 merged. + +## Controls + +Apollo CAST PRO-1 (`templates/cast-pro-1-sendspin-control.yaml`): `Core.yaml` next to it is +`Integrations/ESPHome/Core.yaml` from ApolloAutomation/CAST_PRO-1, which also defines the `version` substitution. +Keep its framework log level at ERROR (DEBUG floods driver output and trips the watchdog); the package reboots itself +into Ethernet-only once it sees a link and appends the MAC suffix to the name. `wifi: enable_on_boot: false` keeps +sendspin off Wi-Fi. + +Nabu Casa Connect AUX-2 (a pre-production unit): build from `packages/aux-2-pre-prod.yaml` at NabuCasa/aux-2 commit +283f2e64 (dropped in bde41e01), not the public `aux-2.yaml`, whose W5500, codec and I2S pins differ (the public one +reads W5500 chip ID 0x00 and fails the codec's I2C writes). Point its local `external_components` source at +`github://NabuCasa/aux-2@main`. Templates `aux-2-preprod-stock-dut.yaml` (I2S codec) and +`aux-2-preprod-usb-audio-dut.yaml` (the USB speaker as the mixer output via `!extend mixing_speaker`). The console is +the CH343 (1a86:55d3); first flash over it, OTA after. Forcing the Wi-Fi flag off with `restore_value: no` boot-loops +when Ethernet fails, so let the package switch itself. diff --git a/.agents/skills/hardware-test/scripts/api_button.py b/.agents/skills/hardware-test/scripts/api_button.py new file mode 100644 index 00000000000..b1f0d962648 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api_button.py @@ -0,0 +1,33 @@ +"""Press a template button on the test device over the API, or list its buttons. + +usage: api_button.py HOST KEY list | press "Button name" [...] +""" + +import asyncio +import sys + +from aioesphomeapi import APIClient, ButtonInfo + +HOST, KEY = sys.argv[1], sys.argv[2] +CMD, ARGS = sys.argv[3], sys.argv[4:] + + +async def main() -> None: + c = APIClient(HOST, 6053, None, noise_psk=KEY) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)} + if CMD == "list": + print("\n".join(sorted(buttons))) + else: + for name in ARGS: + if name not in buttons: + sys.exit(f"no button named {name!r}; have {sorted(buttons)}") + c.button_command(buttons[name].key) + print("pressed", name) + await asyncio.sleep(0.3) + await asyncio.sleep(0.5) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/api_outgoing/cycles.py b/.agents/skills/hardware-test/scripts/api_outgoing/cycles.py new file mode 100644 index 00000000000..e64eed18b3a --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api_outgoing/cycles.py @@ -0,0 +1,73 @@ +"""Per wake cycle timings from a serial capture of the deep sleep timing config. + +usage: cycles.py CAPTURE [LABEL] +A cycle starts at the boot banner; the numbers are the device's millis() from the timing lines, and the +capture clock gap from the first line of the cycle to the "Dialing" line. +""" + +import pathlib +import re +import statistics +import sys + +cap = ( + pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() +) +label = sys.argv[2] if len(sys.argv) > 2 else "" +TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]") + + +def secs(line): + m = TS.match(line) + if not m: + return None + h, mi, s, f = m.groups() + return int(h) * 3600 + int(mi) * 60 + int(s) + (int(f) / 10 ** len(f) if f else 0) + + +cycles = [] +cur = None +for line in cap: + if "rst:0x" in line or (cur is None and "Log initialized" in line): + cur = {"start": secs(line), "reset": line.split("]", 1)[-1].strip()[:60]} + cycles.append(cur) + continue + if cur is None: + continue + if "Dialing" in line: + cur.setdefault("dial_cap", secs(line)) + m = re.search(r"wifi connected at (\d+) ms", line) + if m: + cur.setdefault("wifi", int(m.group(1))) + m = re.search(r"client connected at (\d+) ms \((.*?)\)", line) + if m and "client" not in cur: + cur["client"] = int(m.group(1)) + cur["who"] = m.group(2) + m = re.search(r"first HA state '(.*?)' at (\d+) ms", line) + if m and "state" not in cur: + cur["state"] = int(m.group(2)) + +print(f"{label} cycles: {len(cycles)}") +print( + f"{'n':>2} {'wifi ms':>8} {'dial +ms':>9} {'client ms':>10} {'state ms':>9} {'client-wifi':>11} {'state-client':>12} client reset" +) +for i, c in enumerate(cycles, 1): + dial = "" + if c.get("dial_cap") is not None and c.get("start") is not None: + dial = f"{(c['dial_cap'] - c['start']) * 1000:.0f}" + cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else "" + sc = c["state"] - c["client"] if "state" in c and "client" in c else "" + print( + f"{i:>2} {c.get('wifi', ''):>8} {dial:>9} {c.get('client', ''):>10} {c.get('state', ''):>9} {cw:>11} {sc:>12} {c.get('who', '')} {c.get('reset', '')[:28]}" + ) +for c in cycles: + if "client" in c and "wifi" in c: + c["client-wifi"] = c["client"] - c["wifi"] + if "state" in c and "client" in c: + c["state-client"] = c["state"] - c["client"] +for key in ("wifi", "client", "state", "client-wifi", "state-client"): + vals = [c[key] for c in cycles if key in c] + if vals: + print( + f"{key}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}" + ) diff --git a/.agents/skills/hardware-test/scripts/api_outgoing/wificycles.py b/.agents/skills/hardware-test/scripts/api_outgoing/wificycles.py new file mode 100644 index 00000000000..db315cc99ea --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api_outgoing/wificycles.py @@ -0,0 +1,107 @@ +"""Per Wi-Fi drop timings from a serial capture of the outgoing connection timing config. + +usage: wificycles.py CAPTURE [LABEL] +A cycle starts at "wifi cycle disabling"; the numbers are the device's millis() from the timing lines. +"dial" says whether the client that connected first came from the device's own dial ("Dialing" seen after the +reconnect and before the hello) or from an incoming connection ("Accept" seen first). +""" + +import pathlib +import re +import statistics +import sys + +cap = ( + pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() +) +label = sys.argv[2] if len(sys.argv) > 2 else "" +TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]") + + +def secs(line): + m = TS.match(line) + if not m: + return None + h, mi, se, f = m.groups() + return int(h) * 3600 + int(mi) * 60 + int(se) + (int(f) / 10 ** len(f) if f else 0) + + +cycles = [] +cur = None +loop_max = [] +for line in cap: + m = re.search(r"loop time max (\d+) ms", line) + if m: + loop_max.append(int(m.group(1))) + if "wifi cycle disabling" in line: + cur = {"order": []} + cycles.append(cur) + continue + if cur is None: + continue + m = re.search(r"wifi connected at (\d+) ms", line) + if m and "wifi" not in cur: + cur["wifi"] = int(m.group(1)) + cur["wifi_cap"] = secs(line) + if "Dialing" in line: + cur["order"].append("dial") + cur["dials"] = cur.get("dials", 0) + 1 + if "dial_cap" not in cur and "wifi_cap" in cur: + cur["dial_cap"] = secs(line) + elif "dial_cap" not in cur and "dial_before_wifi_cap" not in cur: + cur["dial_before_wifi_cap"] = secs(line) + if ( + re.search(r"client disconnected at \d+ ms \(\d+\.\d+\.\d+\.\d+\)", line) + and "wifi" in cur + and "client" not in cur + ): + cur["dial_closed"] = cur.get("dial_closed", 0) + 1 + if "Accept " in line and "wifi" in cur: + cur["order"].append("accept") + m = re.search(r"client connected at (\d+) ms \((.*?)\)", line) + if m and "wifi" in cur and "client" not in cur: + cur["client"] = int(m.group(1)) + cur["who"] = m.group(2).split(" ")[0] + cur["via"] = cur["order"][0] if cur["order"] else "?" + m = re.search(r"Connect (failed|timeout)", line) + if m: + cur.setdefault("connect_errors", 0) + cur["connect_errors"] += 1 + +print(f"{label} cycles: {len(cycles)}") +print( + f"{'n':>2} {'wifi ms':>8} {'dial-wifi cap ms':>16} {'client ms':>10} {'client-wifi':>11} via dials closed client" +) +for i, c in enumerate(cycles, 1): + cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else "" + c["client-wifi"] = cw + dw = "" + if "wifi_cap" in c and c.get("wifi_cap") is not None: + # the first dial of the cycle, whether it was logged before or after the Wi-Fi line + if c.get("dial_before_wifi_cap") is not None: + dw = f"{(c['dial_before_wifi_cap'] - c['wifi_cap']) * 1000:.0f}" + elif "dial_cap" in c and c["dial_cap"] is not None: + dw = f"{(c['dial_cap'] - c['wifi_cap']) * 1000:.0f}" + c["dial-wifi"] = dw + print( + f"{i:>2} {c.get('wifi', ''):>8} {dw:>16} {c.get('client', ''):>10} {cw:>11} {c.get('via', ''):7s} {c.get('dials', 0):>5} {c.get('dial_closed', 0):>6} {c.get('who', '')}" + ) +for via in ("dial", "accept"): + vals = [ + c["client-wifi"] + for c in cycles + if c.get("via") == via and c["client-wifi"] != "" + ] + if vals: + print( + f"client-wifi via {via}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}" + ) +dws = [float(c["dial-wifi"]) for c in cycles if c.get("dial-wifi") not in ("", None)] +if dws: + print( + f"dial after wifi connected (capture clock): n={len(dws)} min={min(dws):.0f} median={statistics.median(dws):.0f} max={max(dws):.0f}" + ) +if loop_max: + print( + f"loop time max per 5 s: n={len(loop_max)} min={min(loop_max)} median={statistics.median(loop_max):.0f} max={max(loop_max)}" + ) diff --git a/.agents/skills/hardware-test/scripts/homeassistant_light/bulb.py b/.agents/skills/hardware-test/scripts/homeassistant_light/bulb.py new file mode 100644 index 00000000000..61efe8f0e21 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/homeassistant_light/bulb.py @@ -0,0 +1,78 @@ +"""Read or drive a bulb directly over its own ESPHome API (an HA-side change, independent of the mirror). + +usage: bulb.py HOST KEY state | on [brightness 0-1] [ct_mireds|rgb r,g,b] | off | restart +""" + +import asyncio +import sys + +from aioesphomeapi import APIClient, ButtonInfo, LightColorCapability, LightInfo + +HOST, KEY, CMD = sys.argv[1], sys.argv[2], sys.argv[3] +ARGS = sys.argv[4:] + + +async def main() -> None: + c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + light = next((e for e in ents if isinstance(e, LightInfo)), None) + if light is None: + sys.exit("no light entity on this device; is HOST a bulb?") + if CMD == "state": + fut = asyncio.get_running_loop().create_future() + + def on_state(st): + if getattr(st, "key", None) == light.key and not fut.done(): + fut.set_result(st) + + c.subscribe_states(on_state) + st = await asyncio.wait_for(fut, 5) + print( + f"{light.name}: on={st.state} mode={st.color_mode} bri={st.brightness:.2f} " + f"ct={st.color_temperature:.1f} rgb={st.red:.2f}/{st.green:.2f}/{st.blue:.2f} " + f"white={st.white:.2f} cold={st.cold_white:.2f} warm={st.warm_white:.2f}" + ) + elif CMD == "on": + kw = {"state": True} + if ARGS: + kw["brightness"] = float(ARGS[0]) + if len(ARGS) > 1: + if "," in ARGS[1]: + r, g, b = (float(x) for x in ARGS[1].split(",")) + kw["rgb"] = (r, g, b) + kw["color_mode"] = ( + LightColorCapability.RGB + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ) + else: + kw["color_temperature"] = float(ARGS[1]) + kw["color_mode"] = ( + LightColorCapability.COLOR_TEMPERATURE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ) + c.light_command(light.key, **kw) + print("bulb on", kw) + elif CMD == "off": + c.light_command(light.key, state=False) + print("bulb off") + elif CMD == "restart": + btn = next( + ( + e + for e in ents + if isinstance(e, ButtonInfo) and "restart" in e.name.lower() + ), + None, + ) + if btn is None: + sys.exit("no restart button on this device") + c.button_command(btn.key) + print("bulb restart pressed") + await asyncio.sleep(0.5) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/serial_capture.py b/.agents/skills/hardware-test/scripts/serial_capture.py index d93b3ca720f..bf6fad218d1 100644 --- a/.agents/skills/hardware-test/scripts/serial_capture.py +++ b/.agents/skills/hardware-test/scripts/serial_capture.py @@ -1,6 +1,6 @@ """Bounded, timestamped serial capture that always exits and closes the port. - serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] + serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] [--ms] default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico. --released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin @@ -76,14 +76,21 @@ def open_port(args: argparse.Namespace, path: str) -> serial.Serial: return s -def write_line(out, tag: bytes, data: bytes) -> None: - stamp = time.strftime("[%H:%M:%S]").encode() +def write_line(out, tag: bytes, data: bytes, ms: bool = False) -> None: + if ms: + now = time.time() + stamp = ( + time.strftime("[%H:%M:%S", time.localtime(now)) + + f".{int(now * 1000) % 1000:03d}]" + ).encode() + else: + stamp = time.strftime("[%H:%M:%S]").encode() out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"", data)) + b"\n") out.flush() -def note(out, text: str) -> None: - write_line(out, b"[capture] ", text.encode()) +def note(out, text: str, ms: bool = False) -> None: + write_line(out, b"[capture] ", text.encode(), ms) def capture(args: argparse.Namespace, out) -> int: @@ -102,18 +109,18 @@ def capture(args: argparse.Namespace, out) -> int: if serial_number is None: serial_number, index = identify(path) if path != args.port: - note(out, f"reopened as {path}") + note(out, f"reopened as {path}", args.ms) except PORT_ERRORS as err: - note(out, f"open failed: {err}") + note(out, f"open failed: {err}", args.ms) time.sleep(1) continue try: data = s.read(4096) except PORT_ERRORS as err: if buf: # keep the cut-off line apart from the next boot's first line - write_line(out, b"[partial]", buf) + write_line(out, b"[partial]", buf, args.ms) buf = b"" - note(out, f"port lost: {err}") + note(out, f"port lost: {err}", args.ms) with contextlib.suppress(*PORT_ERRORS): s.close() s = None @@ -125,9 +132,9 @@ def capture(args: argparse.Namespace, out) -> int: buf += data while b"\n" in buf: line, buf = buf.split(b"\n", 1) - write_line(out, b"", line.rstrip(b"\r")) + write_line(out, b"", line.rstrip(b"\r"), args.ms) if buf: - write_line(out, b"[partial]", buf) + write_line(out, b"[partial]", buf, args.ms) if s is not None: s.close() return received @@ -144,6 +151,7 @@ def main() -> int: ap.add_argument("--released", action="store_true") ap.add_argument("--reset", action="store_true") ap.add_argument("--append", action="store_true") + ap.add_argument("--ms", action="store_true", help="millisecond timestamps") args = ap.parse_args() with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out: received = capture(args, out) diff --git a/.agents/skills/hardware-test/scripts/usb_audio/playurl.py b/.agents/skills/hardware-test/scripts/usb_audio/playurl.py new file mode 100644 index 00000000000..23afafcb45e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/usb_audio/playurl.py @@ -0,0 +1,30 @@ +"""Play a URL on the speaker_source media player over the API (no sync task involved). + +usage: playurl.py HOST KEY URL [PLAYER_NAME] +""" + +import asyncio +import sys + +from aioesphomeapi import APIClient, MediaPlayerInfo + +HOST, KEY, URL = sys.argv[1], sys.argv[2], sys.argv[3] +PLAYER = sys.argv[4] if len(sys.argv) > 4 else "Player" + + +async def main() -> None: + c = APIClient(HOST, 6053, None, noise_psk=KEY) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + players = {e.name: e for e in ents if isinstance(e, MediaPlayerInfo)} + print("media players:", sorted(players)) + if PLAYER not in players: + sys.exit(f"no media player named {PLAYER!r}; have {sorted(players)}") + p = players[PLAYER] + c.media_player_command(p.key, media_url=URL) + print("sent play_media to", PLAYER) + await asyncio.sleep(1) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml b/.agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml new file mode 100644 index 00000000000..825ca265620 --- /dev/null +++ b/.agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml @@ -0,0 +1,23 @@ +# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local +# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md). +# Nabu Casa Connect AUX-2 on the usb_audio PR tree: stock package (I2S PCM5122 output) as a control. +packages: + core: !include aux-2-pre-prod.yaml + +esphome: + name: aux-2-dut + name_add_mac_suffix: false + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + encryption: + key: !secret api_key + +logger: + level: DEBUG + +sendspin: + unpaired_access: true diff --git a/.agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml b/.agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml new file mode 100644 index 00000000000..3b72ba12539 --- /dev/null +++ b/.agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml @@ -0,0 +1,39 @@ +# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local +# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md). +# Nabu Casa Connect AUX-2 on the usb_audio PR tree: the Anker S330 on the USB host port as the mixer output. +packages: + core: !include aux-2-pre-prod.yaml + +esphome: + name: aux-2-usb-dut + name_add_mac_suffix: false + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + encryption: + key: !secret api_key + +logger: + level: DEBUG + baud_rate: 0 + +sendspin: + unpaired_access: true + +usb_audio: + id: usb_audio_id + +speaker: + - platform: usb_audio + id: usb_speaker + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 24bit + num_channels: 2 + buffer_size: 28800 + write_timeout: 50ms + - id: !extend mixing_speaker + output_speaker: usb_speaker diff --git a/.agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml b/.agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml new file mode 100644 index 00000000000..9d7f71df4a1 --- /dev/null +++ b/.agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml @@ -0,0 +1,27 @@ +# CAST PRO-1 control for the sendspin sync comparison: Apollo's current package, DEBUG logging as on the S3. +esphome: + name: "cast-pro-1-dut" + friendly_name: CAST PRO-1 test + project: + name: "ApolloAutomation.CAST_PRO-1" + version: ${version} + +packages: + core: !include Core.yaml + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + enable_on_boot: false + +logger: + level: DEBUG + +api: + encryption: + key: !secret api_key + +sendspin: + manufacturer: Apollo + model: CAST PRO-1 control + unpaired_access: true diff --git a/.agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml b/.agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml new file mode 100644 index 00000000000..775936c0b6d --- /dev/null +++ b/.agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml @@ -0,0 +1,46 @@ +# Deep sleep wake timing for the outgoing API connection: boot -> dial -> client hello -> first HA state. +esphome: + name: deep-sleep-dut + on_boot: + priority: -100 + then: + - lambda: ESP_LOGI("timing", "setup done at %u ms", (unsigned) millis()); + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + level: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + fast_connect: true + on_connect: + - lambda: ESP_LOGI("timing", "wifi connected at %u ms", (unsigned) millis()); + +api: + encryption: + key: !secret api_key + outgoing_connection: + on_client_connected: + - lambda: ESP_LOGI("timing", "client connected at %u ms (%s %s)", (unsigned) millis(), client_info.c_str(), client_address.c_str()); + on_client_disconnected: + - lambda: ESP_LOGI("timing", "client disconnected at %u ms", (unsigned) millis()); + +ota: + - platform: esphome + +text_sensor: + - platform: homeassistant + id: ha_state + entity_id: light.ENTITY + on_value: + - lambda: ESP_LOGI("timing", "first HA state '%s' at %u ms", x.c_str(), (unsigned) millis()); + +deep_sleep: + id: ds + run_duration: 15s + sleep_duration: 15s diff --git a/.agents/skills/hardware-test/templates/globals-storage-dut.yaml b/.agents/skills/hardware-test/templates/globals-storage-dut.yaml new file mode 100644 index 00000000000..4cd6c639170 --- /dev/null +++ b/.agents/skills/hardware-test/templates/globals-storage-dut.yaml @@ -0,0 +1,66 @@ +# Restoring globals with every storage choice; each boot bumps the counters, logs them with the reset reason, saves +# and syncs; odd boots soft restart, even boots deep sleep (ESP32) or idle (D1 mini). The host's reset pulse and a +# power cut give the other reset kinds. +logger: + level: DEBUG + +preferences: + flash_write_interval: 1s + +globals: + - id: cnt_rtc + type: int + restore_value: true + storage: rtc + initial_value: "0" + - id: cnt_flash + type: int + restore_value: true + storage: flash + initial_value: "0" + - id: cnt_default + type: int + restore_value: true + initial_value: "0" + - id: str_rtc + type: std::string + restore_value: true + storage: rtc + max_restore_data_length: 24 + initial_value: '"fresh"' + - id: arr_rtc + type: uint8_t[2] + restore_value: true + storage: rtc + initial_value: "{0, 0}" + +esphome: + on_boot: + priority: -100 + then: + - lambda: |- + id(cnt_rtc) += 1; + id(cnt_flash) += 1; + id(cnt_default) += 1; + id(arr_rtc)[0] += 1; + id(arr_rtc)[1] += 2; + char buf[24]; + snprintf(buf, sizeof(buf), "boot%d", id(cnt_rtc)); + ESP_LOGI("gl", "restored rtc=%d flash=%d default=%d str='%s' arr=%u/%u (values before this boot's bump: one less)", + id(cnt_rtc) - 1, id(cnt_flash) - 1, id(cnt_default) - 1, id(str_rtc).c_str(), id(arr_rtc)[0] - 1, id(arr_rtc)[1] - 2); + id(str_rtc) = buf; + - delay: 2s + - lambda: |- + global_preferences->sync(); + ESP_LOGI("gl", "saved rtc=%d flash=%d default=%d str='%s' arr=%u/%u", id(cnt_rtc), id(cnt_flash), id(cnt_default), + id(str_rtc).c_str(), id(arr_rtc)[0], id(arr_rtc)[1]); + - if: + condition: + lambda: 'return id(cnt_rtc) % 2 == 1;' + then: + - delay: 3s + - lambda: 'ESP_LOGI("gl", "odd boot, soft restart now"); App.safe_reboot();' + +button: + - platform: restart + name: Restart diff --git a/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml b/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml new file mode 100644 index 00000000000..9681a41de7a --- /dev/null +++ b/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml @@ -0,0 +1,145 @@ +# shared part: two Home Assistant lights mirrored on the device plus buttons that drive them +esphome: + # A local change made before the light core's first write (b19a776 sends it instead of merging it). + on_boot: + priority: -100 + then: + - lambda: 'ESP_LOGI("halight", "on_boot turning B on at 35 percent before the first write");' + - light.turn_on: + id: bulb_b + brightness: 35% +logger: + level: VERBOSE + logs: + api: DEBUG + api.connection: DEBUG + api.service: DEBUG + wifi: DEBUG + scheduler: DEBUG + component: DEBUG + sensor: DEBUG + mdns: DEBUG + ota: DEBUG + socket: DEBUG + esp-idf: DEBUG + preferences: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + +ota: + - platform: esphome + +light: + - platform: homeassistant + id: bulb_a + name: Mirror A + entity_id: light.ENTITY_A + report_timeout: 5s + on_turn_on: + - logger.log: "bulb_a on_turn_on" + on_turn_off: + - logger.log: "bulb_a on_turn_off" + - platform: homeassistant + id: bulb_b + name: Mirror B + entity_id: light.ENTITY_B + report_timeout: 5s + +button: + - platform: template + name: A warm 40 + id: a_warm_40 + on_press: + - light.turn_on: + id: bulb_a + brightness: 40% + color_temperature: 370 mireds + - platform: template + name: A cool 100 + on_press: + - light.turn_on: + id: bulb_a + brightness: 100% + color_temperature: 153 mireds + - platform: template + name: A red + on_press: + - light.turn_on: + id: bulb_a + brightness: 80% + red: 100% + green: 0% + blue: 0% + - platform: template + name: A green dim + on_press: + - light.turn_on: + id: bulb_a + brightness: 20% + red: 0% + green: 100% + blue: 0% + - platform: template + name: A brightness only 60 + on_press: + - light.turn_on: + id: bulb_a + brightness: 60% + - platform: template + name: A transition 100 + on_press: + - light.turn_on: + id: bulb_a + brightness: 100% + transition_length: 3s + - platform: template + name: A flash + on_press: + - light.turn_on: + id: bulb_a + flash_length: 1s + - platform: template + name: A off + on_press: + - light.turn_off: bulb_a + - platform: template + name: A toggle + on_press: + - light.toggle: bulb_a + - platform: template + name: B warm 50 + on_press: + - light.turn_on: + id: bulb_b + brightness: 50% + color_temperature: 300 mireds + - platform: template + name: B off + on_press: + - light.turn_off: bulb_b + - platform: template + name: Log state + on_press: + - lambda: |- + auto v = id(bulb_a).current_values; + ESP_LOGI("halight", "A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f", + v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(), + v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(), + id(bulb_b).current_values.get_brightness()); + +interval: + - interval: 30s + then: + - lambda: |- + auto v = id(bulb_a).current_values; + ESP_LOGI("halight", "alive A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f", + v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(), + v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(), + id(bulb_b).current_values.get_brightness()); diff --git a/.agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml b/.agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml new file mode 100644 index 00000000000..4f6ca276b5d --- /dev/null +++ b/.agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml @@ -0,0 +1,55 @@ +# Mains build (no deep sleep): outgoing API connection timing after a Wi-Fi drop, and the loop cost while a +# connect is pending or the network is down. +esphome: + name: wifi-drop-dut + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + level: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + fast_connect: true + on_connect: + - lambda: ESP_LOGI("timing", "wifi connected at %u ms", (unsigned) millis()); + on_disconnect: + - lambda: ESP_LOGI("timing", "wifi disconnected at %u ms", (unsigned) millis()); + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + outgoing_connection: + delay: 2s + on_client_connected: + - lambda: ESP_LOGI("timing", "client connected at %u ms (%s %s)", (unsigned) millis(), client_info.c_str(), client_address.c_str()); + on_client_disconnected: + - lambda: ESP_LOGI("timing", "client disconnected at %u ms (%s)", (unsigned) millis(), client_info.c_str()); + +ota: + - platform: esphome + +debug: + update_interval: 5s + +sensor: + - platform: debug + loop_time: + name: Loop Time + on_value: + - lambda: ESP_LOGI("timing", "loop time max %.0f ms over the last 5 s at %u ms", x, (unsigned) millis()); + +button: + - platform: template + name: Wifi cycle + on_press: + - lambda: ESP_LOGI("timing", "wifi cycle disabling at %u ms", (unsigned) millis()); + - wifi.disable: + - delay: 4s + - lambda: ESP_LOGI("timing", "wifi cycle enabling at %u ms", (unsigned) millis()); + - wifi.enable: diff --git a/.agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml b/.agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml new file mode 100644 index 00000000000..079ffab7fde --- /dev/null +++ b/.agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml @@ -0,0 +1,76 @@ +# Music Assistant target: the plain USB audio template plus the CAST-1 style sendspin wiring (resampler speakers +# into a mixer whose inputs never time out, speaker_source media player, sendspin hub and sources). +packages: + base: !include usb-audio-waveshare-s3-eth-dut.yaml + +# Logs over the API only: a DEBUG line per report would saturate UART0 and trip the task watchdog. +logger: + baud_rate: 0 + +speaker: + - id: !extend usb_speaker + buffer_size: 28800 + write_timeout: 50ms + - platform: mixer + id: mixer_speaker_id + output_speaker: usb_speaker + num_channels: 2 + task_stack_in_psram: true + source_speakers: + - id: announcement_mixer_input + timeout: never + - id: media_mixer_input + timeout: never + - platform: resampler + id: announcement_resampling_speaker + output_speaker: announcement_mixer_input + sample_rate: 48000 + bits_per_sample: 16 + - platform: resampler + id: media_resampling_speaker + output_speaker: media_mixer_input + sample_rate: 48000 + bits_per_sample: 16 + +sendspin: + id: sendspin_hub + # PSRAM stack skews time sync; keep internal + task_stack_in_psram: false + manufacturer: Waveshare + model: ESP32-S3-ETH USB audio + firmware_version: "0.1" + unpaired_access: true + +media_source: + - platform: sendspin + id: sendspin_source + - platform: audio_http + id: http_media_source + buffer_size: 500000 + - platform: audio_http + id: http_announcement_source + buffer_size: 250000 + +media_player: + - platform: sendspin + id: sendspin_group_media_player + name: Sendspin Player + - platform: speaker_source + id: external_media_player + name: Player + volume_increment: 0.05 + announcement_pipeline: + format: FLAC + num_channels: 1 + sample_rate: 48000 + speaker: announcement_resampling_speaker + sources: + - http_announcement_source + media_pipeline: + speaker: media_resampling_speaker + format: FLAC + num_channels: 2 + sample_rate: 48000 + sources: + - sendspin_source + - http_media_source diff --git a/.agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml b/.agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml new file mode 100644 index 00000000000..229f5147bd4 --- /dev/null +++ b/.agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml @@ -0,0 +1,142 @@ +# Waveshare ESP32-S3-ETH: USB host for a speakerphone (usb_audio), logs over Ethernet, the USB port is the host port +esphome: + name: usb-audio-dut + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHz + +logger: + level: DEBUG + hardware_uart: UART0 + logs: + usb_host: DEBUG + +ethernet: + type: W5500 + clk_pin: GPIO13 + mosi_pin: GPIO11 + miso_pin: GPIO12 + cs_pin: GPIO14 + interrupt_pin: GPIO10 + reset_pin: GPIO9 + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + +ota: + - platform: esphome + +usb_audio: + id: usb_audio_id + +microphone: + - platform: usb_audio + id: usb_mic + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 16bit + num_channels: 2 + buffer_size: 9600 + on_data: + - lambda: |- + id(mic_bytes) += x.size(); + id(mic_chunks) += 1; + +speaker: + - platform: usb_audio + id: usb_speaker + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 24bit + num_channels: 2 + buffer_size: 9600 + write_timeout: 20ms + +globals: + - id: beep_left + type: int + initial_value: "0" + - id: beep_written + type: uint32_t + initial_value: "0" + - id: beep_short + type: uint32_t + initial_value: "0" + - id: beep_phase + type: float + initial_value: "0" + - id: mic_bytes + type: uint32_t + initial_value: "0" + - id: mic_chunks + type: uint32_t + initial_value: "0" + +button: + - platform: template + name: Mic start + on_press: + - microphone.capture: usb_mic + - platform: template + name: Mic stop + on_press: + - microphone.stop_capture: usb_mic + - platform: template + name: Beep + on_press: + - lambda: |- + // 2 s of a 1 kHz tone at 48 kHz 24-bit stereo, fed 10 ms at a time from the interval below + id(beep_left) = 200; + id(beep_written) = 0; + id(beep_short) = 0; + id(beep_phase) = 0.0f; + id(usb_speaker).start(); + ESP_LOGI("uatest", "beep started"); + - platform: template + name: Speaker stop + on_press: + - speaker.stop: usb_speaker + - platform: template + name: Restart + on_press: + - lambda: App.safe_reboot(); + +interval: + - interval: 8ms + then: + - lambda: |- + if (id(beep_left) <= 0) return; + // 10 ms of 48 kHz stereo 24-bit samples, 3 bytes little-endian per sample + static uint8_t buf[480 * 2 * 3]; + float phase = id(beep_phase); + for (int i = 0; i < 480; i++) { + int32_t s = (int32_t) (1500000.0f * sinf(phase)); + phase += 2.0f * 3.14159265f * 1000.0f / 48000.0f; + if (phase > 6.2831853f) phase -= 6.2831853f; + for (int c = 0; c < 2; c++) { + uint8_t *o = buf + (i * 2 + c) * 3; + o[0] = s & 0xFF; o[1] = (s >> 8) & 0xFF; o[2] = (s >> 16) & 0xFF; + } + } + size_t n = id(usb_speaker).play(buf, sizeof(buf), 0); + if (n == 0) { id(beep_short) += 1; return; } // not accepted yet, try the same chunk next time + id(beep_phase) = phase; + id(beep_written) += n; + id(beep_left) -= 1; + if (id(beep_left) == 0) ESP_LOGI("uatest", "beep done: %u bytes queued, %u retries", (unsigned) id(beep_written), (unsigned) id(beep_short)); + - interval: 5s + then: + - lambda: |- + ESP_LOGI("uatest", "mic capturing=%d bytes=%u chunks=%u | speaker running=%d | heap free %u", + id(usb_mic).is_running(), (unsigned) id(mic_bytes), (unsigned) id(mic_chunks), + id(usb_speaker).is_running(), (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); From dc56b27f33c81bd13376b42c819d25c3ab12fcfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:50:21 -1000 Subject: [PATCH 649/651] [api] Send the device info strings from flash and keep the version out of RAM (#21106) --- esphome/components/api/api.proto | 12 ++--- esphome/components/api/api_connection.cpp | 52 +++++-------------- esphome/components/api/api_pb2.cpp | 12 ++--- esphome/components/api/api_pb2.h | 12 ++--- esphome/components/api/api_pb2_dump.cpp | 12 ++--- esphome/core/application.cpp | 2 +- esphome/core/build_info_data.h | 7 +-- esphome/writer.py | 6 +++ .../components/api/bench_proto_encode.cpp | 8 +-- tests/unit_tests/test_writer.py | 8 +++ 10 files changed, 60 insertions(+), 71 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 679aff06b5e..73455926b33 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -142,7 +142,7 @@ message HelloResponse { // A string identifying the server (ESP); like client info this may be empty // and only exists for debugging/logging purposes. // Currently set to ESPHOME_VERSION string literal. - string server_info = 3 [(max_data_length) = 32, (force) = true]; + string server_info = 3 [(max_data_length) = 32, (force) = true, (progmem) = true]; // The name of the server (App.get_name() - device hostname) // max_data_length matches ESPHOME_DEVICE_NAME_MAX_LEN (validated by validate_hostname) @@ -278,7 +278,7 @@ message DeviceInfoResponse { string mac_address = 3 [(max_data_length) = 17, (force) = true]; // A string describing the ESPHome version. For example "1.10.0" - string esphome_version = 4 [(max_data_length) = 32, (force) = true]; + string esphome_version = 4 [(max_data_length) = 32, (force) = true, (progmem) = true]; // A string describing the date of compilation, this is generated by the compiler // and therefore may not be in the same format all the time. @@ -287,14 +287,14 @@ message DeviceInfoResponse { // The model of the board. For example NodeMCU // max_data_length matches core/config.BOARD_MAX_LENGTH (validated in platform schemas) - string model = 6 [(max_data_length) = 127, (force) = true]; + string model = 6 [(max_data_length) = 127, (force) = true, (progmem) = true]; bool has_deep_sleep = 7 [(field_ifdef) = "USE_DEEP_SLEEP"]; // The esphome project details if set // max_data_length matches core/config.PROJECT_MAX_LENGTH - string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; - string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; + string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME", (progmem) = true]; + string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME", (progmem) = true]; uint32 webserver_port = 10 [(field_ifdef) = "USE_WEBSERVER"]; @@ -304,7 +304,7 @@ message DeviceInfoResponse { // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15. uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; - string manufacturer = 12 [(max_data_length) = 20, (force) = true]; + string manufacturer = 12 [(max_data_length) = 20, (force) = true, (progmem) = true]; string friendly_name = 13 [(max_data_length) = 120, (force) = true]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index bcb6486a84d..83269d07653 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -28,6 +28,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/version.h" #ifdef USE_PROVISIONING #include "esphome/components/provisioning/provisioning.h" @@ -84,8 +85,6 @@ static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * // 28-30s. See https://github.com/esphome/esphome/issues/14999 static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000; -static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); - // Cross-validate C++ constants against proto max_data_length annotations in api.proto static_assert(MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1 == 17, "Update max_data_length for mac_address/bluetooth_mac_address in api.proto"); @@ -1899,6 +1898,11 @@ void APIConnection::complete_authentication_() { #endif } +// A flash string on ESP8266, a mergeable literal elsewhere; #undef after the device info reply +#define PROGMEM_STRING_REF(s) ProgmemStringRef(ESPHOME_PSTR(s), sizeof(s) - 1) +// One flash copy for the hello and device info replies +static ProgmemStringRef esphome_version_ref() { return PROGMEM_STRING_REF(ESPHOME_VERSION); } + bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Copy client name with truncation if needed (set_client_name handles truncation) this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size()); @@ -1914,7 +1918,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { resp.api_version_major = 1; resp.api_version_minor = 19; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise - resp.server_info = ESPHOME_VERSION_REF; + resp.server_info = esphome_version_ref(); resp.name = StringRef(App.get_name()); #ifdef USE_PROVISIONING @@ -1969,14 +1973,13 @@ bool APIConnection::send_device_info_response_() { format_mac_addr_upper(mac, mac_address); resp.mac_address = StringRef(mac_address); - resp.esphome_version = ESPHOME_VERSION_REF; + resp.esphome_version = esphome_version_ref(); // Stack buffer for build time string char build_time_str[Application::BUILD_TIME_STR_SIZE]; App.get_build_time_string(build_time_str); resp.compilation_time = StringRef(build_time_str); - // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) #define ESPHOME_MANUFACTURER "Espressif" #elif defined(USE_RP2) @@ -1993,48 +1996,19 @@ bool APIConnection::send_device_info_response_() { #define ESPHOME_MANUFACTURER "Host" #endif -#ifdef USE_ESP8266 - // ESP8266 requires PROGMEM for flash storage, copy to stack for memcpy compatibility - static const char MANUFACTURER_PROGMEM[] PROGMEM = ESPHOME_MANUFACTURER; - char manufacturer_buf[sizeof(MANUFACTURER_PROGMEM)]; - memcpy_P(manufacturer_buf, MANUFACTURER_PROGMEM, sizeof(MANUFACTURER_PROGMEM)); - resp.manufacturer = StringRef(manufacturer_buf, sizeof(MANUFACTURER_PROGMEM) - 1); -#else - static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER); - resp.manufacturer = MANUFACTURER; -#endif + resp.manufacturer = PROGMEM_STRING_REF(ESPHOME_MANUFACTURER); static_assert(sizeof(ESPHOME_MANUFACTURER) - 1 <= 20, "Update max_data_length for manufacturer in api.proto"); #undef ESPHOME_MANUFACTURER -#ifdef USE_ESP8266 - static const char MODEL_PROGMEM[] PROGMEM = ESPHOME_BOARD; - char model_buf[sizeof(MODEL_PROGMEM)]; - memcpy_P(model_buf, MODEL_PROGMEM, sizeof(MODEL_PROGMEM)); - resp.model = StringRef(model_buf, sizeof(MODEL_PROGMEM) - 1); -#else - static constexpr auto MODEL = StringRef::from_lit(ESPHOME_BOARD); - resp.model = MODEL; -#endif + resp.model = PROGMEM_STRING_REF(ESPHOME_BOARD); #ifdef USE_DEEP_SLEEP resp.has_deep_sleep = deep_sleep::global_has_deep_sleep; #endif #ifdef ESPHOME_PROJECT_NAME -#ifdef USE_ESP8266 - static const char PROJECT_NAME_PROGMEM[] PROGMEM = ESPHOME_PROJECT_NAME; - static const char PROJECT_VERSION_PROGMEM[] PROGMEM = ESPHOME_PROJECT_VERSION; - char project_name_buf[sizeof(PROJECT_NAME_PROGMEM)]; - char project_version_buf[sizeof(PROJECT_VERSION_PROGMEM)]; - memcpy_P(project_name_buf, PROJECT_NAME_PROGMEM, sizeof(PROJECT_NAME_PROGMEM)); - memcpy_P(project_version_buf, PROJECT_VERSION_PROGMEM, sizeof(PROJECT_VERSION_PROGMEM)); - resp.project_name = StringRef(project_name_buf, sizeof(PROJECT_NAME_PROGMEM) - 1); - resp.project_version = StringRef(project_version_buf, sizeof(PROJECT_VERSION_PROGMEM) - 1); -#else - static constexpr auto PROJECT_NAME = StringRef::from_lit(ESPHOME_PROJECT_NAME); - static constexpr auto PROJECT_VERSION = StringRef::from_lit(ESPHOME_PROJECT_VERSION); - resp.project_name = PROJECT_NAME; - resp.project_version = PROJECT_VERSION; -#endif + resp.project_name = PROGMEM_STRING_REF(ESPHOME_PROJECT_NAME); + resp.project_version = PROGMEM_STRING_REF(ESPHOME_PROJECT_VERSION); #endif +#undef PROGMEM_STRING_REF #ifdef USE_WEBSERVER resp.webserver_port = USE_WEBSERVER_PORT; #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8f7b622bc96..57cf7e70f61 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -31,7 +31,7 @@ uint8_t *HelloResponse::encode_msg(const void *self, uint8_t *__restrict__ pos P const auto &msg = *static_cast(self); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.api_version_major); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.api_version_minor); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.name); return pos; } @@ -117,17 +117,17 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, uint8_t *__restrict__ const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.mac_address); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, msg.compilation_time); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, msg.model); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, msg.model); #ifdef USE_DEEP_SLEEP pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, msg.project_name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, msg.project_name); #endif #ifdef ESPHOME_PROJECT_NAME - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, msg.project_version); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, msg.project_version); #endif #ifdef USE_WEBSERVER pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.webserver_port); @@ -135,7 +135,7 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, uint8_t *__restrict__ #ifdef USE_BLUETOOTH_PROXY pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.bluetooth_proxy_feature_flags); #endif - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, msg.manufacturer); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, msg.manufacturer); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, msg.friendly_name); #ifdef USE_VOICE_ASSISTANT pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.voice_assistant_feature_flags); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index fd12882dc9a..4c3dbf6df7c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -466,7 +466,7 @@ class HelloResponse final : public ProtoMessage { #endif uint32_t api_version_major{0}; uint32_t api_version_minor{0}; - StringRef server_info{}; + ProgmemStringRef server_info{}; StringRef name{}; 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 { @@ -608,17 +608,17 @@ class DeviceInfoResponse final : public ProtoMessage { #endif StringRef name{}; StringRef mac_address{}; - StringRef esphome_version{}; + ProgmemStringRef esphome_version{}; StringRef compilation_time{}; - StringRef model{}; + ProgmemStringRef model{}; #ifdef USE_DEEP_SLEEP bool has_deep_sleep{false}; #endif #ifdef ESPHOME_PROJECT_NAME - StringRef project_name{}; + ProgmemStringRef project_name{}; #endif #ifdef ESPHOME_PROJECT_NAME - StringRef project_version{}; + ProgmemStringRef project_version{}; #endif #ifdef USE_WEBSERVER uint32_t webserver_port{0}; @@ -626,7 +626,7 @@ class DeviceInfoResponse final : public ProtoMessage { #ifdef USE_BLUETOOTH_PROXY uint32_t bluetooth_proxy_feature_flags{0}; #endif - StringRef manufacturer{}; + ProgmemStringRef manufacturer{}; StringRef friendly_name{}; #ifdef USE_VOICE_ASSISTANT uint32_t voice_assistant_feature_flags{0}; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 688ec48e6e6..757c569cc89 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -970,7 +970,7 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HelloResponse")); dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major); dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor); - dump_field(out, ESPHOME_PSTR("server_info"), this->server_info); + dump_progmem_string_field(out, ESPHOME_PSTR("server_info"), this->server_info); dump_field(out, ESPHOME_PSTR("name"), this->name); return out.c_str(); } @@ -1021,17 +1021,17 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceInfoResponse")); dump_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address); - dump_field(out, ESPHOME_PSTR("esphome_version"), this->esphome_version); + dump_progmem_string_field(out, ESPHOME_PSTR("esphome_version"), this->esphome_version); dump_field(out, ESPHOME_PSTR("compilation_time"), this->compilation_time); - dump_field(out, ESPHOME_PSTR("model"), this->model); + dump_progmem_string_field(out, ESPHOME_PSTR("model"), this->model); #ifdef USE_DEEP_SLEEP dump_field(out, ESPHOME_PSTR("has_deep_sleep"), this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - dump_field(out, ESPHOME_PSTR("project_name"), this->project_name); + dump_progmem_string_field(out, ESPHOME_PSTR("project_name"), this->project_name); #endif #ifdef ESPHOME_PROJECT_NAME - dump_field(out, ESPHOME_PSTR("project_version"), this->project_version); + dump_progmem_string_field(out, ESPHOME_PSTR("project_version"), this->project_version); #endif #ifdef USE_WEBSERVER dump_field(out, ESPHOME_PSTR("webserver_port"), this->webserver_port); @@ -1039,7 +1039,7 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #ifdef USE_BLUETOOTH_PROXY dump_field(out, ESPHOME_PSTR("bluetooth_proxy_feature_flags"), this->bluetooth_proxy_feature_flags); #endif - dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer); + dump_progmem_string_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer); dump_field(out, ESPHOME_PSTR("friendly_name"), this->friendly_name); #ifdef USE_VOICE_ASSISTANT dump_field(out, ESPHOME_PSTR("voice_assistant_feature_flags"), this->voice_assistant_feature_flags); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 2ddc97efb5f..d9f29259d03 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -559,7 +559,7 @@ void Application::get_comment_string(std::span b uint32_t Application::get_config_hash() { return ESPHOME_CONFIG_HASH; } -uint32_t Application::get_config_version_hash() { return fnv1a_hash_extend(ESPHOME_CONFIG_HASH, ESPHOME_VERSION); } +uint32_t Application::get_config_version_hash() { return ESPHOME_CONFIG_VERSION_HASH; } time_t Application::get_build_time() { return ESPHOME_BUILD_TIME; } diff --git a/esphome/core/build_info_data.h b/esphome/core/build_info_data.h index 02bb465e44f..c33edbdeeee 100644 --- a/esphome/core/build_info_data.h +++ b/esphome/core/build_info_data.h @@ -5,8 +5,9 @@ // // This file is only used by static analyzers and IDEs. -#define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT -#define ESPHOME_BUILD_TIME 1700000000 // NOLINT -#define ESPHOME_COMMENT_SIZE 1 // NOLINT +#define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT +#define ESPHOME_CONFIG_VERSION_HASH 0x9abcdef0U // NOLINT +#define ESPHOME_BUILD_TIME 1700000000 // NOLINT +#define ESPHOME_COMMENT_SIZE 1 // NOLINT static const char ESPHOME_BUILD_TIME_STR[] = "2024-01-01 00:00:00 +0000"; static const char ESPHOME_COMMENT_STR[] = ""; diff --git a/esphome/writer.py b/esphome/writer.py index e6c0c0e2e90..d5cf622c2c1 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -472,6 +472,7 @@ def generate_build_info_data_h() -> str: namespace esphome { extern const uint32_t ESPHOME_CONFIG_HASH; +extern const uint32_t ESPHOME_CONFIG_VERSION_HASH; extern const time_t ESPHOME_BUILD_TIME; extern const size_t ESPHOME_COMMENT_SIZE; #ifdef USE_ESP8266 @@ -500,9 +501,14 @@ def generate_build_info_data_cpp( comment_size = len(comment.encode("utf-8")) + 1 # +1 for NUL return f"""// Auto-generated build_info data #include "esphome/core/build_info_data.h" +#include "esphome/core/helpers.h" +#include "esphome/core/version.h" namespace esphome {{ const uint32_t ESPHOME_CONFIG_HASH = 0x{config_hash:08x}U; // NOLINT +// Folded at compile time, so the version string is not kept in RAM +static constexpr uint32_t CONFIG_VERSION_HASH = fnv1a_hash_extend(0x{config_hash:08x}U, ESPHOME_VERSION); +const uint32_t ESPHOME_CONFIG_VERSION_HASH = CONFIG_VERSION_HASH; // NOLINT const time_t ESPHOME_BUILD_TIME = {build_time}; // NOLINT const size_t ESPHOME_COMMENT_SIZE = {comment_size}; // NOLINT #ifdef USE_ESP8266 diff --git a/tests/benchmarks/components/api/bench_proto_encode.cpp b/tests/benchmarks/components/api/bench_proto_encode.cpp index e1383e89900..f8d836489f2 100644 --- a/tests/benchmarks/components/api/bench_proto_encode.cpp +++ b/tests/benchmarks/components/api/bench_proto_encode.cpp @@ -123,7 +123,7 @@ static void Encode_HelloResponse(benchmark::State &state) { HelloResponse msg; msg.api_version_major = 1; msg.api_version_minor = 10; - msg.server_info = StringRef::from_lit("esphome v2026.3.0"); + msg.server_info = ProgmemStringRef::from_lit("esphome v2026.3.0"); msg.name = StringRef::from_lit("living-room-sensor"); uint32_t size = msg.calculate_size(); (void) buffer.resize(size); @@ -204,10 +204,10 @@ static DeviceInfoResponse make_device_info_response() { DeviceInfoResponse msg; msg.name = StringRef::from_lit("living-room-sensor"); msg.mac_address = StringRef::from_lit("AA:BB:CC:DD:EE:FF"); - msg.esphome_version = StringRef::from_lit("2026.3.0"); + msg.esphome_version = ProgmemStringRef::from_lit("2026.3.0"); msg.compilation_time = StringRef::from_lit("Mar 16 2026, 12:00:00"); - msg.model = StringRef::from_lit("esp32-poe-iso"); - msg.manufacturer = StringRef::from_lit("Olimex"); + msg.model = ProgmemStringRef::from_lit("esp32-poe-iso"); + msg.manufacturer = ProgmemStringRef::from_lit("Olimex"); msg.friendly_name = StringRef::from_lit("Living Room Sensor"); #ifdef USE_DEVICES for (uint32_t i = 0; i < ESPHOME_DEVICE_COUNT && i < 20; i++) { diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index f03a42b6e8d..4575371822a 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -2190,6 +2190,7 @@ def test_generate_build_info_data_h_format() -> None: assert "#pragma once" in result assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in result + assert "extern const uint32_t ESPHOME_CONFIG_VERSION_HASH;" in result assert "extern const time_t ESPHOME_BUILD_TIME;" in result assert "extern const size_t ESPHOME_COMMENT_SIZE;" in result assert "extern const char ESPHOME_BUILD_TIME_STR[]" in result @@ -2206,6 +2207,13 @@ def test_generate_build_info_data_h_esp8266_progmem() -> None: assert "PROGMEM" in result +def test_generate_build_info_data_cpp_config_version_hash() -> None: + """The config version hash is folded from the config hash and ESPHOME_VERSION.""" + result = generate_build_info_data_cpp(0x12345678, 1700000000, "", "") + assert "fnv1a_hash_extend(0x12345678U, ESPHOME_VERSION);" in result + assert "ESPHOME_CONFIG_VERSION_HASH = CONFIG_VERSION_HASH;" in result + + def test_generate_build_info_data_cpp_format() -> None: """Test generate_build_info_data_cpp produces correct data definitions.""" result = generate_build_info_data_cpp( From 7cf72c287db67fa7a518bfa05991b8ef33739e69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:50:39 -1000 Subject: [PATCH 650/651] [web_server] Pass the listing flag as a bool and share the state event send (#21112) --- .../components/web_server/list_entities.cpp | 51 +++---- esphome/components/web_server/list_entities.h | 7 + esphome/components/web_server/web_server.cpp | 136 ++++-------------- esphome/components/web_server/web_server.h | 16 ++- .../web_server_idf/web_server_idf.cpp | 24 +--- .../web_server_idf/web_server_idf.h | 9 +- 6 files changed, 92 insertions(+), 151 deletions(-) diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 869ed3ea173..07f2ce205a9 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -14,155 +14,158 @@ ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, AsyncEventSource ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es) : web_server_(ws), events_(es) {} #endif +void ListEntitiesIterator::send_all_(void *source, message_generator_t *generator) { + this->events_->deferrable_send_state(source, true, generator); +} + #ifdef USE_BINARY_SENSOR bool ListEntitiesIterator::on_binary_sensor(binary_sensor::BinarySensor *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::binary_sensor_all_json_generator); + this->send_all_(obj, WebServer::binary_sensor_all_json_generator); return true; } #endif #ifdef USE_COVER bool ListEntitiesIterator::on_cover(cover::Cover *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::cover_all_json_generator); + this->send_all_(obj, WebServer::cover_all_json_generator); return true; } #endif #ifdef USE_FAN bool ListEntitiesIterator::on_fan(fan::Fan *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::fan_all_json_generator); + this->send_all_(obj, WebServer::fan_all_json_generator); return true; } #endif #ifdef USE_LIGHT bool ListEntitiesIterator::on_light(light::LightState *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::light_all_json_generator); + this->send_all_(obj, WebServer::light_all_json_generator); return true; } #endif #ifdef USE_SENSOR bool ListEntitiesIterator::on_sensor(sensor::Sensor *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::sensor_all_json_generator); + this->send_all_(obj, WebServer::sensor_all_json_generator); return true; } #endif #ifdef USE_SWITCH bool ListEntitiesIterator::on_switch(switch_::Switch *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::switch_all_json_generator); + this->send_all_(obj, WebServer::switch_all_json_generator); return true; } #endif #ifdef USE_BUTTON bool ListEntitiesIterator::on_button(button::Button *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::button_all_json_generator); + this->send_all_(obj, WebServer::button_all_json_generator); return true; } #endif #ifdef USE_TEXT_SENSOR bool ListEntitiesIterator::on_text_sensor(text_sensor::TextSensor *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_sensor_all_json_generator); + this->send_all_(obj, WebServer::text_sensor_all_json_generator); return true; } #endif #ifdef USE_LOCK bool ListEntitiesIterator::on_lock(lock::Lock *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::lock_all_json_generator); + this->send_all_(obj, WebServer::lock_all_json_generator); return true; } #endif #ifdef USE_VALVE bool ListEntitiesIterator::on_valve(valve::Valve *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::valve_all_json_generator); + this->send_all_(obj, WebServer::valve_all_json_generator); return true; } #endif #ifdef USE_CLIMATE bool ListEntitiesIterator::on_climate(climate::Climate *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::climate_all_json_generator); + this->send_all_(obj, WebServer::climate_all_json_generator); return true; } #endif #ifdef USE_NUMBER bool ListEntitiesIterator::on_number(number::Number *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::number_all_json_generator); + this->send_all_(obj, WebServer::number_all_json_generator); return true; } #endif #ifdef USE_DATETIME_DATE bool ListEntitiesIterator::on_date(datetime::DateEntity *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::date_all_json_generator); + this->send_all_(obj, WebServer::date_all_json_generator); return true; } #endif #ifdef USE_DATETIME_TIME bool ListEntitiesIterator::on_time(datetime::TimeEntity *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::time_all_json_generator); + this->send_all_(obj, WebServer::time_all_json_generator); return true; } #endif #ifdef USE_DATETIME_DATETIME bool ListEntitiesIterator::on_datetime(datetime::DateTimeEntity *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::datetime_all_json_generator); + this->send_all_(obj, WebServer::datetime_all_json_generator); return true; } #endif #ifdef USE_TEXT bool ListEntitiesIterator::on_text(text::Text *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_all_json_generator); + this->send_all_(obj, WebServer::text_all_json_generator); return true; } #endif #ifdef USE_SELECT bool ListEntitiesIterator::on_select(select::Select *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::select_all_json_generator); + this->send_all_(obj, WebServer::select_all_json_generator); return true; } #endif #ifdef USE_ALARM_CONTROL_PANEL bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::alarm_control_panel_all_json_generator); + this->send_all_(obj, WebServer::alarm_control_panel_all_json_generator); return true; } #endif #ifdef USE_WATER_HEATER bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::water_heater_all_json_generator); + this->send_all_(obj, WebServer::water_heater_all_json_generator); return true; } #endif #ifdef USE_INFRARED bool ListEntitiesIterator::on_infrared(infrared::Infrared *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::infrared_all_json_generator); + this->send_all_(obj, WebServer::infrared_all_json_generator); return true; } #endif #ifdef USE_RADIO_FREQUENCY bool ListEntitiesIterator::on_radio_frequency(radio_frequency::RadioFrequency *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::radio_frequency_all_json_generator); + this->send_all_(obj, WebServer::radio_frequency_all_json_generator); return true; } #endif #ifdef USE_EVENT bool ListEntitiesIterator::on_event(event::Event *obj) { - // Null event type, since we are just iterating over entities - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::event_all_json_generator); + this->send_all_(obj, WebServer::event_all_json_generator); return true; } #endif #ifdef USE_UPDATE bool ListEntitiesIterator::on_update(update::UpdateEntity *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::update_all_json_generator); + this->send_all_(obj, WebServer::update_all_json_generator); return true; } #endif diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index dc32cbd1ad6..4323c7e3695 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -4,6 +4,10 @@ #ifdef USE_WEBSERVER #include "esphome/core/component.h" #include "esphome/core/component_iterator.h" +namespace esphome::json { +class JsonBuilder; +} // namespace esphome::json + namespace esphome::web_server_idf { #ifdef USE_ESP32 class AsyncEventSource; @@ -37,6 +41,9 @@ class ListEntitiesIterator final : public ComponentIterator { // NOLINTEND(bugprone-macro-parentheses) protected: + // Same type as message_generator_t, which this header cannot include + void send_all_(void *source, void (*generator)(WebServer *, void *, json::JsonBuilder &)); + const WebServer *web_server_; #ifdef USE_ESP32 esphome::web_server_idf::AsyncEventSource *events_; diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 4546ead98b2..613f2f854dd 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -278,28 +278,17 @@ void DeferredUpdateEventSource::loop() { this->entities_iterator_.try_advance(1); } -void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type, +void DeferredUpdateEventSource::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) { // Skip if no connected clients to avoid unnecessary deferred queue processing if (this->count() == 0) return; - // allow all json "details_all" to go through before publishing bare state events, this avoids unnamed entries showing - // up in the web GUI and reduces event load during initial connect - if (!entities_iterator_.completed() && 0 != strcmp(event_type, "state_detail_all")) + // Let the detail_all listing finish before bare state events, so the web UI never shows unnamed entries and + // initial connect sends less + if (!detail_all && !this->entities_iterator_.completed()) return; - if (source == nullptr) - return; - if (event_type == nullptr) - return; - if (message_generator == nullptr) - return; - - if (0 != strcmp(event_type, "state_detail_all") && 0 != strcmp(event_type, "state")) { - ESP_LOGE(TAG, "Can't defer non-state event"); - } - if (!deferred_queue_.empty()) process_deferred_queue_(); if (!deferred_queue_.empty()) { @@ -332,13 +321,13 @@ bool DeferredUpdateEventSourceList::loop() { return !this->empty(); } -void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const char *event_type, +void DeferredUpdateEventSourceList::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) { // Skip if no event sources (no connected clients) to avoid unnecessary iteration if (this->empty()) return; for (DeferredUpdateEventSource *dues : *this) { - dues->deferrable_send_state(source, event_type, message_generator); + dues->deferrable_send_state(source, detail_all, message_generator); } } @@ -711,12 +700,13 @@ static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const ch return request->arg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE; } -#ifdef USE_SENSOR -void WebServer::on_sensor_update(sensor::Sensor *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", sensor_state_json_generator); +void WebServer::send_state_event_(void *source, const EntityBase *entity, message_generator_t *generator) { + if (!entity->is_internal() || this->include_internal_) + this->events_.deferrable_send_state(source, false, generator); } + +#ifdef USE_SENSOR +void WebServer::on_sensor_update(sensor::Sensor *obj) { this->send_state_event_(obj, sensor_state_json_generator); } void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (sensor::Sensor *obj : App.get_sensors()) { auto entity_match = match.match_entity(obj); @@ -758,9 +748,7 @@ void WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_ #ifdef USE_TEXT_SENSOR void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", text_sensor_state_json_generator); + this->send_state_event_(obj, text_sensor_state_json_generator); } void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (text_sensor::TextSensor *obj : App.get_text_sensors()) { @@ -818,11 +806,7 @@ static void execute_switch_action(switch_::Switch *obj, SwitchAction action) { } } -void WebServer::on_switch_update(switch_::Switch *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", switch_state_json_generator); -} +void WebServer::on_switch_update(switch_::Switch *obj) { this->send_state_event_(obj, switch_state_json_generator); } void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (switch_::Switch *obj : App.get_switches()) { auto entity_match = match.match_entity(obj); @@ -911,9 +895,7 @@ void WebServer::button_json_(button::Button *obj, JsonDetail start_config, json: #ifdef USE_BINARY_SENSOR void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", binary_sensor_state_json_generator); + this->send_state_event_(obj, binary_sensor_state_json_generator); } void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) { @@ -951,11 +933,7 @@ void WebServer::binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value #endif #ifdef USE_FAN -void WebServer::on_fan_update(fan::Fan *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", fan_state_json_generator); -} +void WebServer::on_fan_update(fan::Fan *obj) { this->send_state_event_(obj, fan_state_json_generator); } void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (fan::Fan *obj : App.get_fans()) { auto entity_match = match.match_entity(obj); @@ -1030,11 +1008,7 @@ void WebServer::fan_json_(fan::Fan *obj, JsonDetail start_config, json::JsonBuil #endif #ifdef USE_LIGHT -void WebServer::on_light_update(light::LightState *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", light_state_json_generator); -} +void WebServer::on_light_update(light::LightState *obj) { this->send_state_event_(obj, light_state_json_generator); } void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (light::LightState *obj : App.get_lights()) { auto entity_match = match.match_entity(obj); @@ -1109,11 +1083,7 @@ void WebServer::light_json_(light::LightState *obj, JsonDetail start_config, jso #endif #ifdef USE_COVER -void WebServer::on_cover_update(cover::Cover *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", cover_state_json_generator); -} +void WebServer::on_cover_update(cover::Cover *obj) { this->send_state_event_(obj, cover_state_json_generator); } void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (cover::Cover *obj : App.get_covers()) { auto entity_match = match.match_entity(obj); @@ -1183,11 +1153,7 @@ void WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config, json::Js #endif #ifdef USE_NUMBER -void WebServer::on_number_update(number::Number *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", number_state_json_generator); -} +void WebServer::on_number_update(number::Number *obj) { this->send_state_event_(obj, number_state_json_generator); } void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_numbers()) { auto entity_match = match.match_entity(obj); @@ -1257,11 +1223,7 @@ void WebServer::number_json_(number::Number *obj, JsonDetail start_config, json: #endif #ifdef USE_DATETIME_DATE -void WebServer::on_date_update(datetime::DateEntity *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", date_state_json_generator); -} +void WebServer::on_date_update(datetime::DateEntity *obj) { this->send_state_event_(obj, date_state_json_generator); } void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_dates()) { auto entity_match = match.match_entity(obj); @@ -1316,11 +1278,7 @@ void WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config, j #endif // USE_DATETIME_DATE #ifdef USE_DATETIME_TIME -void WebServer::on_time_update(datetime::TimeEntity *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", time_state_json_generator); -} +void WebServer::on_time_update(datetime::TimeEntity *obj) { this->send_state_event_(obj, time_state_json_generator); } void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_times()) { auto entity_match = match.match_entity(obj); @@ -1375,9 +1333,7 @@ void WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config, j #ifdef USE_DATETIME_DATETIME void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", datetime_state_json_generator); + this->send_state_event_(obj, datetime_state_json_generator); } void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_datetimes()) { @@ -1433,11 +1389,7 @@ void WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_c #endif // USE_DATETIME_DATETIME #ifdef USE_TEXT -void WebServer::on_text_update(text::Text *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", text_state_json_generator); -} +void WebServer::on_text_update(text::Text *obj) { this->send_state_event_(obj, text_state_json_generator); } void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_texts()) { auto entity_match = match.match_entity(obj); @@ -1496,11 +1448,7 @@ void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail #endif #ifdef USE_SELECT -void WebServer::on_select_update(select::Select *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", select_state_json_generator); -} +void WebServer::on_select_update(select::Select *obj) { this->send_state_event_(obj, select_state_json_generator); } void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_selects()) { auto entity_match = match.match_entity(obj); @@ -1556,11 +1504,7 @@ void WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail st #endif #ifdef USE_CLIMATE -void WebServer::on_climate_update(climate::Climate *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", climate_state_json_generator); -} +void WebServer::on_climate_update(climate::Climate *obj) { this->send_state_event_(obj, climate_state_json_generator); } void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_climates()) { auto entity_match = match.match_entity(obj); @@ -1743,11 +1687,7 @@ static void execute_lock_action(lock::Lock *obj, LockAction action) { } } -void WebServer::on_lock_update(lock::Lock *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", lock_state_json_generator); -} +void WebServer::on_lock_update(lock::Lock *obj) { this->send_state_event_(obj, lock_state_json_generator); } void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (lock::Lock *obj : App.get_locks()) { auto entity_match = match.match_entity(obj); @@ -1800,11 +1740,7 @@ void WebServer::lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail st #endif #ifdef USE_VALVE -void WebServer::on_valve_update(valve::Valve *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", valve_state_json_generator); -} +void WebServer::on_valve_update(valve::Valve *obj) { this->send_state_event_(obj, valve_state_json_generator); } void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (valve::Valve *obj : App.get_valves()) { auto entity_match = match.match_entity(obj); @@ -1870,9 +1806,7 @@ void WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config, json::Js #ifdef USE_ALARM_CONTROL_PANEL void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", alarm_control_panel_state_json_generator); + this->send_state_event_(obj, alarm_control_panel_state_json_generator); } void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) { @@ -1940,9 +1874,7 @@ void WebServer::alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel #ifdef USE_WATER_HEATER void WebServer::on_water_heater_update(water_heater::WaterHeater *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", water_heater_state_json_generator); + this->send_state_event_(obj, water_heater_state_json_generator); } void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (water_heater::WaterHeater *obj : App.get_water_heaters()) { @@ -2235,11 +2167,7 @@ void WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj, Json #endif #ifdef USE_EVENT -void WebServer::on_event(event::Event *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", event_state_json_generator); -} +void WebServer::on_event(event::Event *obj) { this->send_state_event_(obj, event_state_json_generator); } void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (event::Event *obj : App.get_events()) { @@ -2292,9 +2220,7 @@ void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail #endif #ifdef USE_UPDATE -void WebServer::on_update(update::UpdateEntity *obj) { - this->events_.deferrable_send_state(obj, "state", update_state_json_generator); -} +void WebServer::on_update(update::UpdateEntity *obj) { this->send_state_event_(obj, update_state_json_generator); } void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (update::UpdateEntity *obj : App.get_updates()) { auto entity_match = match.match_entity(obj); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d545fbf95dd..13695e033ec 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -162,7 +162,10 @@ class DeferredUpdateEventSource final : public AsyncEventSource { void loop(); - void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); + // detail_all events go out while entities are still being listed; state events wait until listing is done + void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator); + // The old event name argument would otherwise convert to true + void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete; void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); }; @@ -176,7 +179,9 @@ class DeferredUpdateEventSourceList final : public std::list void send_state_event_(T *obj, message_generator_t *generator) { + this->send_state_event_(obj, static_cast(obj), generator); + } + void send_state_event_(void *source, const EntityBase *entity, message_generator_t *generator); + void add_sorting_info_(JsonObject &root, EntityBase *entity); #ifdef USE_LIGHT diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 456c9be2c9a..b2792d14d3e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -797,14 +797,13 @@ void AsyncEventSource::try_send_nodefer(const char *message, size_t message_len, } } -void AsyncEventSource::deferrable_send_state(void *source, const char *event_type, - message_generator_t *message_generator) { +void AsyncEventSource::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) { // Skip if no connected clients to avoid unnecessary processing if (this->empty()) return; for (auto *ses : this->sessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions - ses->deferrable_send_state(source, event_type, message_generator); + ses->deferrable_send_state(source, detail_all, message_generator); } } } @@ -1268,24 +1267,13 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t mess return this->stash_chunk_(prefix, prefix_len, message, message_len, g.total, sent); } -void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *event_type, +void AsyncEventSourceResponse::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) { - // allow all json "details_all" to go through before publishing bare state events, this avoids unnamed entries showing - // up in the web GUI and reduces event load during initial connect - if (!this->entities_iterator_.completed() && 0 != strcmp(event_type, "state_detail_all")) + // Let the detail_all listing finish before bare state events, so the web UI never shows unnamed entries and + // initial connect sends less + if (!detail_all && !this->entities_iterator_.completed()) return; - if (source == nullptr) - return; - if (event_type == nullptr) - return; - if (message_generator == nullptr) - return; - - if (0 != strcmp(event_type, "state_detail_all") && 0 != strcmp(event_type, "state")) { - ESP_LOGE(TAG, "Can't defer non-state event"); - } - drain_tail_(); process_deferred_queue_(); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c25dd8d609f..c87aca0aade 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -301,7 +301,10 @@ class AsyncEventSourceResponse { public: bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); - void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); + // detail_all events go out while entities are still being listed; state events wait until listing is done + void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator); + // The old event name argument would otherwise convert to true + void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete; void loop(); protected: @@ -413,7 +416,9 @@ class AsyncEventSource : public AsyncWebHandler { void handleRequest(AsyncWebServerRequest *request) override; void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); - void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); + void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator); + // The old event name argument would otherwise convert to true + void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete; /// Returns true if there are sessions remaining (including pending cleanup). bool loop(); bool empty() { return this->count() == 0; } From 85dd0f71b3797b10d07cc45bd23d8bc9ac04e3ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:50:56 -1000 Subject: [PATCH 651/651] [core] Add Apply entities so hub forwarding buttons, switches, numbers and selects need no class (#20935) --- esphome/automation.py | 14 +- esphome/components/button/__init__.py | 9 +- esphome/components/button/button.h | 10 + esphome/components/number/__init__.py | 13 +- esphome/components/number/number.h | 10 + esphome/components/select/__init__.py | 14 +- esphome/components/select/select.h | 10 + esphome/components/switch/__init__.py | 13 +- esphome/components/switch/switch.h | 10 + esphome/core/entity_helpers.py | 103 +++++- tests/components/button/apply_button_test.cpp | 27 ++ tests/components/number/apply_number_test.cpp | 31 ++ tests/components/select/apply_select_test.cpp | 27 ++ tests/components/switch/apply_switch_test.cpp | 37 ++ tests/unit_tests/core/test_entity_helpers.py | 320 +++++++++++++++++- 15 files changed, 625 insertions(+), 23 deletions(-) create mode 100644 tests/components/button/apply_button_test.cpp create mode 100644 tests/components/number/apply_number_test.cpp create mode 100644 tests/components/select/apply_select_test.cpp create mode 100644 tests/components/switch/apply_switch_test.cpp diff --git a/esphome/automation.py b/esphome/automation.py index 91cbf0daab1..d1d504f9121 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -430,12 +430,20 @@ def _apply_function( ) -> MockObj: """Emit the generated function and declare ``id_`` as the ``ApplyAction`` or ``ApplyCondition`` templated on it, so ``play()`` calls it directly.""" - fn = cg.static_function( - f"esphome__{id_.id}__fn", return_type, lambda_args, statements - ) + fn = apply_static_function(id_, return_type, lambda_args, statements) return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) +def apply_static_function( + id_: ID, + return_type: SafeExpType, + params: TemplateArgsType, + statements: list[str], +) -> MockObj: + """Emit the static function generated for ``id_`` and return an expression naming it.""" + return cg.static_function(f"esphome__{id_.id}__fn", return_type, params, statements) + + async def _render_values( name: str, target: str, diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index a4543c06c07..b42b5a9be61 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -23,6 +23,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + ApplyEntity, SubEntities, entity_duplicate_validator, queue_entity_register, @@ -46,6 +47,7 @@ DEVICE_CLASSES = [ button_ns = cg.esphome_ns.namespace("button") Button = button_ns.class_("Button", cg.EntityBase) ButtonPtr = Button.operator("ptr") +ApplyButton = button_ns.class_("ApplyButton", Button) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") @@ -155,11 +157,14 @@ async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: return var +APPLY_ENTITY = ApplyEntity(ApplyButton, ((ButtonPtr, "entity"),)) + + def sub_buttons( config: ConfigType, *, parent: MockObj | ID | None = None ) -> SubEntities: - """Return a SubEntities bound to new_button.""" - return SubEntities(new_button, config, parent) + """Return a SubEntities bound to new_button; ``apply`` statements can use ``entity``.""" + return SubEntities(new_button, config, parent, APPLY_ENTITY) BUTTON_PRESS_SCHEMA = maybe_simple_id( diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index 96e9107532b..81e55e99038 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -46,4 +46,14 @@ class Button : public EntityBase { LazyCallbackManager press_callback_{}; }; +/// Calls the codegen-generated Fn(this) on a press; the hub and any publish are baked into Fn. +template class ApplyButton final : public Button { + public: + // Not "= default": placement new would zero-fill .bss that is already zero. + ApplyButton() {} + + protected: + void press_action() override { Fn(this); } +}; + } // namespace esphome::button diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 96352a8f1a6..c863caa51af 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -82,6 +82,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + ApplyEntity, SubEntities, entity_duplicate_validator, queue_entity_register, @@ -160,6 +161,7 @@ IS_PLATFORM_COMPONENT = True number_ns = cg.esphome_ns.namespace("number") Number = number_ns.class_("Number", cg.EntityBase) NumberPtr = Number.operator("ptr") +ApplyNumber = number_ns.class_("ApplyNumber", Number) # Triggers ValueRangeTrigger = number_ns.class_( @@ -321,11 +323,18 @@ async def new_number(config, *args, min_value: float, max_value: float, step: fl return var +APPLY_ENTITY = ApplyEntity( + ApplyNumber, + ((NumberPtr, "entity"), (cg.float_, "value")), + "entity->publish_state(value);", +) + + def sub_numbers( config: ConfigType, *, parent: MockObj | ID | None = None ) -> SubEntities: - """Return a SubEntities bound to new_number.""" - return SubEntities(new_number, config, parent) + """Return a SubEntities bound to new_number; ``apply`` statements can use ``entity`` and ``value``.""" + return SubEntities(new_number, config, parent, APPLY_ENTITY) NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All( diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index b697e770be0..b18b93e4b86 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -54,4 +54,14 @@ class Number : public EntityBase { LazyCallbackManager state_callback_; }; +/// Calls the codegen-generated Fn(this, value) on a new value; the hub and any publish are baked into Fn. +template class ApplyNumber final : public Number { + public: + // Not "= default": placement new would zero-fill .bss that is already zero. + ApplyNumber() {} + + protected: + void control(float value) override { Fn(this, value); } +}; + } // namespace esphome::number diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 68fe39b6bcb..3d23dc14066 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + ApplyEntity, SubEntities, entity_duplicate_validator, queue_entity_register, @@ -35,6 +36,7 @@ IS_PLATFORM_COMPONENT = True select_ns = cg.esphome_ns.namespace("select") Select = select_ns.class_("Select", cg.EntityBase) SelectPtr = Select.operator("ptr") +ApplySelect = select_ns.class_("ApplySelect", Select) # Conditions SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) @@ -121,11 +123,19 @@ async def new_select(config, *args, options: list[str]): return var +APPLY_ENTITY = ApplyEntity( + ApplySelect, + ((SelectPtr, "entity"), (cg.size_t, "index")), + "entity->publish_state(index);", + (("option", "const char *option = entity->option_at(index);"),), +) + + def sub_selects( config: ConfigType, *, parent: MockObj | ID | None = None ) -> SubEntities: - """Return a SubEntities bound to new_select.""" - return SubEntities(new_select, config, parent) + """Return a SubEntities bound to new_select; ``apply`` statements can use ``entity``, ``index`` and ``option``.""" + return SubEntities(new_select, config, parent, APPLY_ENTITY) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 2294f34e624..3bbf9344e37 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -112,4 +112,14 @@ class Select : public EntityBase { LazyCallbackManager state_callback_; }; +/// Calls the codegen-generated Fn(this, index) on a new index; the hub and any publish are baked into Fn. +template class ApplySelect final : public Select { + public: + // Not "= default": placement new would zero-fill .bss that is already zero. + ApplySelect() {} + + protected: + void control(size_t index) override { Fn(this, index); } +}; + } // namespace esphome::select diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index e965b552f48..51deaaac166 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -22,6 +22,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + ApplyEntity, SubEntities, entity_duplicate_validator, queue_entity_register, @@ -43,6 +44,7 @@ DEVICE_CLASSES = [ switch_ns = cg.esphome_ns.namespace("switch_") Switch = switch_ns.class_("Switch", cg.EntityBase) SwitchPtr = Switch.operator("ptr") +ApplySwitch = switch_ns.class_("ApplySwitch", Switch) SwitchRestoreMode = switch_ns.enum("SwitchRestoreMode") @@ -176,11 +178,18 @@ async def new_switch(config, *args): return var +APPLY_ENTITY = ApplyEntity( + ApplySwitch, + ((SwitchPtr, "entity"), (cg.bool_, "state")), + "entity->publish_state(state);", +) + + def sub_switches( config: ConfigType, *, parent: MockObj | ID | None = None ) -> SubEntities: - """Return a SubEntities bound to new_switch.""" - return SubEntities(new_switch, config, parent) + """Return a SubEntities bound to new_switch; ``apply`` statements can use ``entity`` and ``state``.""" + return SubEntities(new_switch, config, parent, APPLY_ENTITY) SWITCH_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index 0564c3efd28..1449d22b819 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -147,4 +147,14 @@ class Switch : public EntityBase { #define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj)) void log_switch(const char *tag, const char *prefix, const char *type, Switch *obj); +/// Calls the codegen-generated Fn(this, state) on a state write; the hub and any publish are baked into Fn. +template class ApplySwitch final : public Switch { + public: + // Not "= default": placement new would zero-fill .bss that is already zero. + ApplySwitch() {} + + protected: + void write_state(bool state) override { Fn(this, state); } +}; + } // namespace esphome::switch_ diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 4f446050253..9ac6286443e 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -2,8 +2,10 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field import functools import logging -from typing import Any +import re +from typing import Any, Literal +from esphome.automation import apply_static_function, parent_ref import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -23,7 +25,7 @@ from esphome.core.config import ( ICON_MAX_LENGTH, UNIT_OF_MEASUREMENT_MAX_LENGTH, ) -from esphome.cpp_generator import MockObj, RawStatement, add, get_variable +from esphome.cpp_generator import MockObj, MockObjClass, RawStatement, add, get_variable from esphome.cpp_types import App import esphome.final_validate as fv from esphome.helpers import ( @@ -468,33 +470,116 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> config[_KEY_ICON_IDX] = icon_idx +ApplyPublish = Literal["before", "after"] | None + + +@dataclass(frozen=True, slots=True) +class ApplyEntity: + """A domain's ``Apply*`` class, its generated function's parameters and its publish statement. + + ``locals`` are (name, declaration) pairs, declared only when the body uses the name. + """ + + cls: MockObjClass + params: tuple[tuple[SafeExpType, str], ...] + publish: str | None = None + locals: tuple[tuple[str, str], ...] = () + + +_APPLY_DOMAIN = "entity_apply" + + @dataclass(frozen=True, slots=True) class SubEntities: """Create the child entity configured under key, set parent (if bound) and pass it to setter. - Extra arguments go to new_entity. Returns None only when key is absent, so the result can be - used directly as a condition. + Returns None only when key is absent. ``apply``/``publish`` are documented on ``create``. """ new_entity: Callable[..., Awaitable[MockObj]] config: ConfigType parent: MockObj | ID | None = None + apply_entity: ApplyEntity | None = None async def __call__( self, key: str, - setter: Callable[[MockObj], Expression], + setter: Callable[[MockObj], Expression] | None, *args: SafeExpType, **kwargs: Any, ) -> MockObj | None: if (conf := self.config.get(key)) is None: return None - var = await self.new_entity(conf, *args, **kwargs) - if self.parent is not None: - await cg.register_parented(var, self.parent) - add(setter(var)) + return await self.create(conf, setter, *args, **kwargs) + + async def create( + self, + conf: ConfigType, + setter: Callable[[MockObj], Expression] | None, + *args: SafeExpType, + apply: str | None = None, + publish: ApplyPublish = None, + **kwargs: Any, + ) -> MockObj: + """Create the entity from ``conf``; ``setter`` may be None when the hub keeps no pointer. + + With ``apply`` the entity is the domain's ``Apply*`` class and its control runs + ``parent->apply``, with ``publish`` ("before"/"after") placing the domain's publish. + """ + entity_id: ID = conf[CONF_ID] + is_apply_class = self.apply_entity is not None and str(entity_id.type) == str( + self.apply_entity.cls + ) + if apply is None and is_apply_class: + raise ValueError(f"{entity_id}: {entity_id.type} needs apply=") + if apply is not None and not is_apply_class: + raise ValueError( + f"{entity_id}: apply= needs the domain's Apply class, not {entity_id.type}" + ) + if apply is not None and self.parent is None: + raise ValueError(f"{entity_id}: apply= needs a parent") + if publish is not None and (apply is None or self.apply_entity.publish is None): + raise ValueError(f"{entity_id}: publish= needs apply= on a stateful domain") + if publish not in (None, "before", "after"): + raise ValueError(f'{entity_id}: publish= must be "before" or "after"') + + if apply is None: + var = await self.new_entity(conf, *args, **kwargs) + if self.parent is not None: + await cg.register_parented(var, self.parent) + else: + fn = await self._apply_function(entity_id, apply, publish) + var = await self.new_entity(conf, cg.TemplateArguments(fn), *args, **kwargs) + if setter is not None: + add(setter(var)) return var + async def _apply_function( + self, entity_id: ID, apply: str, publish: ApplyPublish + ) -> SafeExpType: + """Emit the control function once per distinct body, so equal entities share one class.""" + parent = self.parent + if isinstance(parent, ID): + parent = await get_variable(parent) + apply_entity = self.apply_entity + body = [f"{parent_ref(parent)}->{apply};"] + if publish == "before": + body.insert(0, apply_entity.publish) + elif publish == "after": + body.append(apply_entity.publish) + for name, decl in apply_entity.locals: + # Declared right before its first use, so calls keep their order. + uses = (i for i, line in enumerate(body) if re.search(rf"\b{name}\b", line)) + if (first := next(uses, None)) is not None: + body.insert(first, decl) + functions: dict[str, SafeExpType] = CORE.data.setdefault(_APPLY_DOMAIN, {}) + key = "\n".join([*(f"{t} {n}" for t, n in apply_entity.params), *body]) + if (fn := functions.get(key)) is None: + fn = functions[key] = apply_static_function( + entity_id, cg.void, list(apply_entity.params), body + ) + return fn + def inherit_property_from(property_to_inherit, parent_id_property, transform=None): """Validator that inherits a configuration property from another entity, for use with FINAL_VALIDATE_SCHEMA. diff --git a/tests/components/button/apply_button_test.cpp b/tests/components/button/apply_button_test.cpp new file mode 100644 index 00000000000..225427f6689 --- /dev/null +++ b/tests/components/button/apply_button_test.cpp @@ -0,0 +1,27 @@ +#include + +#include "esphome/components/button/button.h" + +namespace esphome::button::testing { + +static Button *last_entity = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static int presses = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static void on_press(Button *entity) { + last_entity = entity; + presses++; +} + +TEST(ApplyButton, PressRunsTheFunctionWithTheEntity) { + last_entity = nullptr; + presses = 0; + ApplyButton button; + button.press(); + button.press(); + EXPECT_EQ(presses, 2); + EXPECT_EQ(last_entity, &button); +} + +TEST(ApplyButton, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplyButton), sizeof(Button)); } + +} // namespace esphome::button::testing diff --git a/tests/components/number/apply_number_test.cpp b/tests/components/number/apply_number_test.cpp new file mode 100644 index 00000000000..bc412a5688b --- /dev/null +++ b/tests/components/number/apply_number_test.cpp @@ -0,0 +1,31 @@ +#include + +#include + +#include "esphome/components/number/number.h" + +namespace esphome::number::testing { + +static Number *last_entity = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static std::vector values; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static void on_control(Number *entity, float value) { + last_entity = entity; + values.push_back(value); +} + +TEST(ApplyNumber, ControlRunsTheFunctionWithTheValue) { + last_entity = nullptr; + values.clear(); + ApplyNumber number; + number.traits.set_min_value(0); + number.traits.set_max_value(10); + number.traits.set_step(1); + number.make_call().set_value(4).perform(); + EXPECT_EQ(values, (std::vector{4})); + EXPECT_EQ(last_entity, &number); +} + +TEST(ApplyNumber, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplyNumber), sizeof(Number)); } + +} // namespace esphome::number::testing diff --git a/tests/components/select/apply_select_test.cpp b/tests/components/select/apply_select_test.cpp new file mode 100644 index 00000000000..083fba742d1 --- /dev/null +++ b/tests/components/select/apply_select_test.cpp @@ -0,0 +1,27 @@ +#include + +#include +#include + +#include "esphome/components/select/select.h" + +namespace esphome::select::testing { + +static constexpr const char *const APPLY_OPTIONS[] = {"low", "medium", "high"}; + +static std::vector chosen; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static void on_control(Select *entity, size_t index) { chosen.emplace_back(entity->option_at(index)); } + +TEST(ApplySelect, ControlRunsTheFunctionWithTheIndex) { + chosen.clear(); + ApplySelect select; + select.traits.set_options_static(APPLY_OPTIONS, 3); + select.make_call().set_index(2).perform(); + select.make_call().set_option("low").perform(); + EXPECT_EQ(chosen, (std::vector{"high", "low"})); +} + +TEST(ApplySelect, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplySelect), sizeof(Select)); } + +} // namespace esphome::select::testing diff --git a/tests/components/switch/apply_switch_test.cpp b/tests/components/switch/apply_switch_test.cpp new file mode 100644 index 00000000000..14304f1f4c3 --- /dev/null +++ b/tests/components/switch/apply_switch_test.cpp @@ -0,0 +1,37 @@ +#include + +#include + +#include "esphome/components/switch/switch.h" + +namespace esphome::switch_::testing { + +static Switch *last_entity = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static std::vector writes; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static void on_write(Switch *entity, bool state) { + last_entity = entity; + writes.push_back(state); +} + +TEST(ApplySwitch, WriteRunsTheFunctionWithTheState) { + last_entity = nullptr; + writes.clear(); + ApplySwitch sw; + sw.turn_on(); + sw.turn_off(); + EXPECT_EQ(writes, (std::vector{true, false})); + EXPECT_EQ(last_entity, &sw); +} + +TEST(ApplySwitch, InversionIsAppliedBeforeTheFunction) { + writes.clear(); + ApplySwitch sw; + sw.set_inverted(true); + sw.turn_on(); + EXPECT_EQ(writes, (std::vector{false})); +} + +TEST(ApplySwitch, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplySwitch), sizeof(Switch)); } + +} // namespace esphome::switch_::testing diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index cd6b3315178..e4f2ec0b8e4 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -7,6 +7,8 @@ from typing import Any import pytest +import esphome.codegen as cg +from esphome.components import button, number, select, switch from esphome.config_validation import Invalid from esphome.const import ( CONF_DEVICE_CLASS, @@ -33,7 +35,7 @@ from esphome.core.entity_helpers import ( setup_entity, setup_unit_of_measurement, ) -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, MockObjClass, RawExpression from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -1255,10 +1257,11 @@ async def test_sub_entities_creates_and_sets( monkeypatch.setattr(entity_helpers.cg, "register_parented", fake_register_parented) hub = MockObj("hub", "->") - sub = entity_helpers.SubEntities(new_entity, {"child": {}}, parent=hub) + conf = {CONF_ID: ID("child")} + sub = entity_helpers.SubEntities(new_entity, {"child": conf}, parent=hub) assert await sub("child", hub.set_child, 1, step=2) is created - assert calls == [({}, (1,), {"step": 2})] + assert calls == [(conf, (1,), {"step": 2})] assert setup_test_environment == ["parent child hub", "hub->set_child(child)"] @@ -1294,3 +1297,314 @@ async def test_sub_entities_without_parent( assert await sub("child", hub.set_child) is not None assert setup_test_environment == ["hub->set_child(child)"] + + +@pytest.fixture +def apply_functions(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str, str]]: + """Record every generated apply function as (name, parameters, body).""" + emitted: list[tuple[str, str, str]] = [] + + def fake_static_function( + name: str, return_type: Any, parameters: list[tuple[Any, str]], body: list[str] + ) -> RawExpression: + assert str(return_type) == "void" + params = ", ".join(f"{type_} {arg}" for type_, arg in parameters) + emitted.append((name, params, "\n".join(body))) + return RawExpression(name) + + monkeypatch.setattr(entity_helpers.cg, "static_function", fake_static_function) + CORE.data.pop(entity_helpers._APPLY_DOMAIN, None) + return emitted + + +_HUB = MockObj("hub", "->") +_APPLY_SWITCH_CLS = cg.esphome_ns.namespace("switch_").class_("ApplySwitch") +_SWITCH_APPLY = entity_helpers.ApplyEntity( + _APPLY_SWITCH_CLS, + ((RawExpression("switch_::Switch *"), "entity"), (RawExpression("bool"), "state")), + "entity->publish_state(state);", +) +_BUTTON_APPLY = entity_helpers.ApplyEntity( + cg.esphome_ns.namespace("button").class_("ApplyButton"), + ((RawExpression("button::Button *"), "entity"),), +) + + +def _apply_conf(name: str, type_: MockObjClass = _APPLY_SWITCH_CLS) -> dict[str, ID]: + return {CONF_ID: ID(name, type=type_)} + + +def _apply_sub( + calls: list[tuple[Any, ...]], + config: dict[str, Any], + apply_entity: entity_helpers.ApplyEntity = _SWITCH_APPLY, + parent: MockObj | ID | None = _HUB, +) -> entity_helpers.SubEntities: + async def new_entity(conf: dict[str, Any], *args: Any, **kwargs: Any) -> MockObj: + calls.append((conf[CONF_ID].id, *(str(a) for a in args))) + return MockObj(conf[CONF_ID].id) + + return entity_helpers.SubEntities(new_entity, config, parent, apply_entity) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("publish", "body"), + [ + (None, "::hub->set_bluetooth(state);"), + ( + "before", + "entity->publish_state(state);\n::hub->set_bluetooth(state);", + ), + ("after", "::hub->set_bluetooth(state);\nentity->publish_state(state);"), + ], +) +async def test_sub_entities_apply_generates_function( + monkeypatch: pytest.MonkeyPatch, + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], + publish: str | None, + body: str, +) -> None: + """With apply the entity is templated on a generated function and stores no parent.""" + + async def fail_register_parented(var: MockObj, parent: MockObj) -> None: + raise AssertionError("must not be called") + + monkeypatch.setattr(entity_helpers.cg, "register_parented", fail_register_parented) + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"bt": _apply_conf("bt_sw")}) + + var = await sub( + "bt", _HUB.set_bt_switch, apply="set_bluetooth(state)", publish=publish + ) + assert str(var) == "bt_sw" + assert calls == [("bt_sw", "")] + assert apply_functions == [ + ("esphome__bt_sw__fn", "switch_::Switch * entity, bool state", body) + ] + assert setup_test_environment == ["hub->set_bt_switch(bt_sw)"] + + +_SELECT_APPLY = entity_helpers.ApplyEntity( + cg.esphome_ns.namespace("select").class_("ApplySelect"), + ((RawExpression("select::Select *"), "entity"), (RawExpression("size_t"), "index")), + "entity->publish_state(index);", + (("option", "const char *option = entity->option_at(index);"),), +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("apply", "publish", "body"), + [ + ( + "set_mode(option)", + "after", + ( + "const char *option = entity->option_at(index);\n" + "::hub->set_mode(option);\nentity->publish_state(index);" + ), + ), + ( + "set_mode(option)", + "before", + ( + "entity->publish_state(index);\n" + "const char *option = entity->option_at(index);\n" + "::hub->set_mode(option);" + ), + ), + ( + "set_mode(index, options_)", + "after", + "::hub->set_mode(index, options_);\nentity->publish_state(index);", + ), + ], +) +async def test_sub_entities_apply_locals( + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], + apply: str, + publish: str, + body: str, +) -> None: + """A local is declared before its first use, and only when named as a whole identifier.""" + conf = _apply_conf("m_sel", _SELECT_APPLY.cls) + sub = _apply_sub([], {"m": conf}, _SELECT_APPLY) + await sub("m", None, apply=apply, publish=publish) + assert apply_functions == [ + ("esphome__m_sel__fn", "select::Select * entity, size_t index", body) + ] + + +@pytest.mark.asyncio +async def test_sub_entities_apply_shares_equal_functions( + setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]] +) -> None: + """Entities with the same statement share one function, so one class instantiation.""" + calls: list[tuple[Any, ...]] = [] + config = { + "a": _apply_conf("a_sw"), + "b": _apply_conf("b_sw"), + "c": _apply_conf("c_sw"), + } + sub = _apply_sub(calls, config) + await sub("a", _HUB.set_a, apply="refresh()", publish="before") + await sub("b", _HUB.set_b, apply="refresh()", publish="before") + await sub("c", _HUB.set_c, apply="refresh()") + assert calls == [ + ("a_sw", ""), + ("b_sw", ""), + ("c_sw", ""), + ] + assert [name for name, _, _ in apply_functions] == [ + "esphome__a_sw__fn", + "esphome__c_sw__fn", + ] + + +@pytest.mark.asyncio +async def test_sub_entities_apply_parent_id( + monkeypatch: pytest.MonkeyPatch, + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], +) -> None: + """A parent given as an ID is looked up once and named from global scope.""" + + async def fake_get_variable(id_: ID) -> MockObj: + assert id_ == ID("my_hub") + return MockObj("my_hub", "->") + + monkeypatch.setattr(entity_helpers, "get_variable", fake_get_variable) + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"r": _apply_conf("r_sw")}, parent=ID("my_hub")) + await sub("r", MockObj("my_hub", "->").set_r, apply="restart()") + assert apply_functions[0][2] == "::my_hub->restart();" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("apply_entity", "conf", "kwargs", "match"), + [ + (_SWITCH_APPLY, _apply_conf("k_id"), {}, "needs apply="), + ( + _SWITCH_APPLY, + _apply_conf("k_id", cg.esphome_ns.namespace("x").class_("MySwitch")), + {"apply": "x()"}, + "needs the domain's Apply class", + ), + ( + _BUTTON_APPLY, + _apply_conf( + "k_id", cg.esphome_ns.namespace("button").class_("ApplyButton") + ), + {"apply": "restart()", "publish": "before"}, + "publish= needs", + ), + ( + _SWITCH_APPLY, + _apply_conf("k_id", cg.esphome_ns.namespace("x").class_("MySwitch")), + {"publish": "before"}, + "publish= needs", + ), + ( + _SWITCH_APPLY, + _apply_conf("k_id"), + {"apply": "x()", "publish": "Before"}, + "must be", + ), + ], +) +async def test_sub_entities_apply_errors( + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], + apply_entity: entity_helpers.ApplyEntity, + conf: dict[str, ID], + kwargs: dict[str, str], + match: str, +) -> None: + """A mismatch between the declared class and apply= fails in Python, not in C++.""" + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"k": conf}, apply_entity) + with pytest.raises(ValueError, match=match): + await sub("k", MockObj("hub", "->").set_k, **kwargs) + assert calls == [] + assert apply_functions == [] + + +@pytest.mark.asyncio +async def test_sub_entities_apply_needs_parent( + setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]] +) -> None: + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"k": _apply_conf("k_id")}, parent=None) + with pytest.raises(ValueError, match="needs a parent"): + await sub("k", None, apply="x()") + + +@pytest.mark.parametrize( + ("sub_fn", "cls", "params", "publish"), + [ + (button.sub_buttons, button.ApplyButton, "button::Button * entity", None), + ( + switch.sub_switches, + switch.ApplySwitch, + "switch_::Switch * entity, bool state", + "entity->publish_state(state);", + ), + ( + number.sub_numbers, + number.ApplyNumber, + "number::Number * entity, float value", + "entity->publish_state(value);", + ), + ( + select.sub_selects, + select.ApplySelect, + "select::Select * entity, size_t index", + "entity->publish_state(index);", + ), + ], +) +def test_domain_apply_entities( + sub_fn: Callable[..., entity_helpers.SubEntities], + cls: MockObjClass, + params: str, + publish: str | None, +) -> None: + """Each domain's sub helper declares its Apply class and the function it calls.""" + apply_entity = sub_fn({}).apply_entity + assert apply_entity.cls is cls + assert ", ".join(f"{type_} {arg}" for type_, arg in apply_entity.params) == params + assert apply_entity.publish == publish + + +@pytest.mark.asyncio +async def test_sub_entities_without_setter( + setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]] +) -> None: + """A hub that does not keep the entity passes no setter; nothing else is emitted.""" + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"vent": _apply_conf("vent_sw")}) + assert await sub("vent", None, apply="vent_door()") is not None + assert calls == [("vent_sw", "")] + assert setup_test_environment == [] + + +@pytest.mark.asyncio +async def test_sub_entities_create_from_whole_config( + setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]] +) -> None: + """create() builds the entity from a config that is the entity itself.""" + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {}) + var = await sub.create( + _apply_conf("rf_sw"), + _HUB.set_rf_switch, + apply="set_rf_mode(state)", + publish="before", + ) + assert str(var) == "rf_sw" + assert calls == [("rf_sw", "")] + assert setup_test_environment == ["hub->set_rf_switch(rf_sw)"]

() 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 8db025f812a..a8fab145802 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 df4e1af8d8c..c70a98b1121 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 dfa22ff85ce..47aaa08e3ab 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 f83b2908b61..9bf488099bc 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 40a5c2daec9..56a4d7c6d3b 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 35d5af314cb..482b338cdad 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 8e965a5c739..c845087684d 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 e69de29bb2d..7b2d567ec7f 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 00000000000..5cd9227e6db --- /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 aafab1f2c8e..cbc1efb0a69 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 00000000000..691c9e6b4e2 --- /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/651] 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 612bda6b1aa..a9dbaa3b8d7 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/651] [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 4264479349c..c75693d2d3f 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 1daaa9c131a..2238e3d8530 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/651] [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 be0c7a75c56..fc2f3a71301 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/651] [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 832c9dc1a32..8d5103cbbaf 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/651] [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 5e756d0057a..f5d15f3cfa4 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 52dfe98aad7..3815312c68f 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 cef60c54f86..6f89754f56b 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/651] [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 00000000000..e2392694f07 --- /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 2858b41f719..69e15bdeca6 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 8db183fabe2..df62e30f8a1 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 1fef37e2ee8..8231cd3b845 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 b48123b9022..210314103ec 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 00000000000..4f501ba0ee5 --- /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 28abdb2218c..00e53765c24 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 00000000000..edff4942347 --- /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 00000000000..47ae267caeb --- /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 00000000000..2adc16ddf4a --- /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 00000000000..1d094a62dc4 --- /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 00000000000..0fba00cd3a1 --- /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 681eb41b5b5..952dd5f50f1 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 1df27d82f73..3c4825b067b 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 00000000000..320b7578602 --- /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 00000000000..0204f29312c --- /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/651] [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 aa6d113e168..61f242d8caf 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 001dae98b83..9a9ca2cbb2a 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 9700e36e6f6..73344f66fa6 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 3769e26831b..388e3edb98d 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 00e53765c24..7ff80cbbd15 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 00000000000..56a9f1b66bb --- /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 00000000000..47073b91062 --- /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 00000000000..da74ce50ae6 --- /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/651] [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 b91bc187e6f..f9f98a67070 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 7297d223572..c9b84540780 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 00000000000..8bdeec19ea2 --- /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/651] [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 0e522ced258..e496e9c635e 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 25c7a78bebe..6fca8bfdd9a 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 64d22f9a143..e7d2501e5df 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 e892664724c..f550bb543b3 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 00000000000..42c55115985 --- /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 00000000000..3df54f88476 --- /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 00000000000..d786022c123 --- /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 00000000000..7ba9bdd4bba --- /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 742f07c8dec..0f68ccb34ab 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 e1c3080e291..60b05b0b2bf 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/651] [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 0e2da59ab1a..3a34fad1400 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 333bca37152..a22d96ad406 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 9f6bd7814e4..401fafedd5e 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 a47aa056068..0785fae029a 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/651] [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 01e25714e7b..af043c0ea82 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 00000000000..6d7ddbb7ebd --- /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 00000000000..de3f55777de --- /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 90734b72f28..572505b01a7 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 00000000000..b29bd00943a --- /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 55f489505cb..9eb21f30fc8 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 956cfdc3233..ad5eb72b040 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 261f78b2623..c81a8687d6b 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/651] [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 060799862f7..75bf0b48427 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/651] [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 af043c0ea82..fea3726f765 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 00000000000..21c1f1d4577 --- /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 00000000000..df6ddd48317 --- /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 7ff80cbbd15..27cfa08c437 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 c81a8687d6b..97ce6a37ed4 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/651] [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 b5051c63e17..11290a95f41 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 9a9ca2cbb2a..f973d146e54 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 dd6ecd3b20d..c890fba3d72 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 1b332515f66..906f9fb12ab 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 50be278bb36..bcb03b010f6 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 9fee0c723b5..ac71cf82327 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/651] [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 fea3726f765..cbe6aa8b495 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 00000000000..501766f0aa0 --- /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 00000000000..1d1b2c003d5 --- /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 27cfa08c437..0a876b4f0cc 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 572505b01a7..927ff022529 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 00000000000..44ab365f97b --- /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 9eb21f30fc8..8d9d8506652 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 ad5eb72b040..bc9cb59420f 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 97ce6a37ed4..16a754d8452 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/651] [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 983a4777e81..969a6c8e8c9 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 00000000000..e69de29bb2d 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 00000000000..6391a8babe8 --- /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 00000000000..765b7ec786c --- /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 4f4347d89a9..fc2c833e83c 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/651] [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 ca824dccd4d..a70d3fe522e 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 8cb2a995212..3839b4ff87a 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/651] [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 2146545db52..d6c46b5509e 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 a307ac0de2a..14dc89e56ae 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 83ee4fdbabe..bcec7ce171d 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 5c25e37abb5..e609a217e0e 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 8530b0a0036..5d9fa096119 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/651] [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 cbe6aa8b495..1a767b723d7 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 c2fc35d3ec9..bedc9bca910 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 da8e3a0ff7f..b560875982e 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 927ff022529..31ecbc3eac0 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 00000000000..dfdf0e5069e --- /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 8d9d8506652..fca5b0c3745 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 bc9cb59420f..da9a0fc9630 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 16a754d8452..2ef158456e7 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 00000000000..3f0e8794c03 --- /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/651] [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 1a767b723d7..5b6cb33dec4 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 5b47f7e438f..71a6a5ace1b 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 98a5fe20819..4e88f06cd45 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 31ecbc3eac0..82840b5c2b4 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 00000000000..3641f1ec807 --- /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 da9a0fc9630..f66c2ed3b0b 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/651] [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 5b6cb33dec4..a664a6bdaf8 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 3edb50141c4..c4ba8c5ebc9 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 82840b5c2b4..cddee51195b 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 00000000000..6b6914ce9ca --- /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/651] [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 a664a6bdaf8..b0dc93e3b79 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 598f1a476ef..1bb2a684175 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 ef9dc39fb3b..83988fbaa69 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 cddee51195b..36060d079ce 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 00000000000..455235cd993 --- /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 fca5b0c3745..cd01690344b 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 f66c2ed3b0b..d2b135f0340 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 2ef158456e7..52a43f41b9f 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/651] [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 187fcfd8c09..835799c262b 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 a54bd19d883..f62bee5f602 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/651] [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 c5c1d3739ee..c710dc94a84 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 cfb3521a66d..c16eea733f7 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 c963f72ab68..5160e44a111 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 89786954ba0..0e363cd1564 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 e891f9dc85c..9761ee9edec 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 4bed2f03e55..6140ccb881e 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/651] [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 07dbf558310..4ba9d1696ae 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 6aecc62d6bb..c8d12c326ba 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 7b3414a32a7..0d6338d6cd1 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 9eb7d3e1784..4e8224226f2 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/651] [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 2376a67416f..ca0ffb37c18 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 8e0668d59db..2c119c75504 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 00000000000..004eb46c007 --- /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 00000000000..61e3c34ca46 --- /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 00000000000..4d92c35c77e --- /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 a2779f9803e..24cc22f06f2 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 6cce1918253..779adcc307f 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 01009de071c..bd5a74f356c 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 e891f9dc85c..3c81acdbaba 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 4bed2f03e55..5449d62be4a 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 00000000000..d73aca27d72 --- /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/651] [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 0bf077b7468..bbe76855fb8 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/651] [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 ca0ffb37c18..b4597fe4abf 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 2c119c75504..d00fa91d05a 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 24cc22f06f2..de45f91ec96 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/651] [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 12aaae315e0..769b86519fa 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 991164eb993..a86b5a63b97 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 b9fbdec38d0..cde79af0b68 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 3f01fb22e6d..8a60e9c3881 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 007455f45a3..b108cf298ab 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/651] [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 0a876b4f0cc..7c3c7d2861b 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 00000000000..fdf6997440b --- /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/651] [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 2a9e682440e..ca32f3d3ffa 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/651] [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 7dad4e390e0..f63039a9714 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/651] [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 e0b7ca7c372..181dd63bb21 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/651] [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 52a43f41b9f..7fa8cd1132d 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/651] [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 b0dc93e3b79..93e26165d6b 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 bfd04c5cd9a..4119b87f286 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 c4a2e8529bb..cdeb8dfec76 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 00000000000..a91040ebaba --- /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 00000000000..ba68f5a48bf --- /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/651] [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 46b7e7a2ffd..f8130876456 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 8cdea4f01a6..b44d88d56e3 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 00000000000..1bee400e62a --- /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 a6d86f03051..3f1a4ae350d 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/651] [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 58915ff96db..31e4952d8e0 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 44c4d6733d3..8d5552c6cca 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 00000000000..291753592c4 --- /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 00000000000..7f39b31dfdb --- /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 00000000000..3f21fcd2c63 --- /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 00000000000..4658e8a4fbf --- /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/651] [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 1480faa8c3f..332638e75bb 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 670145cfe01..1ede1f4d8de 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/651] [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 93e26165d6b..45873f2385c 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 901950d3634..ecd27a95a56 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 3263f6b2208..70454dbc497 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 f6064dd5df7..ab762612e17 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 e7fa563fb54..07e37935209 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 770784b0fc5..05940494716 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 19521e8f6d2..572179605a0 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 23c6bd21e8f..099388f8ecf 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 923d412e841..ae6ed0a8e9a 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 38db90c97bc..3c5291ffb24 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 fc2f3a71301..783343c5a20 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 7b2d567ec7f..da300b47b13 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 00000000000..41eefdad7fa --- /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 a653b5137ef..ea1dbcf2944 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 00000000000..5bdd60cae0b --- /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 9be34941b61..8d08e52ffde 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 bffea25864e..b003318a1f9 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/651] [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 61f242d8caf..667e288fd72 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 00000000000..e0e5ca98053 --- /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/651] [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 77c1c9f1ade..0c164893df2 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 dd1998a7c29..d090caeef11 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 3b8330f1591..3b10c9cb3c2 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 a4b091b7c24..739a0e2ada3 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/651] [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 a91c455e0fd..36cd6d6ab7f 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 8f5860e26e1..7310e32afb6 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 a6a40829b11..0936aa3ab50 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 3918ddc1fa8..8e824038341 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 17e27fb7704..276fd5adf17 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 00000000000..e2c6c350bfc --- /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 00000000000..98a90591421 --- /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/651] [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 dbe9b1e45c1..2d48f5a5937 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 09c854c6495..1d847963fec 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 b0a8b4f83fd..7d6983ddc3c 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 e3f2e3940af..14e3b64133d 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/651] [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 16df864082d..0a0c4a59fb1 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 12af8345f13..1c9474bdb7b 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 7cbde6d7438..a12cc658106 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/651] [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 3a34fad1400..e8dc09abba7 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 a22d96ad406..257d9459b5e 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 401fafedd5e..248002d185f 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 00000000000..8401f221636 --- /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/651] [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 45873f2385c..e347ee96ffb 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 00000000000..0c7d6c7ec92 --- /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 00000000000..9946d4230b4 --- /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 7c3c7d2861b..46584389341 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 41eefdad7fa..106ac129e2d 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 5bdd60cae0b..0cdde16d4fc 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 36060d079ce..2fe959bb161 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 00000000000..38543565465 --- /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 7fa8cd1132d..dff4e8f9c5d 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 00000000000..fd9e5afe4a3 --- /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/651] [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 3bdea03bf52..8187dac0082 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 36e9ec406d1..0aa48bc3ea0 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 b47af9d978a..013ba25b5fb 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 4f233e99655..c48c72c06e4 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 5fa3ea054b5..b0da010561e 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 1f9b6cb9348..d3bd1ae0da6 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 c94eea11902..16251537497 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 00000000000..0d027d3c406 --- /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 d7a46373b86..c87433fa56b 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 b30856c2041..2b10af332a3 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 953351f259b..2153e232a05 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 581ba8323b5..f9094b05c50 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 f973d146e54..62732e09153 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 73344f66fa6..c1f4061a534 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 388e3edb98d..bdca350ddeb 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 46584389341..5ae701c5526 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 00000000000..0a4e1bc42de --- /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 00000000000..dc522e3178f --- /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 00000000000..172fb7de711 --- /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 00000000000..077a7da009d --- /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 00000000000..077a7da009d --- /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 00000000000..3015527c18c --- /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/651] [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 bb751dd677c..bd8c099c09f 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/651] [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 b633c5ca4f0..45d9a74b49a 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 f550bb543b3..5882c133f6a 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 7835156b01f..6bffc434eef 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 8a2510cf0a0..6a086767840 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 60b05b0b2bf..24a431b0c05 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/651] [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 c75693d2d3f..7e263a45141 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 3435a6c0bf6..efc7e6ca570 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 73fab843fc4..e341ce04bbc 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 b705ee95fa7..8b5fc7519a1 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 5ae701c5526..628435a4d84 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 00000000000..e69de29bb2d 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 00000000000..1f6840f3006 --- /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 00000000000..80c0bc142ba --- /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 00000000000..37a80362654 --- /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 00000000000..badd604322d --- /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 00000000000..7f0bd4e07ca --- /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 00000000000..4c0280c9ff3 --- /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 6d55db1f08a..8551f78c26a 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 00000000000..7d6fa086efb --- /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 e891f9dc85c..39e62c0664e 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 4bed2f03e55..6e2dfff6dda 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 00000000000..ef628abe54d --- /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/651] [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 76dd240f7ac..d46858d3977 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 1b2236ae8e9..bfdff910da0 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 00000000000..25135f1d390 --- /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/651] [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 36cd6d6ab7f..dd9387785f7 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 31fe286a865..d730e3a4a61 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/651] [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 8e824038341..e6046ebc36c 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 00000000000..27d6df90944 --- /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 00000000000..4edd8f14c2c --- /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/651] [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 7e263a45141..db9dcccd7f6 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 e347ee96ffb..9cf9493cd0e 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 00000000000..b9cd6ec886e --- /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 00000000000..205d09215ad --- /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 00000000000..1407aff81ec --- /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 00000000000..16345e70f19 --- /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 00000000000..e332134eac2 --- /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 00000000000..dcba2ddcb92 --- /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 628435a4d84..3168d269cf4 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 00000000000..3cba9245abb --- /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 00000000000..2d202998f16 --- /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 2fe959bb161..f1045572656 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 00000000000..f23d68640d8 --- /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 cd01690344b..6aa7ae37bb9 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 d2b135f0340..50623190406 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 dff4e8f9c5d..b3583ffe12d 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 00000000000..fc2c53f9673 --- /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 00000000000..b217e0f0d9d --- /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 00000000000..b5b3d45c10d --- /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/651] [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 db9dcccd7f6..192147f2902 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 00000000000..fcd885e5db4 --- /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 00000000000..3ff0e8f15c2 --- /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 00000000000..38065aa7859 --- /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 00000000000..11c1e6b8628 --- /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 00000000000..9f4a4e570fc --- /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 00000000000..b292cc87cc2 --- /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 00000000000..39cbd46a8de --- /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 00000000000..3956f0a42de --- /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 00000000000..aa6069e434b --- /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 00000000000..1b0815d6911 --- /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 00000000000..0ca013ef6ca --- /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 00000000000..0b9b3a2e54c --- /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 00000000000..c0a58a00db7 --- /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 00000000000..e5af1541923 --- /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 00000000000..77fd8e299bb --- /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 00000000000..286ab79298e --- /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 00000000000..88f730a83de --- /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 00000000000..fcb5a6a526c --- /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 00000000000..d287bfe46a1 --- /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 00000000000..62203267027 --- /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/651] [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 192147f2902..2db90be68e3 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 00000000000..88c3a173efe --- /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 00000000000..2efd34a1d8c --- /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 00000000000..aa7679f39a1 --- /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 00000000000..c9d4354e7a3 --- /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 00000000000..e5b19d5ba27 --- /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 00000000000..398bcbae6ea --- /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 00000000000..43144b09f31 --- /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 00000000000..e4efcf68fac --- /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 00000000000..d66d2604239 --- /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 00000000000..7c673924396 --- /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 00000000000..4374ee92d7d --- /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/651] [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 2db90be68e3..27aac4fb68e 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 00000000000..262c951e80f --- /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 00000000000..4f7396fcbdd --- /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 00000000000..b8a81a2e490 --- /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 00000000000..a92fc1f48c0 --- /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 00000000000..8d7e1156be4 --- /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 00000000000..aa31c424384 --- /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 00000000000..9f087cb9411 --- /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 00000000000..fb7ac2fba5c --- /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 00000000000..ca42d95738e --- /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/651] [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 dd9387785f7..630d68b562d 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 7310e32afb6..e8f9208774c 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 81149f056ba..5957870e7f2 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 e6046ebc36c..e143f93a4be 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 276fd5adf17..b4dd4b2ac43 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 00000000000..8c9718ca363 --- /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 00000000000..560d45bf116 --- /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 f3a082fdd2e..680e51df1c8 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 00000000000..1ce3077e5f9 --- /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 825d86c19e1..98b56cbf793 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 00000000000..c5a9072a1d6 --- /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 00000000000..32be96043c9 --- /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 00000000000..19b8eb5fe41 --- /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/651] [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 27aac4fb68e..02e66742e5f 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 00000000000..3ca0ea4f780 --- /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 00000000000..6bd3cc88d50 --- /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 00000000000..3d07d9006b2 --- /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 00000000000..ffea27deddc --- /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 00000000000..4ddcb3bc270 --- /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 00000000000..38b325dc1e3 --- /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 9cf9493cd0e..286d209e980 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 00000000000..72568be649d --- /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 00000000000..b324218f14b --- /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 3168d269cf4..943458e3f64 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 00000000000..b2d1d1c7160 --- /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 00000000000..5126d849349 --- /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 00000000000..e799f7587f2 --- /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 00000000000..e7a5e2c18f2 --- /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 00000000000..6172163ce47 --- /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 00000000000..98fbf9714ae --- /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 6aa7ae37bb9..da88548d2ab 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 50623190406..edd5ee3647b 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 b3583ffe12d..66ee803d4f9 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/651] [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 0038d290408..93bb8269199 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 2defdb0c35c..c2dd40b3e86 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 0354f719ef5..fbabf7460f2 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 42c667b6585..7278d5b3c46 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 00000000000..12a51eed2ce --- /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 00000000000..26409ccdaef --- /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/651] [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 02e66742e5f..b45816640ed 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 59788e76dc7..d9e303c973b 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 00000000000..76622032cdf --- /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 00000000000..41ba352e5a6 --- /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 00000000000..e6194e76813 --- /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 00000000000..e9b6d6462cd --- /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 00000000000..5afbb648be0 --- /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 00000000000..a0c026162a7 --- /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 00000000000..a543aec3001 --- /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 00000000000..32665060b77 --- /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 00000000000..74d93e6577f --- /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 00000000000..2b25b2d23f9 --- /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 00000000000..c571a2be0cd --- /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 00000000000..2b25b2d23f9 --- /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/651] [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 b45816640ed..168c5430806 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 00000000000..92bf8c998ff --- /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 00000000000..78d5d78a9d6 --- /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 00000000000..8ca841a7e43 --- /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 00000000000..5a3513db026 --- /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 00000000000..94e055acf85 --- /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 00000000000..a2286c6e515 --- /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 00000000000..103b71b6628 --- /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/651] [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 168c5430806..2d3f8af71d0 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 00000000000..bd63fd4ef2d --- /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 00000000000..24744c3330d --- /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 00000000000..06d0987d67d --- /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 00000000000..6c5da0396bd --- /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 00000000000..c6f30304bd0 --- /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 00000000000..6c904466250 --- /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 00000000000..bad906653a1 --- /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 00000000000..f7aff8bedd9 --- /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 00000000000..7132464c436 --- /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/651] [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 2d3f8af71d0..9de83996659 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 00000000000..472ac8f7cf8 --- /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 00000000000..b6d2fc87cb4 --- /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 00000000000..c9d64892c48 --- /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 00000000000..81af55699fe --- /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 00000000000..a20fdd9112c --- /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 00000000000..5a609d9283e --- /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 00000000000..e3dd8e09386 --- /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 943458e3f64..a085cf9c0ac 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 00000000000..b4951e804f8 --- /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 00000000000..956cd9c8135 --- /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/651] [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 9de83996659..8f1271b76a8 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 00000000000..99c4ee91879 --- /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 00000000000..22ca7ae56b2 --- /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 00000000000..15c19933c7b --- /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 00000000000..ea4b4d3b81a --- /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 00000000000..15d19bcc0c0 --- /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 00000000000..e69de29bb2d diff --git a/tests/component_tests/lis3dh/test_motion.py b/tests/component_tests/lis3dh/test_motion.py new file mode 100644 index 00000000000..1d86761d4ce --- /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 00000000000..54f492ecfa7 --- /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 00000000000..41a8a051658 --- /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 00000000000..63c7f2a94ac --- /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/651] [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 8f1271b76a8..ec2bddd5489 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 00000000000..038e865f3ab --- /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 00000000000..bf53d96a83f --- /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 00000000000..0c516a6bc8d --- /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 00000000000..90a4f1c3540 --- /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 00000000000..7331691f4db --- /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/651] [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 ec2bddd5489..2b77a16db89 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 00000000000..1c39c7eab32 --- /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 00000000000..83e0a36cbac --- /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 00000000000..6462236ac93 --- /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 00000000000..4af809836ce --- /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 00000000000..33b213d363c --- /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 00000000000..a82d5fd3431 --- /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 00000000000..7346cb4670e --- /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 00000000000..c3969bdf0df --- /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 00000000000..113e0881e6f --- /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 00000000000..5f06985c7b5 --- /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 00000000000..3502ccb85ca --- /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 00000000000..8cd9b4b8dd1 --- /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/651] [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 2b77a16db89..87a89a14f8a 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 00000000000..2a5d7bc1d7a --- /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 00000000000..d2f7961e020 --- /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 00000000000..d88b76a20f0 --- /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 00000000000..a3fcec22432 --- /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 00000000000..99d1e1f7f85 --- /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 00000000000..fe4dd41cef8 --- /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/651] [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 87a89a14f8a..09fdec59a0c 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 00000000000..b8ace97fc9d --- /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 00000000000..356ef2c0669 --- /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 00000000000..a67a4b0658b --- /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 00000000000..4866eb7b9c9 --- /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 00000000000..c1608f1a3cf --- /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 00000000000..b47e39c3898 --- /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 00000000000..4a98b9388ab --- /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 00000000000..319a7c71a65 --- /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/651] [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 09fdec59a0c..ea3cdaed294 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 00000000000..67b38dc21ee --- /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 00000000000..3e1e766c579 --- /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 00000000000..d6d58b93bc4 --- /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 00000000000..7add67e9b09 --- /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 00000000000..383182c1e0f --- /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 a085cf9c0ac..87dc703b375 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 00000000000..e69de29bb2d 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 00000000000..48569f3f9e8 --- /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 00000000000..fbadd42363d --- /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 00000000000..4a6b7150c4e --- /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 00000000000..0621ab995e3 --- /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 00000000000..9a9351cde82 --- /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/651] [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 ea3cdaed294..8c28f879772 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 00000000000..880005d6e77 --- /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 00000000000..5ff8e6f1ef0 --- /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 00000000000..666f301cb04 --- /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 00000000000..2809e7531a0 --- /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 0a4880cf733..22d3afb7754 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 00000000000..7b9ada2e823 --- /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 00000000000..943a8c54a9d --- /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 00000000000..29ed6be26aa --- /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 00000000000..80de52bae2e --- /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 010313db7f9..4a89a1fb916 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 00000000000..b34e87a7589 --- /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 00000000000..c4fefffd3eb --- /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 00000000000..8023264e40e --- /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/651] [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 8c28f879772..45aa0c49ab3 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 00000000000..7cd4038a95a --- /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 00000000000..49bcf7a2462 --- /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 00000000000..ae1bdfe3736 --- /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 00000000000..75b675bfe96 --- /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 00000000000..f2472d2d5db --- /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 00000000000..80b28696a12 --- /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 00000000000..ca78f2b0291 --- /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 00000000000..c10519f9d03 --- /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/651] [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 45aa0c49ab3..23bac1efae1 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 00000000000..207e0cb8b15 --- /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 00000000000..f8010f46186 --- /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 00000000000..1c39e8ec4ad --- /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 00000000000..bbe9cacc2de --- /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 00000000000..4694c745018 --- /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 00000000000..c8557fcc9be --- /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 00000000000..2e5a444bec9 --- /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 d512362fa01..9623eb21b3c 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 1a406bc0891..2b8f933f889 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 87dc703b375..a1b32b39f55 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 00000000000..5ee22ae9d46 --- /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 00000000000..64391dc080b --- /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 00000000000..4c64260e869 --- /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 00000000000..afdbf0e6760 --- /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 00000000000..fd21fecf7f6 --- /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 00000000000..a979e3fda19 --- /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/651] [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 23bac1efae1..050f4d19d92 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 00000000000..ecd6a79dd5c --- /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 00000000000..67f9dbaac3d --- /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 00000000000..906d4a7c3f3 --- /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 00000000000..30cd0ec59f0 --- /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 00000000000..de0247ed4e2 --- /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 00000000000..e9930c953d3 --- /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 00000000000..95f0f76f143 --- /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 00000000000..7f24480cdce --- /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/651] [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 050f4d19d92..d5866286fdb 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 00000000000..e57860786f5 --- /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 00000000000..e660ec63d14 --- /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 00000000000..a6920871475 --- /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 00000000000..e69de29bb2d 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 00000000000..2ebf6087e7b --- /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 00000000000..9f4b117cd3d --- /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 00000000000..8854333e249 --- /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 00000000000..88ac37a74fa --- /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/651] [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 d5866286fdb..d18ad6e6b4f 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 586b69d603b..92a8e439d2a 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 14291fb3284..ba2fbded92e 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 030356bc03e..e9f7283b01d 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 00000000000..18b49a95bd3 --- /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 00000000000..6d2be7f7eba --- /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 00000000000..4c86ee599c2 --- /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 00000000000..8982d61b4a2 --- /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 00000000000..763cc106fcc --- /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/651] [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 d18ad6e6b4f..826c5e70038 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 00000000000..808f27a67a8 --- /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 00000000000..4cb43b5efbd --- /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 00000000000..41bcdddf4ba --- /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 00000000000..9b87cc6503b --- /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 00000000000..a4f6e8e16fc --- /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 00000000000..5ef5be8856c --- /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/651] [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 826c5e70038..c74ae5616bf 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 00000000000..fb22727b36a --- /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 00000000000..ae59d17f96a --- /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 00000000000..65f1cd10d6f --- /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 00000000000..608cffd0287 --- /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 00000000000..534500ca3af --- /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 00000000000..193f830e47e --- /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 00000000000..451bf93b94e --- /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/651] [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 a1709c1fa56..37f718fb7eb 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 56d6a609522..a1fefdffe80 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 00000000000..747a0898fa8 --- /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 ad36e592d14..97127fd2dd5 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 0e1b0604a3d..242b86614d8 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 a1b32b39f55..c645428c7c1 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 00000000000..13091521a83 --- /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 41f30fd5c61..390def04a15 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 00000000000..aafcb7af3ab --- /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 b47e39c3898..63ea3c5a1a1 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/651] [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 c74ae5616bf..5e88b7d7e78 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 00000000000..18eb60df336 --- /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 00000000000..d640993a4cc --- /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 00000000000..f6d5a2f62c9 --- /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 00000000000..463617bbc65 --- /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 00000000000..ec1dafdf15c --- /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 00000000000..b284bc20c2a --- /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 00000000000..bb1cece60fb --- /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 00000000000..128f6d444a7 --- /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 00000000000..bc4c8ddda13 --- /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/651] [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 5e88b7d7e78..b25611b3b66 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 00000000000..4f2b026a5ed --- /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 00000000000..72d5e44235f --- /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 00000000000..c3e4719d395 --- /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 00000000000..4ed70222673 --- /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 00000000000..36acb9fd591 --- /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 00000000000..2e925aea29d --- /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 00000000000..f5da209fe27 --- /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 00000000000..a48a1a5711c --- /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 00000000000..10efaac7f45 --- /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 00000000000..bc1105479d6 --- /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 00000000000..91ec731ca95 --- /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/651] [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 b25611b3b66..2c637b0d200 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 00000000000..b3822f7582e --- /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 00000000000..5ef91dc591f --- /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 00000000000..040b362a8d5 --- /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 00000000000..2f880251e56 --- /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 00000000000..ad7221f0b21 --- /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 00000000000..1628572d406 --- /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 00000000000..8b0c92d3520 --- /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 00000000000..ff10c79edcf --- /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 00000000000..5634c186860 --- /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 00000000000..3e4ea33f7a5 --- /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 00000000000..722f8f201fc --- /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/651] [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 2c637b0d200..99a12f839f3 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 00000000000..0e6330bd88a --- /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 00000000000..6debc0d9c7b --- /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 00000000000..cc7f77d3d02 --- /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 00000000000..2b169271c68 --- /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 00000000000..2cb9547e5cf --- /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 00000000000..8226f36aa74 --- /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 00000000000..913a4adeeaa --- /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 00000000000..be77d931dfc --- /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/651] [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 99a12f839f3..f7875eb1407 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 00000000000..deadd224d54 --- /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 00000000000..cc152ca85d3 --- /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 00000000000..2eafcb348af --- /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 00000000000..172af1d6a25 --- /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 00000000000..c74d97e1d16 --- /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 00000000000..ef3f9fb05d1 --- /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 00000000000..43a2225bcba --- /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 00000000000..37db86d10a1 --- /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 00000000000..4f0186d9eb8 --- /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 00000000000..3dddb492665 --- /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/651] [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 45d9a74b49a..f499e0de1bc 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 9498d4b4da7..577385b2174 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 5882c133f6a..f02819a3edb 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 418cab5ea15..fd7e9f12786 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 6a086767840..fe4bf197157 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 24a431b0c05..c30b03467f1 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/651] [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 766cdc4afb3..481b8d01dad 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 450a97eb190..68086d94660 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 e7323e6e937..64e4f95f16b 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 00000000000..e62f11fd090 --- /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 00000000000..a3da64388f9 --- /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/651] [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 35c2a17c247..bbb7f08fb8e 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 09cde1261d0..c59e27f43ab 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 1513d537c21..7f6cb83f16c 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 00000000000..126f932686d --- /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 fb093238095..76654ddaa9e 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 bdfe149cb7b..d48569f0f4b 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 78064cbe305..f6fb5dbd457 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 e6020c711a1..1c6e8891a96 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/651] [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 0c164893df2..2b094e67a14 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 dc4fc5b47ed..7a0d03df529 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 dbdf3233660..f775afe7f66 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 77db1499231..39920ff4363 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 00000000000..8074ca06f6f --- /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 00000000000..f58187f4556 --- /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 f4c5935059c..6fa6c616b67 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 60f6b28e076..ca50641309b 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 3b10c9cb3c2..801eda4496a 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/651] [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 f7875eb1407..fab41f5b1fa 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 00000000000..355ecc23058 --- /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 00000000000..beca9d85815 --- /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 00000000000..850738ead6a --- /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 00000000000..b64a49903f2 --- /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 00000000000..496f587e42a --- /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 00000000000..a8fdc5956dc --- /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 00000000000..746829bea79 --- /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 00000000000..e1dc80743f5 --- /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/651] [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 109a22c7bca..1384f8b0086 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 d4b4906c30d..46f7d9488c4 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 49457200bc6..7426cc5614c 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 00000000000..6f2556735f9 --- /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 00000000000..c18737ea3c4 --- /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/651] [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 1440e07f763..8f345e3e668 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 732db5b0b4f..ed48ddebdbc 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 01949f45df2..9b5ef493081 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 00000000000..41a87c87795 --- /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 9d18fa36b8f..07981c18976 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/651] [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 959ce77c7a9..2ff0421f392 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 9d18fb9b1a6..54457d82386 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 6db76dcb640..7898d466861 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 eb25af3095f..9eda24f7b94 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 00000000000..d0526cab197 --- /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 00000000000..ac422c33099 --- /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/651] [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 6bdd9467c01..5031c4a457f 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/651] [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 fab41f5b1fa..075b8113d88 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 00000000000..d1d75568e94 --- /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 00000000000..4fa28eec17a --- /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 00000000000..7064ad7d451 --- /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 00000000000..f22d242f9cc --- /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 00000000000..1652ae5c6a3 --- /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 00000000000..41b5001a1f4 --- /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 00000000000..09338269ea4 --- /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 00000000000..deba1114e5d --- /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 00000000000..ca7354799f1 --- /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 00000000000..9c195fc5678 --- /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 00000000000..97a2fe61b20 --- /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/651] [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 472ac8f7cf8..8a0e1654188 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 00000000000..15e5b69d2d7 --- /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 a20fdd9112c..00ce0f20804 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 5a609d9283e..ed046fe0495 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 00000000000..878cc05f874 --- /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 00000000000..4f2ac365399 --- /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 b4951e804f8..84fd33a983b 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/651] [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 0447b968a3b..a83195d42dc 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 a505d58efef..840d1ac11c7 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 02a5c5750f8..33af42b6a7a 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 c322ed21f23..fc277bb5e41 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 74f44c0b3bc..d4d94be2363 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 15142869ac3..93b9dcb75ce 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 5be55a52000..3d6148e64f6 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 1f1ec37eee4..bd4c9a1bce7 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 2316aad5cf6..fd6fd7f6f15 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 4f4093a5945..d166bb361fa 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 456fbb58638..923b0afe18a 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 e53868f102b..6c86fe10bab 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 3cf1d9802ef..8a668ae0fc1 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 2153e232a05..d9a7d65e8ce 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 f9094b05c50..4d4490d9076 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 f775afe7f66..d8a2ff9000d 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 00000000000..28314cb1c27 --- /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 6fa6c616b67..f1e04da8c87 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/651] [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 aca0901d2b8..576887f6dc7 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 1769c110184..781079c1901 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 897d5f60a14..10ea0d0b4cc 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 2b79bd5464f..3a895e25de6 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/651] [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 2576856e2f6..938b191756d 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 02a39d3c848..eb8a41ad9d0 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/651] [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 00000000000..775fda50c10 --- /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 00000000000..900b340e9c2 --- /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 00000000000..625515ec489 --- /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/651] [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 06d0987d67d..6463aab38d0 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/651] [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 41524eebd27..57331a639c7 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 bf13f25053e..9d8351f6f76 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 fd0430d85be..5e883acd7e1 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/651] [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 e4aa0684c78..d3223484ffc 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 3688176d006..1b135c9666f 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/651] [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 0819a1bfb00..9316e90eda1 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 3d4a2ba85bc..a29373a9d4b 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 4c9d6fd7367..09d23e2925e 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/651] [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 ad25130ec73..1b8e02acccc 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/651] [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 f02819a3edb..b5e2ec17e8a 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 198b002d43c..b0d3656101e 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 c30b03467f1..c3276823ecc 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/651] [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 7056ce35aa8..55e25d76b72 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 2a8b3938886..8e0399ac354 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 74ced63a4e8..d4783701c4d 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 67c9b77f4d7..f72f69422d0 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 55d5c0ae866..33bad52acf7 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 e62e2a98d92..9f7190980bf 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/651] [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 07dd325a2c7..388b8165fd7 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 465df528e5f..13fb6b05dab 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/651] [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 74d93e6577f..78d64c634a3 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 00000000000..364ad5329db --- /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 2b25b2d23f9..513617a53b4 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 c571a2be0cd..7bf893aeff6 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 2b25b2d23f9..513617a53b4 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/651] [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 78778553aab..49f55ab47f0 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 da460905c0e..7aedf599249 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 c0a60cacbf3..753401e6aa8 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/651] [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 0750ae250dc..fbce5232ff2 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/651] [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 393a79422bc..4a2c10cb52d 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 14275412294..e9e31411e42 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 f2d46280448..fe0b1b02ed1 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/651] [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 8f345e3e668..79c6785be24 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 ed48ddebdbc..9b027c4e11f 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 9b5ef493081..5ff99881dea 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 bbb7f08fb8e..e7cf1c1143a 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 00000000000..168fee24275 --- /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 00000000000..60a044f483c --- /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/651] [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 e57860786f5..03a100d9297 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 00000000000..fb741950e29 --- /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 e660ec63d14..edea50d1115 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 a6920871475..0a9a45c28b4 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 00000000000..265f34a4459 --- /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 00000000000..e088166ac6c --- /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 00000000000..9abe6fec510 --- /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 00000000000..660313e6413 --- /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 00000000000..33c8f806fa9 --- /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 00000000000..25527ff9a27 --- /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 00000000000..e5eb2d98a2e --- /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 00000000000..b27d2fbcfae --- /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 c645428c7c1..072abe83742 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 2ebf6087e7b..940bf5822fa 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 9f4b117cd3d..295a68d262b 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/651] [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 8bbb9ed7f9b..f7680f105ca 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/651] [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 1c39c7eab32..ecc90455d1c 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 6462236ac93..3f71c77a62b 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 4af809836ce..9a0a2569742 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 00000000000..bc24e300cae --- /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 00000000000..e2598b917a8 --- /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 00000000000..912e1397348 --- /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 00000000000..4a1376b9bf2 --- /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 00000000000..96eccc08f41 --- /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 00000000000..d017d3f1a6c --- /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 072abe83742..1a5ab82d934 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 00000000000..2f0e569bb98 --- /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 33b213d363c..5ae96497103 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 00000000000..bda2f21bb48 --- /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 113e0881e6f..858a689b395 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 5f06985c7b5..57b66f0d9dd 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 8cd9b4b8dd1..44b808cd4c7 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/651] [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 f3647c4d1ca..e66e2456767 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 48226c743a9..759dd6c13da 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 db8dad2f542..e872b73b09f 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 1a5ab82d934..6f2a499730c 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 570d1b0a12e..b836333ad87 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 b47e39c3898..9ebc2589968 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 4a98b9388ab..c0607404540 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 319a7c71a65..257e90c0e62 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/651] [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 077dd03ccc4..9b4cf6680a4 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 00000000000..e353a8d1183 --- /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 fbfed7444d6..c1eda11815a 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 09b29dd7ef4..bb82975dbc3 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 caf75e19be7..d88dde78344 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 6f2a499730c..fc6dd82ed98 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 00000000000..04f634741fd --- /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 00000000000..272bd0dfe66 --- /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 00000000000..cbd4280f034 --- /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 00000000000..60d25577014 --- /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 49f2629baf8..b933db1d9bc 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 f86177c0f92..346efac83ec 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 1e5f1f456ce..dfc2e2e2245 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 cd06d8a83d7..6c294f4cdc6 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/651] [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 075b8113d88..f068a3a2215 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 00000000000..5ccf043c250 --- /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 00000000000..ea229307d5c --- /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 00000000000..d27a4ce96f4 --- /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 00000000000..42a9f633aff --- /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 00000000000..b0842c47a5c --- /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 00000000000..504777ab5ba --- /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/651] [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 a11cc6a008a..9a895b0e0e9 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 470242c9184..98fea2987e9 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 d299560aaba..e7678ccabee 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 04aef00d46b..5e6709ec97d 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 fc6dd82ed98..a7d2084a540 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 00000000000..793e3c30a6c --- /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 00000000000..56c7c38bf93 --- /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 00000000000..7cdabad7d66 --- /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 4df9f5863e1..3c588d0afcd 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 6daa08c22bd..6f4125b47b4 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 a65500ccc38..687622481ea 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 00000000000..e69de29bb2d 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 00000000000..b4b14cd7e1f --- /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/651] [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 f068a3a2215..fc14cb33d09 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 00000000000..09f1d1df4fe --- /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 00000000000..52f1f35708d --- /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 00000000000..b1fcfe06150 --- /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 00000000000..3a0be00f00a --- /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 00000000000..3028881357b --- /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 00000000000..e56be394c59 --- /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 00000000000..610eca9c091 --- /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 00000000000..bee19625978 --- /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 00000000000..d58d8014944 --- /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 From 7881dfc5d88bd028f61fce78b46f668db1dfcd1f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:29:42 -0500 Subject: [PATCH 269/651] [deep_sleep] Reject wakeup_pin configs that fail after validation (#18616) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- esphome/components/deep_sleep/__init__.py | 67 +++++++++++-------- .../deep_sleep/test_deep_sleep.py | 63 +++++++++++++++++ 2 files changed, 101 insertions(+), 29 deletions(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 2b820c9be6c..5f340c60efe 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -143,6 +143,8 @@ def validate_wakeup_pin( processed_pins: list[ConfigType] = [{CONF_PIN: value}] else: processed_pins = list(value) + if not processed_pins: + raise cv.Invalid("'wakeup_pin' needs at least one entry") for i, pin_config in enumerate(processed_pins): # now validate each item @@ -154,29 +156,34 @@ def validate_wakeup_pin( def validate_config(config: ConfigType) -> ConfigType: - # right now only BK72XX supports the list format for wakeup pins - if CORE.is_bk72xx: - if CONF_WAKEUP_PIN_MODE in config: - wakeup_pins = config.get(CONF_WAKEUP_PIN, []) - if len(wakeup_pins) > 1: - raise cv.Invalid( - "You need to remove the global wakeup_pin_mode and define it per pin" - ) - if wakeup_pins: - if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]: - raise cv.Invalid( - "Specify wakeup_pin_mode either at the top level under deep_sleep " - "or under the pin entry, not both" - ) - wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) - elif ( - isinstance(config.get(CONF_WAKEUP_PIN), list) - and len(config[CONF_WAKEUP_PIN]) > 1 - ): + wakeup_pins = config.get(CONF_WAKEUP_PIN) + has_pin_mode = CONF_WAKEUP_PIN_MODE in config + if has_pin_mode and wakeup_pins is None: raise cv.Invalid( - "Your platform does not support providing multiple entries in wakeup_pin" + "'wakeup_pin_mode' requires 'wakeup_pin'", path=[CONF_WAKEUP_PIN_MODE] ) - + multiple_pins = wakeup_pins is not None and len(wakeup_pins) > 1 + # wakeup_pin is limited to ESP32 and BK72XX, and only BK72XX accepts a list + if not CORE.is_bk72xx: + if multiple_pins: + raise cv.Invalid( + "ESP32 does not support multiple entries for 'wakeup_pin'", + path=[CONF_WAKEUP_PIN], + ) + elif has_pin_mode: + if multiple_pins: + raise cv.Invalid( + "Remove the global 'wakeup_pin_mode' and define it per pin", + path=[CONF_WAKEUP_PIN_MODE], + ) + if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): + raise cv.Invalid( + "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " + "or under the pin entry, not both", + path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], + ) + # Move the global mode into the single pin entry, which to_code() reads + pin[CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) return config @@ -203,11 +210,11 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: if CORE.is_bk72xx: max_duration = core.TimePeriod(hours=36) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + raise cv.Invalid("'sleep_duration' cannot be more than 36 hours on BK72XX") elif CORE.using_zephyr: max_duration = core.TimePeriod(days=49) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr") + raise cv.Invalid("'sleep_duration' cannot be more than 49 days on Zephyr") return value @@ -290,7 +297,10 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, _validate_sleep_duration, ), - cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin, + cv.Optional(CONF_WAKEUP_PIN): cv.All( + cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), + validate_wakeup_pin, + ), cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All( cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), cv.enum(WAKEUP_PIN_MODES, upper=True), @@ -347,11 +357,10 @@ async def to_code(config: ConfigType) -> None: if CONF_SLEEP_DURATION in config: cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION])) - if CONF_WAKEUP_PIN in config: - pins_as_list = config.get(CONF_WAKEUP_PIN, []) + if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None: if CORE.is_bk72xx: - cg.add(var.init_wakeup_pins(len(pins_as_list))) - for item in pins_as_list: + cg.add(var.init_wakeup_pins(len(wakeup_pins))) + for item in wakeup_pins: cg.add( var.add_wakeup_pin( await cg.gpio_pin_expression(item[CONF_PIN]), @@ -361,7 +370,7 @@ async def to_code(config: ConfigType) -> None: ) ) else: - pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN]) + pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN]) cg.add(var.set_wakeup_pin(pin)) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index e68b1d17cc1..e05109f246a 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -123,3 +123,66 @@ def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin( assert ( result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP" ) + + +def test_deep_sleep_empty_wakeup_pin_list_rejected() -> None: + """An empty wakeup_pin list is rejected at validation instead of crashing codegen.""" + with pytest.raises(cv.Invalid, match="at least one entry"): + deep_sleep.validate_wakeup_pin([]) + + +def test_deep_sleep_bk72xx_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode with no wakeup_pin is rejected at validation.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +def test_deep_sleep_esp32_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On ESP32 the same mode-only config is rejected too, instead of being silently dead.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +@pytest.mark.parametrize( + "platform", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.NRF52_ZEPHYR] +) +def test_deep_sleep_wakeup_pin_rejected_on_unsupported_platform( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + """wakeup_pin is rejected where the C++ component has no wakeup pin support.""" + set_core_config(platform) + with pytest.raises(cv.Invalid, match="only available on"): + deep_sleep.CONFIG_SCHEMA({CONF_WAKEUP_PIN: "GPIO4"}) + + +def test_deep_sleep_esp32_multiple_wakeup_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """ESP32 accepts only one wakeup_pin entry.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {CONF_WAKEUP_PIN: [{"pin": "GPIO4"}, {"pin": "GPIO5"}]} + with pytest.raises(cv.Invalid, match="ESP32 does not support multiple") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [CONF_WAKEUP_PIN] + + +def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode cannot be applied to several pins.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [{"pin": "GPIO12"}, {"pin": "GPIO13"}], + deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE", + } + with pytest.raises(cv.Invalid, match="define it per pin") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE] From 25843c5242cfabe600f3e7262f869734cc5a3a43 Mon Sep 17 00:00:00 2001 From: guillempages Date: Tue, 13 Oct 2026 10:16:44 +0200 Subject: [PATCH 270/651] [image] Implement RGB111 format (#20114) Co-authored-by: J. Nick Koston --- esphome/components/image/__init__.py | 92 +++++++++++++++---- esphome/components/image/image.cpp | 35 +++++++ esphome/components/image/image.h | 2 + esphome/components/lvgl/__init__.py | 17 ++++ .../runtime_image/runtime_image.cpp | 16 ++++ tests/component_tests/image/test_init.py | 90 ++++++++++++++++++ tests/component_tests/lvgl/test_validation.py | 54 ++++++++++- tests/components/image/common.yaml | 16 ++++ tests/components/online_image/common.yaml | 11 +++ 9 files changed, 313 insertions(+), 20 deletions(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index eaee31a1c7b..668a78f3cbf 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -145,13 +145,42 @@ def is_alpha_only(image: Image): return all(b.getextrema()[1] == 0 for b in image.split()[:-1]) -class ImageBinary(ImageEncoder): +class BitPackedEncoder(ImageEncoder): + """Packs pixels MSB first, with every row padded to a whole byte.""" + + def __init__( + self, + bpp: int, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + self.bpp = bpp + super().__init__( + (width * bpp + 7) // 8, height, transparency, dither, invert_alpha + ) + self.bitno = 0 + + def add_bit(self, bit: bool) -> None: + if bit: + self.data[self.index] |= 0x80 >> self.bitno + self.bitno += 1 + if self.bitno == 8: + self.end_row() + + def end_row(self) -> None: + if self.bitno != 0: + self.bitno = 0 + self.index += 1 + + +class ImageBinary(BitPackedEncoder): allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY} def __init__(self, width, height, transparency, dither, invert_alpha): - self.width8 = (width + 7) // 8 - super().__init__(self.width8, height, transparency, dither, invert_alpha) - self.bitno = 0 + super().__init__(1, width, height, transparency, dither, invert_alpha) def convert(self, image, path): if is_alpha_only(image): @@ -159,22 +188,46 @@ class ImageBinary(ImageEncoder): return image.convert("1", dither=self.dither) def encode(self, pixel): - if self.invert_alpha: - pixel = not pixel - if pixel: - self.data[self.index] |= 0x80 >> (self.bitno % 8) - self.bitno += 1 - if self.bitno == 8: - self.bitno = 0 - self.index += 1 + self.add_bit(bool(pixel) != self.invert_alpha) - def end_row(self): - """ - Pad rows to a byte boundary - """ - if self.bitno != 0: - self.bitno = 0 - self.index += 1 + +# The eight colours one bit per channel can express, for dithering. +_RGB111_PALETTE = Image.new("P", (1, 1)) +_RGB111_PALETTE.putpalette([255 * int(x) for i in range(8) for x in f"{i:03b}"]) + + +class ImageRGB111(BitPackedEncoder): + allow_config = {CONF_OPAQUE, CONF_ALPHA_CHANNEL} + + def __init__( + self, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + bpp = 4 if transparency == CONF_ALPHA_CHANNEL else 3 + super().__init__(bpp, width, height, transparency, dither, invert_alpha) + + def convert(self, image: Image, path: str): + image = image.convert("RGBA") + if self.dither != Image.Dither.FLOYDSTEINBERG: + return image + alpha = image.getchannel("A") + image = image.convert("RGB").quantize( + palette=_RGB111_PALETTE, dither=Image.Dither.FLOYDSTEINBERG + ) + image = image.convert("RGB") + image.putalpha(alpha) + return image + + def encode(self, pixel: tuple[int, int, int, int]): + r, g, b, a = pixel + if self.invert_alpha: + a ^= 0xFF + for channel in (r, g, b, a)[: self.bpp]: + self.add_bit(channel > 127) class ImageGrayscale(ImageEncoder): @@ -315,6 +368,7 @@ class ReplaceWith: IMAGE_TYPE = { "BINARY": ImageBinary, "GRAYSCALE": ImageGrayscale, + "RGB111": ImageRGB111, "RGB565": ImageRGB565, "RGB": ImageRGB, "TRANSPARENT_BINARY": ReplaceWith("'type: BINARY' and 'transparency: chroma_key'"), diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index bfe311be284..d27d110248c 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -3,6 +3,8 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +ESPHOME_LOG_TAG(TAG, "image"); + namespace esphome::image { void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) { @@ -36,6 +38,17 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color } break; } + case IMAGE_TYPE_RGB111: { + for (int img_x = img_x0; img_x < w; img_x++) { + for (int img_y = img_y0; img_y < h; img_y++) { + auto color = this->get_rgb111_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + } + } + break; + } case IMAGE_TYPE_GRAYSCALE: for (int img_x = img_x0; img_x < w; img_x++) { for (int img_y = img_y0; img_y < h; img_y++) { @@ -91,6 +104,8 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off if (this->get_binary_pixel_(x, y)) return color_on; return color_off; + case IMAGE_TYPE_RGB111: + return this->get_rgb111_pixel_(x, y); case IMAGE_TYPE_GRAYSCALE: return this->get_grayscale_pixel_(x, y); case IMAGE_TYPE_RGB565: @@ -120,6 +135,10 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { this->dsc_.header.cf = LV_COLOR_FORMAT_A8; break; + case IMAGE_TYPE_RGB111: + // Rejected by the lvgl config validation + ESP_LOGE(TAG, "RGB111 images are not supported in LVGL."); + break; case IMAGE_TYPE_RGB: switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: @@ -152,6 +171,19 @@ bool Image::get_binary_pixel_(int x, int y) const { const uint32_t pos = x + y * width_8; return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u)); } +Color Image::get_rgb111_pixel_(int x, int y) const { + const uint32_t bitpos = y * this->get_width_stride() * 8 + x * this->bpp_; + const uint8_t *byte = this->data_start_ + bitpos / 8; + const uint8_t shift = bitpos % 8; + // A pixel can straddle two bytes; read the second one only when it does. + uint16_t window = progmem_read_byte(byte) << 8; + if (shift + this->bpp_ > 8) + window |= progmem_read_byte(byte + 1); + const uint8_t bits = (window >> (16 - this->bpp_ - shift)) & ((1u << this->bpp_) - 1); + const uint8_t rgb = this->bpp_ == 4 ? bits >> 1 : bits; + const bool opaque = this->bpp_ == 3 || (bits & 1); + return Color(rgb & 4 ? 0xFF : 0x00, rgb & 2 ? 0xFF : 0x00, rgb & 1 ? 0xFF : 0x00, opaque ? 0xFF : 0x00); +} Color Image::get_rgb_pixel_(int x, int y) const { const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8; Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1), @@ -216,6 +248,9 @@ Image::Image(const uint8_t *data_start, int width, int height, ImageType type, T case IMAGE_TYPE_BINARY: this->bpp_ = 1; break; + case IMAGE_TYPE_RGB111: + this->bpp_ = this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? 4 : 3; + break; case IMAGE_TYPE_GRAYSCALE: this->bpp_ = 8; break; diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index fd9e92c21d3..a94f8137f41 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -13,6 +13,7 @@ enum ImageType { IMAGE_TYPE_GRAYSCALE = 1, IMAGE_TYPE_RGB = 2, IMAGE_TYPE_RGB565 = 3, + IMAGE_TYPE_RGB111 = 4, // one bit per channel plus an optional alpha bit, for colour e-paper }; enum Transparency { @@ -44,6 +45,7 @@ class Image : public display::BaseImage { #endif protected: bool get_binary_pixel_(int x, int y) const; + Color get_rgb111_pixel_(int x, int y) const; Color get_rgb_pixel_(int x, int y) const; Color get_rgb565_pixel_(int x, int y) const; Color get_grayscale_pixel_(int x, int y) const; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9b2f41e0635..6e3087ba9f0 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -25,6 +25,7 @@ from esphome.components.image import ( ImageBinary, ImageGrayscale, ImageRGB, + ImageRGB111, ImageRGB565, get_image_metadata, ) @@ -43,6 +44,7 @@ from esphome.const import ( CONF_ROTATION, CONF_TIMEOUT, CONF_TRIGGER_ID, + CONF_TYPE, ) from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj @@ -293,6 +295,21 @@ def final_validation(config_list): if len(config_list) != 1: multi_conf_validate(config_list) + for img in get_lv_images_used(): + path = global_config.get_path_for_id(img.id)[:-1] + # An image nested in another platform (sendspin's current_image) takes its type from the parent + while path: + image_conf = global_config.get_config_for_path(path) + if isinstance(image_conf, dict) and CONF_TYPE in image_conf: + break + path = path[:-1] + else: + continue + if IMAGE_TYPE.get(image_conf[CONF_TYPE]) is ImageRGB111: + raise cv.FinalExternalInvalid( + f"Image '{img}': LVGL does not support RGB111 images, use RGB565 or RGB instead", + path + [CONF_TYPE], + ) for w in get_focused_widgets(): path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 6c101945746..79d2627a11c 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -99,6 +99,22 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { } break; } + case image::IMAGE_TYPE_RGB111: { + uint8_t bits = (color.r >> 7) << 2 | (color.g >> 7) << 1 | (color.b >> 7); + if (this->bpp_ == 4) + bits = bits << 1 | (color.w >> 7); + const uint32_t stride = (this->buffer_width_ * this->bpp_ + 7u) / 8u; + const uint32_t bitpos = y * stride * 8 + x * this->bpp_; + uint8_t *byte = this->buffer_ + bitpos / 8; + // Place the pixel in a 16-bit window so it may straddle two bytes. + const uint8_t shift = 16 - this->bpp_ - bitpos % 8; + const uint16_t mask = ((1u << this->bpp_) - 1) << shift; + const uint16_t value = bits << shift; + byte[0] = (byte[0] & ~(mask >> 8)) | (value >> 8); + if (mask & 0xFF) + byte[1] = (byte[1] & ~(mask & 0xFF)) | (value & 0xFF); + break; + } case image::IMAGE_TYPE_GRAYSCALE: { uint32_t pos = this->get_position_(x, y); auto gray = static_cast(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b); diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index fad8b7df093..4d62bb52a19 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -102,6 +102,16 @@ from esphome.core import CORE "File can't be opened as image", id="invalid_image_file", ), + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "rgb111", + "transparency": "chroma_key", + }, + "Image format 'RGB111' cannot have transparency: chroma_key", + id="rgb111_with_chroma_key_transparency", + ), ], ) def test_file_platform_configuration_errors( @@ -963,3 +973,83 @@ async def test_rgb565_alpha_animation_layout_per_frame( assert all(a == 0x00 for a in frame1_alpha), ( f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ( + "mode", + "transparency", + "frame0_color", + "frame1_color", + "frame0_row", + "frame1_row", + ), + [ + pytest.param( + "RGB", + CONF_OPAQUE, + (255, 0, 0), + (0, 0, 255), + [0x92, 0x48], + [0x24, 0x92], + id="rgb111", + ), + pytest.param( + "RGBA", + CONF_ALPHA_CHANNEL, + (255, 0, 0, 255), + (0, 0, 255, 0), + [0x99, 0x99, 0x90], + [0x22, 0x22, 0x20], + id="rgb111_alpha", + ), + ], +) +async def test_rgb111_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, + mode: str, + transparency: str, + frame0_color: tuple[int, ...], + frame1_color: tuple[int, ...], + frame0_row: list[int], + frame1_row: list[int], +) -> None: + """RGB111 packs one bit per channel MSB first and pads every row to a byte, + so each frame is a self-contained block of row_stride * height bytes. + """ + # A width that is not a multiple of 8 leaves padding bits in every row. + width = 5 + height = 3 + frame0 = PILImage.new(mode, (width, height), frame0_color) + frame1 = PILImage.new(mode, (width, height), frame1_color) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB111", + CONF_TRANSPARENCY: transparency, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + frame_size = len(frame0_row) * height + assert data == frame0_row * height + frame1_row * height, ( + f"Expected {frame_size} row-padded bytes per frame, got {data}" + ) diff --git a/tests/component_tests/lvgl/test_validation.py b/tests/component_tests/lvgl/test_validation.py index 9a767c0dae5..31cbccb9ff9 100644 --- a/tests/component_tests/lvgl/test_validation.py +++ b/tests/component_tests/lvgl/test_validation.py @@ -2,14 +2,23 @@ from __future__ import annotations +from contextlib import nullcontext + import pytest from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER from esphome.components.display import add_metadata from esphome.components.lvgl import final_validation +from esphome.components.lvgl.defines import get_lv_images_used from esphome.config import Config from esphome.config_validation import Invalid -from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.const import ( + CONF_ID, + CONF_TYPE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE, ID from esphome.final_validate import full_config @@ -175,3 +184,46 @@ class TestDrawRoundingMerge: configs = [_make_lvgl_config(["my_disp"])] final_validation(configs) assert configs[0]["draw_rounding"] == 2 + + +class TestImageTypeValidation: + """Test the image types LVGL accepts.""" + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_image_type(self, image_type: str, expectation) -> None: + image_id = ID("my_image", True) + fc = full_config.get() + fc["image"] = [{CONF_ID: image_id, CONF_TYPE: image_type}] + fc.declare_ids.append((image_id, ["image", 0, CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_nested_image_takes_parent_type(self, image_type: str, expectation) -> None: + """A sendspin artwork image declares its ID below the slot that holds the type.""" + image_id = ID("album_art", True) + fc = full_config.get() + fc["image"] = [{CONF_TYPE: image_type, "current_image": {CONF_ID: image_id}}] + fc.declare_ids.append((image_id, ["image", 0, "current_image", CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 5a8f9383198..6b4978fc548 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -10,6 +10,22 @@ image: type: BINARY transparency: chroma_key + - platform: file + id: rgb111_image + file: ../../pnglogo.png + type: RGB111 + - platform: file + id: rgb111_alpha_image + file: ../../pnglogo.png + type: RGB111 + transparency: alpha_channel + - platform: file + id: rgb111_dither_alpha_image + file: ../../pnglogo.png + type: RGB111 + dither: FloydSteinberg + transparency: alpha_channel + - platform: file id: rgba_image file: ../../pnglogo.png diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index df8f85596f8..2a20fa25373 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -37,6 +37,17 @@ image: format: PNG type: RGB transparency: chroma_key + - platform: online_image + id: online_rgb111_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + format: png + - platform: online_image + id: online_rgb111_transparent_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + transparency: alpha_channel + format: png - platform: online_image id: online_binary_bmp url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp From 77c9fa2fa4557c332ea2aebd9097482a1a9fa2a2 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 10:43:02 +0200 Subject: [PATCH 271/651] [emontx] Read the emonTx firmware version (#20619) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/emontx.cpp | 71 ++++++++++- esphome/components/emontx/emontx.h | 13 ++ .../components/emontx/text_sensor/__init__.py | 23 ++++ esphome/core/defines.h | 1 + tests/components/emontx/common.yaml | 6 + .../components/emontx/text_sensor/__init__.py | 11 ++ .../text_sensor/emontx_version_test.cpp | 116 ++++++++++++++++++ 7 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 esphome/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/emontx_version_test.cpp diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 14dc89e56ae..9c8436c0070 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -1,19 +1,32 @@ #include "emontx.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/components/json/json_util.h" namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +#ifdef USE_EMONTX_VERSION +static constexpr uint32_t VERSION_REQUEST_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so 'v' waits +static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +// Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" +static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +#endif + 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"); + if (this->lock_on_reboot_) { + // The line feed ends any boot noise first + ESP_LOGI(TAG, "Unlocking emonTx after boot"); + this->write_byte('\n'); + this->send_command("emonunlock"); + } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->request_version_(); +#endif } void EmonTx::on_shutdown() { @@ -48,6 +61,11 @@ void EmonTx::loop() { uint8_t dropped; while (this->available() > 0 && this->read_byte(&dropped)) this->discarding_line_ = dropped != '\n'; +#ifdef USE_EMONTX_VERSION + // The emonTx may have been flashed, or a request dropped, during the pause + if (this->version_text_sensor_ != nullptr) + this->request_version_(); +#endif } // Read all available data to prevent UART buffer overflow @@ -77,6 +95,10 @@ void EmonTx::loop() { ESP_LOGV(TAG, "Line is JSON, parsing..."); this->parse_json_(this->buffer_.data(), len); } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->parse_version_line_(this->buffer_.data(), len); +#endif } } else if (received == '\r' || this->discarding_line_) { // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause @@ -102,6 +124,16 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif +#ifdef USE_EMONTX_VERSION + auto msg = root["MSG"]; + if (this->version_text_sensor_ != nullptr && msg.is()) { + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + uint32_t msg_number = msg; + if (msg_number <= this->last_msg_number_) + this->request_version_(); + this->last_msg_number_ = msg_number; + } +#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -112,6 +144,30 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } +#ifdef USE_EMONTX_VERSION +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::request_version_() { + if (this->is_paused()) + return; + this->write_byte('\n'); + this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_version_line_(const char *line, size_t len) { + constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; + if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + return; + const char *version = line + prefix_len; + const char *end = line + len; + while (version < end && *version == ' ') + version++; + if (version == end) + return; + ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); + this->version_text_sensor_->publish_state(version, end - version); +} +#endif + /** * @brief Logs the EmonTx component configuration details. */ @@ -127,6 +183,9 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif +#ifdef USE_EMONTX_VERSION + LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); +#endif } /** diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index bcec7ce171d..8f6a2424c94 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,6 +13,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_EMONTX_VERSION +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::emontx { @@ -27,6 +30,9 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { +#ifdef USE_EMONTX_VERSION + SUB_TEXT_SENSOR(version) +#endif public: EmonTx() = default; @@ -60,6 +66,10 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); +#ifdef USE_EMONTX_VERSION + void request_version_(); + void parse_version_line_(const char *line, size_t len); +#endif #ifdef USE_SENSOR FixedVector> sensors_{}; @@ -71,6 +81,9 @@ class EmonTx final : public Component, public uart::UARTDevice { 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 +#ifdef USE_EMONTX_VERSION + uint32_t last_msg_number_{0}; +#endif std::array buffer_{}; }; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py new file mode 100644 index 00000000000..9b3065f0f86 --- /dev/null +++ b/esphome/components/emontx/text_sensor/__init__.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_EMONTX_ID, EmonTx + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), + cv.Optional(CONF_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_EMONTX_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: + cg.add_define("USE_EMONTX_VERSION") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a7d2084a540..d9b12fdddb1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -68,6 +68,7 @@ #define USE_DEVICES #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 +#define USE_EMONTX_VERSION #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index e609a217e0e..4a5e347053a 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -24,3 +24,9 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py new file mode 100644 index 00000000000..60a67b5a0b6 --- /dev/null +++ b/tests/components/emontx/text_sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; only its define is wanted here. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_EMONTX_VERSION") + + manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp new file mode 100644 index 00000000000..fe905a0e958 --- /dev/null +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -0,0 +1,116 @@ +#include + +#include + +#include "esphome/components/emontx/emontx.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "../common.h" + +namespace esphome::emontx::testing { + +namespace { +class EmonTxVersionTest : public ::testing::Test { + protected: + void SetUp() override { + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_version_text_sensor(&this->version_); + } + + void TearDown() override { + App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); + App.scheduler.call(millis()); + } + + // Lets the delayed 'v' go out + static void run_timers() { + App.scheduler.call(millis()); + delay(300); // NOLINT - test only, the scheduler reads the real clock + App.scheduler.call(millis()); + } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + + MockUARTComponent uart_; + EmonTx sut_; + text_sensor::TextSensor version_; +}; +} // namespace + +// 'v' must not reach the emonTx in the same burst as the line feed, or it joins the pending command +TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { + this->sut_.set_lock_on_reboot(true); + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\nv\n"); +} + +TEST_F(EmonTxVersionTest, VersionLinePublished) { + this->uart_.push_rx("> Firmware:\r\n - Bootloader: UART (.bin)\r\n - Version: v1.1.1\r\n"); + this->sut_.loop(); + + ASSERT_TRUE(this->version_.has_state()); + EXPECT_EQ(this->version_.get_state(), "v1.1.1"); +} + +TEST_F(EmonTxVersionTest, OtherLinesNotPublished) { + this->uart_.push_rx(" - Build: 768be1b (Default)\r\n{\"P1\":100}\r\n - Version:\r\n - Version: \r\n"); + this->sut_.loop(); + + EXPECT_FALSE(this->version_.has_state()); +} + +// MSG restarts at 1 after a reboot, e.g. a firmware update +TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { + this->uart_.push_rx("{\"MSG\":41}\n{\"MSG\":42}\n"); + this->sut_.loop(); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->uart_.push_rx("{\"MSG\":1}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +// The emonTx may have been flashed during the pause, with MSG already past its old value +TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->sut_.set_paused(false); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, RequestDroppedByPauseSentAgainOnResume) { + this->sut_.setup(); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n"); + + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n\nv\n"); +} + +} // namespace esphome::emontx::testing From 192ae31e454a022d858aed017dc646d7737cf5bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 02:19:10 -1000 Subject: [PATCH 272/651] [tests] Turn off the Hypothesis deadline for the MAC address tests (#20614) --- tests/unit_tests/test_core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 94458109753..ff36f1da18e 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -4,7 +4,7 @@ import subprocess import sys from unittest.mock import patch -from hypothesis import given +from hypothesis import given, settings import pytest from esphome import const, core @@ -31,6 +31,7 @@ class TestHexInt: class TestMACAddress: + @settings(deadline=None) @given(value=mac_addr_strings()) def test_init__valid(self, value): core.MACAddress(*value.split(":")) @@ -40,6 +41,7 @@ class TestMACAddress: with pytest.raises(ValueError, match="MAC Address must consist of 6 items"): core.MACAddress(*value.split(":")) + @settings(deadline=None) @given(value=mac_addr_strings()) def test_str(self, value): target = core.MACAddress(*(int(v, 16) for v in value.split(":"))) From fd541bca2cfd7755f036f9f8b824a5408fec5ccd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 02:20:32 -1000 Subject: [PATCH 273/651] [core] Document constexpr constants, per-model schemas and isolated build tests in AGENTS.md (#20617) --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5031c4a457f..e00888b5f77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,9 @@ file does, and it is the authority when they disagree. The most useful starting * **C++ Preprocessor Directives:** * **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums. + * **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope + and inside functions; `static const` is only for a value computed at run time. A function-local + `static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too. * **Use `#define` only for:** - Conditional compilation (`#ifdef`, `#ifndef`) - Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`) @@ -217,6 +220,9 @@ file does, and it is the authority when they disagree. The most useful starting 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`. + * **Model-specific code** sits behind a define the Python side emits only for configs that use that model + (`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check + stays 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 @@ -567,6 +573,9 @@ file does, and it is the authority when they disagree. The most useful starting An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`. * **Configuration Validation:** + * **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...}, + key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator + that rejects keys and fills defaults afterwards. * **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`. * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. @@ -609,6 +618,9 @@ file does, and it is the authority when they disagree. The most useful starting `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. + * **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option + that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to + `ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-` file. * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: ``` tests/ From 29a668af0443ab80e29fa29753a56160bbd57db3 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:01:17 +0000 Subject: [PATCH 274/651] [esp32_camera] Fix compile warning (#20628) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32_camera/esp32_camera.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index ef29fc9b25d..a36fc763a68 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -282,7 +282,7 @@ void ESP32Camera::loop() { } this->frame_count_++; if (now - this->last_log_time_ >= FRAME_LOG_INTERVAL_MS) { - ESP_LOGD(TAG, "Received %u images in last %us", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000); + ESP_LOGD(TAG, "Received %u images in last %" PRIu32 "s", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000); this->last_log_time_ = now; this->frame_count_ = 0; } From c1c1d2b2ae7d199b7579c99e93091f7d9fb8107f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 07:28:32 -1000 Subject: [PATCH 275/651] [core] Add probe_ota_key, a Noise handshake with the device that sends no image (#20601) --- esphome/__main__.py | 34 +- esphome/espota2.py | 346 +++++++++++--- tests/unit_tests/test_espota2.py | 4 +- tests/unit_tests/test_espota2_noise.py | 629 ++++++++++++++++++++++++- 4 files changed, 918 insertions(+), 95 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 2b094e67a14..82ef585e1a9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -483,12 +483,9 @@ def has_ota() -> bool: def has_native_ota() -> bool: """Check if native API OTA upload is available (``platform: esphome``).""" - if CONF_OTA not in CORE.config: - return False - return any( - ota_item.get(CONF_PLATFORM) == CONF_ESPHOME - for ota_item in CORE.config[CONF_OTA] - ) + from esphome.espota2 import esphome_ota_items + + return bool(esphome_ota_items(CORE.config)) def has_web_server_ota() -> bool: @@ -1359,14 +1356,17 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: return CONF_WEB_SERVER +def _esphome_ota_conf(config: ConfigType) -> ConfigType | None: + """The validated esphome OTA platform block, if configured.""" + from esphome.espota2 import esphome_ota_items + + return next(iter(esphome_ota_items(config)), None) + + def _upload_via_native_api( config: ConfigType, network_devices: list[str], args: ArgsProtocol ) -> tuple[int, str | None]: - ota_conf: ConfigType = {} - for ota_item in config.get(CONF_OTA, []): - if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME: - ota_conf = ota_item - break + ota_conf = _esphome_ota_conf(config) or {} from esphome import espota2 from esphome.components.noise import static_encryption_key @@ -1457,10 +1457,8 @@ def _old_key_notice(config: ConfigType) -> None: `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): + for item in espota2.esphome_ota_items(config): + if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY): _LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE) @@ -1468,12 +1466,12 @@ def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: from esphome import web_server_ota + from esphome.espota2 import esphome_ota_items from esphome.web_server_helpers import get_web_server_connection if any( - ota_item.get(CONF_PLATFORM) == CONF_ESPHOME - and ota_item.get(CONF_ENCRYPTION) is not None - for ota_item in config.get(CONF_OTA, []) + ota_item.get(CONF_ENCRYPTION) is not None + for ota_item in esphome_ota_items(config) ): _LOGGER.warning( "This config has OTA encryption, but the web_server OTA path sends " diff --git a/esphome/espota2.py b/esphome/espota2.py index d8a2ff9000d..14e163676d1 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable import contextlib +import enum import gzip import hashlib import io @@ -12,8 +13,10 @@ import socket import time from typing import Any +from esphome.const import CONF_ESPHOME, CONF_OTA, CONF_PLATFORM from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.types import ConfigType OTA_TYPE_UPDATE_APP = 0x00 OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01 @@ -118,6 +121,7 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } +_AUTH_ANSWERS = (*_AUTH_METHODS, RESPONSE_AUTH_OK) # Error response code -> human-readable message (without the "Error: " prefix; check_error() # prepends it uniformly). Looked up by check_error() to translate a single byte from the device @@ -224,10 +228,52 @@ class OTAKeyRejected(OTAError): """The Noise handshake failed on the key; another key may succeed.""" +class OTAEncryptionNotOffered(OTAError): + """A key is configured but the device cannot encrypt.""" + + +class ProbeResult(enum.Enum): + """What probe_ota_key found; only ACCEPTED means the device runs the key.""" + + ACCEPTED = "accepted" + REJECTED = "rejected" # the device runs another key + NOT_OFFERED = "not offered" # the device cannot encrypt + DEVICE_ERROR = "device error" # answered, but not about the key + UNREACHABLE = "unreachable" # no answer at all + + def __bool__(self) -> bool: + return self is ProbeResult.ACCEPTED + + +_PROBE_ANSWERS = ( + (OTAKeyRejected, ProbeResult.REJECTED), + (OTAEncryptionNotOffered, ProbeResult.NOT_OFFERED), +) + + +def esphome_ota_items(config: ConfigType) -> list[ConfigType]: + """The esphome ota entries; a raw ``ota:`` may be a mapping, not a list.""" + ota = config.get(CONF_OTA) or [] + items = [ota] if isinstance(ota, dict) else ota + return [ + item + for item in items + if isinstance(item, dict) and item.get(CONF_PLATFORM) == CONF_ESPHOME + ] + + # 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" +ENCRYPTION_NOT_OFFERED_ADVICE = ( + "Refusing to send the image in plaintext. The running firmware predates " + "ESPHome 2026.9.0 or has no 'api: encryption: key'. With an api key, install " + "once without the 'ota: encryption:' block (that build offers encryption), " + f"then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: true' under " + "'ota: encryption:' for this one install, or flash by serial or the " + "web_server OTA platform." +) 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, " @@ -429,6 +475,22 @@ def send_check( raise OTANetworkError(f"sending {msg}: {err}") from err +def noise_handshake(psk: str, prologue: bytes) -> Any: + """A fresh NNpsk0 initiator; a bad key or a missing noise stack raises here.""" + # Lazy: the noise stack is only imported for an encrypted upload + try: + from aioesphomeapi.noise import NoiseHandshake + except ImportError as err: + raise OTAError( + "OTA encryption requires a newer aioesphomeapi; update your " + "esphome installation (pip install -U esphome) and retry" + ) from err + try: + return NoiseHandshake(psk, prologue) + except ValueError as err: + raise OTAError(f"Invalid OTA encryption key: {err}") from err + + class NoiseSocketWrapper: """Runs the OTA session inside a Noise (ChaCha20-Poly1305) transport. @@ -439,25 +501,12 @@ class NoiseSocketWrapper: """ def __init__(self, sock: socket.socket, psk: str, prologue: bytes) -> None: - # Deliberately lazy: the noise stack (noiseprotocol, cryptography) is - # only imported when an encrypted upload actually runs. - try: - from aioesphomeapi.noise import NoiseHandshake - except ImportError as err: - raise OTAError( - "OTA encryption requires a newer aioesphomeapi; update your " - "esphome installation (pip install -U esphome) and retry" - ) from err - # The aioesphomeapi import above already loaded cryptography; bind - # the exception once so recv() pays no per-frame import lookup + self._handshake = noise_handshake(psk, prologue) + # Bound once; recv() must not import per frame from cryptography.exceptions import InvalidTag self._invalid_tag = InvalidTag self._sock = sock - try: - self._handshake = NoiseHandshake(psk, prologue) - except ValueError as err: - raise OTAError(f"Invalid OTA encryption key: {err}") from err self._encrypt = None self._decrypt = None self._buffer = b"" @@ -564,43 +613,20 @@ class NoiseSocketWrapper: return data -def perform_ota( +def _negotiate_session( sock: socket.socket, - password: str | None, - file_handle: io.IOBase, - filename: Path, - ota_type: int = OTA_TYPE_UPDATE_APP, - noise_psk: str | None = None, + noise_psk: str | None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, -) -> None: - # Validate up front; an out-of-range value would only surface as a - # ValueError deep inside send_check, bypassing OTAError handling - if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: - raise OTAError( - f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" - ) - if ota_type not in _SUPPORTED_OTA_TYPES: - supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) - raise OTAError( - f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" - ) - - if noise_psk is not None and not noise_psk: - raise OTAError( - "An empty OTA encryption key was provided; refusing to upload in plaintext" - ) - - file_contents = file_handle.read() - file_size = len(file_contents) - _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) - +) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool]: + """Negotiate through the Noise handshake; returns the socket to go on with + (wrapped once encrypted), version, features, extended.""" # Enable nodelay, we need it for phase 1 sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.info("Connection established; device supports OTA version %s", version) + _LOGGER.debug("Device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -656,15 +682,9 @@ def perform_ota( # capture the image (wifi credentials, api key) # Remove before 2027.3.0: installing without the block no longer # falls back then; advise 'allow_plaintext_upload: true' instead - raise OTAError( + raise OTAEncryptionNotOffered( "An OTA encryption key is configured but the device did not " - "offer encryption; refusing to send the image in plaintext. " - "The running firmware predates ESPHome 2026.9.0 or has no " - "'api: encryption: key'. With an api key, install once " - "without the 'ota: encryption:' block (that build offers " - f"encryption), then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: " - "true' under 'ota: encryption:' for this one install, or flash by " - "serial or the web_server OTA platform." + "offer encryption" ) if noise_psk: # The prologue binds every negotiation byte both sides saw, so any @@ -682,9 +702,50 @@ def perform_ota( except OTANetworkError as err: # A transport fault: retry encrypted before considering plaintext raise OTAHandshakeNetworkError(str(err)) from err - _LOGGER.info("Encrypted connection established") + _LOGGER.debug("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) + return sock, version, features, extended_proto + + +def perform_ota( + sock: socket.socket, + password: str | None, + file_handle: io.IOBase, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, + noise_psk: str | None = None, + plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, + on_upload_start: Callable[[], None] | None = None, +) -> None: + # Validate up front; an out-of-range value would only surface as a + # ValueError deep inside send_check, bypassing OTAError handling + if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: + raise OTAError( + f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" + ) + if ota_type not in _SUPPORTED_OTA_TYPES: + supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) + raise OTAError( + f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" + ) + + if noise_psk is not None and not noise_psk: + raise OTAError( + "An empty OTA encryption key was provided; refusing to upload in plaintext" + ) + + file_contents = file_handle.read() + file_size = len(file_contents) + _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) + + sock, version, features, extended_proto = _negotiate_session( + sock, noise_psk, plaintext_fallback, allow_plaintext_upload + ) + _LOGGER.info("Connection established; device supports OTA version %s", version) + if isinstance(sock, NoiseSocketWrapper): + _LOGGER.info("Encrypted connection established") if ota_type != OTA_TYPE_UPDATE_APP: # Any non-app OTA type requires the extended protocol and the @@ -761,18 +822,15 @@ def perform_ota( send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) - (auth,) = receive_exactly( - sock, - 1, - "auth", - [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], - ) + (auth,) = receive_exactly(sock, 1, "auth", list(_AUTH_ANSWERS)) if auth != RESPONSE_AUTH_OK: hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) _LOGGER.info("Handshake complete") + if on_upload_start is not None: + on_upload_start() # the device may commit anything sent from here sock.settimeout(DATA_PHASE_TIMEOUT) @@ -890,6 +948,146 @@ def perform_ota( time.sleep(1) +PROBE_TIMEOUT = 120.0 +PROBE_RETRY_DELAY = 3.0 +PROBE_CONNECT_TIMEOUT = 3.0 +PROBE_HANDSHAKE_TIMEOUT = 10.0 + + +def _resolve_targets(remote_host: str | list[str], remote_port: int) -> list[Any]: + """Every address to try for the host, through the cache; OTAError when + there is none.""" + from esphome.core import CORE + + try: + res = resolve_ip_address( + remote_host, remote_port, address_cache=CORE.address_cache + ) + except EsphomeError as err: + raise OTAError(err) from err + if not res: + raise OTAError(f"No addresses to connect to for {remote_host}") + return res + + +class _DeadlineSocket: + """Re-arms the timeout before every operation so the whole negotiation, + not each read, ends by the deadline.""" + + def __init__(self, sock: socket.socket, deadline: float) -> None: + self._sock = sock + self._deadline = deadline + + def _arm(self) -> None: + remaining = self._deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("probe deadline reached") + self._sock.settimeout(min(PROBE_HANDSHAKE_TIMEOUT, remaining)) + + def recv(self, amount: int) -> bytes: + self._arm() + return self._sock.recv(amount) + + def sendall(self, data: bytes) -> None: + self._arm() + self._sock.sendall(data) + + def setsockopt(self, level: int, optname: int, value: int) -> None: + self._sock.setsockopt(level, optname, value) + + def close(self) -> None: + self._sock.close() + + +def probe_ota_key( + remote_host: str | list[str], + remote_port: int, + noise_psk: str, + *, + timeout: float = PROBE_TIMEOUT, + retry_rejected: bool = True, +) -> ProbeResult: + """Whether the device completes a Noise handshake with the key, no image + sent. Retries until the deadline, a rejection too with ``retry_rejected`` + (the old firmware may answer once more after an upload).""" + + noise_handshake(noise_psk, b"") # a local fault must not read as the device's no + deadline = time.monotonic() + timeout + + def pause(delay: float) -> None: + time.sleep(max(0.0, min(delay, deadline - time.monotonic()))) + + res: list[Any] = [] + answered: set[Any] = set() # addresses whose answer no retry changes + last_error = "no time left" + outcome = ProbeResult.UNREACHABLE # a device answer outranks a network error + while time.monotonic() < deadline: + if not res: + # A rebooting device may drop off mDNS for a while; keep asking. + # Addresses the caller pre-resolved (the address cache, or a + # literal IP) are its to keep; only a hostname is looked up again + try: + res = _resolve_targets(remote_host, remote_port) + except OTAError as err: + if outcome is ProbeResult.UNREACHABLE: + last_error = str(err) + _LOGGER.debug("Host not resolved yet (%s); retrying", err) + pause(PROBE_RETRY_DELAY) + continue + # One round: every address that has not answered, back to back + started = time.monotonic() + unreached = 0 + for af, socktype, _, _, sa in res: + if sa in answered: + continue + if (remaining := deadline - time.monotonic()) <= 0: + break + sock = socket.socket(af, socktype) + # A dead host must not eat the budget + sock.settimeout(min(PROBE_CONNECT_TIMEOUT, remaining)) + with contextlib.closing(sock): + try: + sock.connect(sa) + session = _negotiate_session( + _DeadlineSocket(sock, deadline), noise_psk + )[0] + # A password challenge also proves the key: it comes encrypted + receive_exactly(session, 1, "auth", list(_AUTH_ANSWERS)) + except (OSError, OTANetworkError) as err: + if outcome is ProbeResult.UNREACHABLE: + last_error = f"{sa[0]}: {err}" + unreached += 1 + except OTAError as err: + # Final in the precheck; a rebooting device may answer + # differently once it is back + found = next( + (r for t, r in _PROBE_ANSWERS if isinstance(err, t)), + ProbeResult.DEVICE_ERROR, + ) + # A stale address answering with some other error must + # not hide what the device said about the key + if found is not ProbeResult.DEVICE_ERROR or not any( + outcome is r for _, r in _PROBE_ANSWERS + ): + outcome, last_error = found, f"{sa[0]}: {err}" + if not retry_rejected: + answered.add(sa) + else: + _LOGGER.info("Device %s accepted the key", sa[0]) + return ProbeResult.ACCEPTED + _LOGGER.debug("Key not accepted yet (%s); retrying", last_error) + # Every address gets its own final say; a stale one cannot answer + # for the device + pending = sum(1 for *_, sa in res if sa not in answered) + if pending == 0 or time.monotonic() >= deadline: + break + if unreached == pending: + res = [] # no answer this round: the device may have a new address + pause(PROBE_RETRY_DELAY - (time.monotonic() - started)) + _LOGGER.warning("Checking the key failed, %s: %s", outcome.value, last_error) + return outcome + + def run_ota_impl_( remote_host: str | list[str], remote_port: int, @@ -900,16 +1098,13 @@ def run_ota_impl_( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, old_noise_psk: str | None = None, + on_upload_start: Callable[[], None] | None = None, ) -> tuple[int, str | None]: from esphome.core import CORE - # Handle both single host and list of hosts try: - # Resolve all hosts at once for parallel DNS resolution - res = resolve_ip_address( - remote_host, remote_port, address_cache=CORE.address_cache - ) - except EsphomeError as err: + res = _resolve_targets(remote_host, remote_port) + except OTAError: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", remote_host, @@ -920,11 +1115,7 @@ def run_ota_impl_( "(If this error persists, please set a static IP address: " "https://esphome.io/components/wifi/#manual-ips)" ) - raise OTAError(err) from err - - if not res: - _LOGGER.error("No addresses to connect to for %s", remote_host) - return 1, None + raise # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries # are shared across the addresses, cycling through them. Wait before an @@ -941,6 +1132,13 @@ def run_ota_impl_( reconnect = False # the device closed on purpose; nothing to wait for attempt = 0 encryption = _EncryptionAttempt(noise_psk, plaintext_fallback, old_noise_psk) + + def upload_started() -> None: + nonlocal on_upload_start + if on_upload_start is not None: + on_upload_start() + on_upload_start = None # once per upload, whatever the retries do + while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] if not reconnect and (reached_device or attempt >= len(res)): @@ -978,6 +1176,7 @@ def run_ota_impl_( encryption.noise_psk, encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, + on_upload_start=upload_started, ) except OTAKeyRejected as err: # The device sent a reject and closed on its own, so reconnect @@ -1013,6 +1212,8 @@ def run_ota_impl_( # Device-reported error (wrong password, wrong flash size, ...); # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) + if isinstance(err, OTAEncryptionNotOffered): + _LOGGER.error(ENCRYPTION_NOT_OFFERED_ADVICE) return 1, None if encryption.tried_old_key: @@ -1038,7 +1239,11 @@ def run_ota( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, old_noise_psk: str | None = None, + on_upload_start: Callable[[], None] | None = None, ) -> tuple[int, str | None]: + """``on_upload_start`` runs once, when the device first accepted the + session and the image is about to go out: the point from which it may + commit, which a later retry or failure does not undo.""" try: return run_ota_impl_( remote_host, @@ -1050,6 +1255,7 @@ def run_ota( plaintext_fallback, allow_plaintext_upload, old_noise_psk, + on_upload_start, ) except OTAError as err: _LOGGER.error(err) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f1e04da8c87..e83335a175f 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -964,10 +964,10 @@ def test_run_ota_impl_device_error_not_retried( def test_run_ota_impl_no_addresses( firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock ) -> None: - """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + """An empty resolution is reported once and fails cleanly.""" mock_resolve_ip.return_value = [] - result_code, result_host = espota2.run_ota_impl_( + result_code, result_host = espota2.run_ota( "test.local", 3232, "password", str(firmware_file) ) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index ca50641309b..1adace67395 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -13,6 +13,7 @@ import base64 from collections.abc import Callable import hashlib import io +import itertools import logging from pathlib import Path import socket @@ -24,11 +25,16 @@ from unittest.mock import Mock, patch import pytest from esphome import espota2 +from esphome.core import EsphomeError +from esphome.espota2 import ProbeResult PSK = base64.b64encode(bytes(range(32))).decode() OTHER_PSK = base64.b64encode(bytes(range(1, 33))).decode() MAGIC = bytes(espota2.MAGIC_BYTES) +# One resolved loopback address, as resolve_ip_address returns it +RESOLVED = [(2, 1, 0, "", ("127.0.0.1", 1))] +TWO_ADDRESSES = [(2, 1, 0, "", ("10.0.0.1", 1)), (2, 1, 0, "", ("10.0.0.2", 1))] def _recv_exact(sock: socket.socket, amount: int) -> bytes: @@ -82,6 +88,7 @@ class FakeEncryptedDevice(threading.Thread): self.require_noise = require_noise self.prologue_features_override = prologue_features_override self.received: bytes | None = None + self.probes = 0 # clients that left right after the handshake self.error: Exception | None = None self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.listener.bind(("127.0.0.1", 0)) @@ -195,7 +202,12 @@ class FakeEncryptedDevice(threading.Thread): ) -> None: """The post-handshake exchange, identical over both transports.""" send_byte(espota2.RESPONSE_AUTH_OK) - recv_unit(1) # ota type + try: + recv_unit(1) # ota type + except ConnectionError: + # A key probe leaves here, like the firmware it is patterned on + self.probes += 1 + return size = int.from_bytes(recv_unit(4), "big") send_byte(espota2.RESPONSE_UPDATE_PREPARE_OK) md5_hex = recv_unit(32) @@ -250,6 +262,7 @@ def _run_ota( noise_psk: str, plaintext_fallback: bool = True, old_noise_psk: str | None = None, + **kwargs: Any, ) -> int: """Drive the retry loop, which is where the fallback and the old key reconnect.""" path = tmp_path / "firmware.bin" @@ -263,10 +276,110 @@ def _run_ota( noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, old_noise_psk=old_noise_psk, + **kwargs, ) return rc +def test_on_upload_start_runs_once_the_device_accepted_the_session( + tmp_path: Path, +) -> None: + """The rejected first key never reaches the hook; the old_key reconnect does.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK, connections=2) + on_upload_start = Mock() + with patch("time.sleep"): + rc = _run_ota( + device, + b"firmware", + tmp_path, + PSK, + plaintext_fallback=False, + old_noise_psk=OTHER_PSK, + on_upload_start=on_upload_start, + ) + device.join_and_check() + assert rc == 0 + on_upload_start.assert_called_once() + + +def test_on_upload_start_fires_once_across_retries() -> None: + """A data phase fault after the hook retries the upload; the hook has + already reported that the device may commit.""" + calls: list[int] = [] + + def perform(*_args: Any, **kwargs: Any) -> None: + kwargs["on_upload_start"]() + if len(calls) == 0: + calls.append(1) + raise espota2.OTANetworkError("reset") + + hook = Mock() + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch("pathlib.Path.open"), + patch("esphome.espota2.perform_ota", side_effect=perform), + patch("time.sleep"), + ): + rc, _ = espota2.run_ota_impl_( + "h", 1, None, Path("fw.bin"), on_upload_start=hook + ) + assert rc == 0 + hook.assert_called_once() + + +def test_probe_result_is_true_only_when_accepted() -> None: + assert ProbeResult.ACCEPTED + assert not any(r for r in ProbeResult if r is not ProbeResult.ACCEPTED) + + +def test_probe_ota_key_keeps_the_key_answer_over_a_stale_device_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale address running some other listener answers with its own + error; what the device said about the key still wins.""" + with ( + patch("esphome.espota2.resolve_ip_address", return_value=TWO_ADDRESSES), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[ + espota2.OTAKeyRejected("no"), + espota2.OTAError("unsupported OTA version 9"), + ], + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: no" in caplog.text + + +def test_on_upload_start_failure_closes_the_socket() -> None: + def start_upload(*_args: Any, **kwargs: Any) -> None: + kwargs["on_upload_start"]() + + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket") as sock, + patch("pathlib.Path.open"), + patch("esphome.espota2.perform_ota", side_effect=start_upload), + pytest.raises(RuntimeError, match="boom"), + ): + espota2.run_ota_impl_( + "h", + 1, + None, + Path("fw.bin"), + on_upload_start=Mock(side_effect=RuntimeError("boom")), + ) + sock.return_value.close.assert_called_once() + + THIRD_PSK = base64.b64encode(bytes(range(2, 34))).decode() @@ -363,9 +476,7 @@ def test_tampered_negotiation_breaks_handshake() -> None: def test_client_fails_closed_when_device_lacks_encryption() -> None: """With a key configured, a device not offering noise aborts the upload.""" device = FakeEncryptedDevice(offer_noise=False, require_noise=False) - with pytest.raises( - espota2.OTAError, match="refusing to send the image.*allow_plaintext_upload" - ): + with pytest.raises(espota2.OTAEncryptionNotOffered, match="did not offer"): _upload(device, b"firmware", PSK) device.join_and_check() @@ -666,6 +777,120 @@ def test_non_key_reject_reason_is_a_device_error( assert not any("Retrying in plaintext" in r.message for r in caplog.records) +def test_probe_ota_key_accepts_the_running_key( + caplog: pytest.LogCaptureFixture, +) -> None: + """The negotiation's upload-style progress lines stay below INFO.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice() + device.start() + with caplog.at_level(logging.INFO): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + assert device.probes == 1 + assert device.received is None + assert "accepted the key" in caplog.text + assert "Connection established" not in caplog.text + + +@pytest.mark.parametrize( + "device_kwargs", + [{"psk": OTHER_PSK}, {"offer_noise": False, "require_noise": False}], + ids=["wrong_key", "no_offer"], +) +def test_probe_ota_key_gives_up_at_the_deadline( + device_kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(connections=2, **device_kwargs) + device.start() + with patch("time.sleep"), caplog.at_level(logging.WARNING): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=0.5) + is not ProbeResult.ACCEPTED + ) + device.join_and_check() + assert any("Checking the key failed" in r.message for r in caplog.records) + assert device.received is None + + +def test_probe_ota_key_fails_fast_on_a_definitive_answer() -> None: + """A device in steady state that rejects the key gives its final answer + on the first attempt; the precheck does not wait out the deadline.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK) + device.start() + with patch("time.sleep") as sleep: + assert ( + espota2.probe_ota_key( + "127.0.0.1", device.port, PSK, timeout=30, retry_rejected=False + ) + is ProbeResult.REJECTED + ) + device.join_and_check() + sleep.assert_not_called() + + +def test_probe_ota_key_retries_after_a_transport_fault() -> None: + """The device may still be rebooting right after the upload.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(connections=2, drop_handshakes=1) + device.start() + with patch("time.sleep"): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + assert device.probes == 1 + + +@pytest.mark.parametrize( + "kwargs", + [{"side_effect": EsphomeError("no such host")}, {"return_value": []}], + ids=["error", "empty"], +) +def test_probe_ota_key_resolution_failure( + kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A name that never resolves is retried until the deadline, then reported.""" + pytest.importorskip("aioesphomeapi.noise") + with ( + patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve, + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2) + is not ProbeResult.ACCEPTED + ) + assert resolve.call_count > 1 + assert any("failed, unreachable" in r.message for r in caplog.records) + + +def test_probe_ota_key_resolves_again_after_a_reboot() -> None: + """The lookup fails while the device reboots, then the probe goes on.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice() + device.start() + real = espota2.resolve_ip_address + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=[EsphomeError("not yet"), real("127.0.0.1", device.port)], + ), + patch("time.sleep"), + ): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + + def test_bare_block_refuses_a_device_that_cannot_encrypt( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: @@ -677,5 +902,399 @@ def test_bare_block_refuses_a_device_that_cannot_encrypt( device.join_and_check() assert rc == 1 assert device.received != b"firmware" - assert any("refusing to send the image" in r.message for r in caplog.records) + assert any("Refusing to send the image" in r.message for r in caplog.records) assert not any("Retrying in plaintext" in r.message for r in caplog.records) + + +def test_probe_ota_key_recomputes_the_budget_after_connect() -> None: + """A slow connect leaves less for the handshake than the budget had.""" + clock = [0.0] + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep"), + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + sock = sock_cls.return_value + sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 0.4) + sock.recv.return_value = b"" + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + connect_timeout, handshake_timeout = ( + c.args[0] for c in sock.settimeout.call_args_list[:2] + ) + assert connect_timeout == 0.5 + assert handshake_timeout == pytest.approx(0.1) + + +def test_probe_ota_key_tries_every_address_before_a_final_no() -> None: + """With retry_rejected off a rejection is final only once every resolved + address has answered; a stale cached IP must not veto the real device.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAKeyRejected("no"), (session, 2, 0, True)], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep") as sleep, + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.ACCEPTED + ) + assert negotiate.call_count == 2 + sleep.assert_not_called() + + +@pytest.mark.parametrize( + "answer", [espota2.RESPONSE_REQUEST_AUTH, espota2.RESPONSE_REQUEST_SHA256_AUTH] +) +def test_probe_ota_key_takes_a_password_challenge_as_proof(answer: int) -> None: + """A device with a password as well as encryption answers the handshake + with a challenge; it arrives encrypted, so the key was accepted.""" + session = Mock() + session.recv.return_value = bytes([answer]) + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket"), + patch("esphome.espota2._negotiate_session", return_value=(session, 2, 0, True)), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + + +def test_probe_ota_key_waits_for_every_address_to_answer() -> None: + """One address rejecting does not stand in for another that only failed + to connect so far; the one that answered is not asked again.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket") as sock, + patch( + "esphome.espota2._negotiate_session", + side_effect=[ + espota2.OTAKeyRejected("no"), + OSError("refused"), + (session, 2, 0, True), + ], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.ACCEPTED + ) + assert negotiate.call_count == 3 + assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [ + ("10.0.0.1", 1), + ("10.0.0.2", 1), + ("10.0.0.2", 1), + ] + + +def test_probe_ota_key_pauses_between_rounds_once_an_address_answered() -> None: + """A stale address before a device that said no keeps the round pacing: + the device is not asked again and the stale address is not hammered.""" + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ) as resolve, + patch("socket.socket") as sock_cls, + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [OSError("refused"), espota2.OTAKeyRejected("no")], + itertools.repeat(OSError("refused")), + ), + ) as negotiate, + patch("time.sleep", side_effect=sleep) as sleeper, + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + assert ProbeResult.ACCEPTED is not espota2.probe_ota_key( + "h", 1, PSK, timeout=30, retry_rejected=False + ) + # Ten rounds of one stale attempt each, a pause between every two + assert negotiate.call_count == 11 + assert sleeper.call_count == 10 + assert [c.args[0] for c in sock_cls.return_value.connect.call_args_list].count( + ("10.0.0.2", 1) + ) == 1 + # Every later round had no answer, so the host was resolved again each time + assert resolve.call_count == 10 + + +def test_probe_ota_key_keeps_trying_an_address_a_new_lookup_added() -> None: + """An address that answered earlier and is gone from the lookup does not + count against the ones still pending.""" + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=itertools.chain( + [TWO_ADDRESSES], + itertools.repeat([(2, 1, 0, "", ("10.0.0.3", 1))]), + ), + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [espota2.OTAKeyRejected("no")], itertools.repeat(OSError("down")) + ), + ) as negotiate, + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + assert ProbeResult.ACCEPTED is not espota2.probe_ota_key( + "h", 1, PSK, timeout=30, retry_rejected=False + ) + assert negotiate.call_count == 11 # the new address was tried every round + + +def test_probe_ota_key_keeps_the_device_answer_when_a_later_lookup_fails( + caplog: pytest.LogCaptureFixture, +) -> None: + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=itertools.chain( + [TWO_ADDRESSES], itertools.repeat(EsphomeError("dns down")) + ), + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAKeyRejected("no"), OSError("down")], + ), + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: no" in caplog.text + + +def test_probe_ota_key_names_a_missing_encryption_offer( + caplog: pytest.LogCaptureFixture, +) -> None: + """The upload's advice about the image does not apply to a probe.""" + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=espota2.OTAEncryptionNotOffered("did not offer encryption"), + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.NOT_OFFERED + ) + assert "failed, not offered: 127.0.0.1: did not offer encryption" in caplog.text + assert "image" not in caplog.text + + +def test_probe_ota_key_resolves_again_after_a_round_without_an_answer() -> None: + """A round with no answer looks the host up again, and the device is + found under its new address.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=[ + [(2, 1, 0, "", ("10.0.0.1", 1))], + [(2, 1, 0, "", ("10.0.0.2", 1))], + ], + ) as resolve, + patch("socket.socket") as sock, + patch( + "esphome.espota2._negotiate_session", + side_effect=[OSError("unreachable"), (session, 2, 0, True)], + ), + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + assert resolve.call_count == 2 + assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [ + ("10.0.0.1", 1), + ("10.0.0.2", 1), + ] + + +def test_probe_ota_key_reports_the_device_answer_over_a_network_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale address failing last must not hide that the device said no.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [espota2.OTAKeyRejected("wrong key")], + itertools.repeat(OSError("refused")), + ), + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.05, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: wrong key" in caplog.text + + +def test_probe_ota_key_reports_a_local_fault_as_its_own() -> None: + """A malformed key never reads as the device refusing it.""" + pytest.importorskip("aioesphomeapi.noise") + with ( + patch("esphome.espota2.resolve_ip_address") as resolve, + pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"), + ): + espota2.probe_ota_key("h", 1, "not-base64!!!", timeout=30) + resolve.assert_not_called() + + +def test_probe_ota_key_takes_another_device_answer_as_final() -> None: + """An unsupported protocol version does not change with a retry.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=espota2.OTAError("unsupported OTA version 9"), + ) as negotiate, + patch("time.sleep") as sleep, + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.DEVICE_ERROR + ) + negotiate.assert_called_once() + sleep.assert_not_called() + + +def test_probe_ota_key_retries_a_device_error_while_it_reboots() -> None: + """After an upload the device may answer oddly until it is back.""" + session = Mock() + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAError("busy"), (session, 2, 0, True)], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + assert negotiate.call_count == 2 + + +def test_probe_ota_key_deadline_spans_the_whole_negotiation() -> None: + """Each read re-arms the timeout from what is left, so a slow peer + cannot stretch the probe one full timeout per read.""" + clock = [0.0] + sock = Mock() + + def slow_recv(_amount: int) -> bytes: + clock[0] += 0.3 + return b"\x00\x02" + + sock.recv.side_effect = slow_recv + with patch("time.monotonic", side_effect=lambda: clock[0]): + wrapped = espota2._DeadlineSocket(sock, 0.5) + wrapped.sendall(b"x") + assert wrapped.recv(2) == b"\x00\x02" + assert wrapped.recv(2) == b"\x00\x02" + with pytest.raises(TimeoutError): + wrapped.recv(1) + timeouts = [c.args[0] for c in sock.settimeout.call_args_list] + assert timeouts == [0.5, 0.5, pytest.approx(0.2)] + + +def test_probe_ota_key_stops_when_connect_used_the_budget() -> None: + """No handshake timeout is granted past the deadline.""" + clock = [0.0] + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep") as sleep, + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + sock = sock_cls.return_value + sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 1.0) + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + assert [c.args[0] for c in sock.settimeout.call_args_list] == [0.5] + sock.sendall.assert_not_called() + sleep.assert_not_called() + + +def test_probe_ota_key_never_waits_past_its_deadline() -> None: + """Connect and handshake timeouts and the retry sleep are all capped by + what is left of the budget.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep") as sleep, + ): + sock = sock_cls.return_value + sock.connect.side_effect = OSError("refused") + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + timeouts = [c.args[0] for c in sock.settimeout.call_args_list] + assert all(t <= 0.5 for t in timeouts) + assert all(c.args[0] <= 0.5 for c in sleep.call_args_list) From 4a80d785a8d95539ba9ccf6ec8ae41ff8fd56d19 Mon Sep 17 00:00:00 2001 From: Tobias Stanzel Date: Tue, 13 Oct 2026 19:52:46 +0200 Subject: [PATCH 276/651] =?UTF-8?q?[filter=5Flifetime]=20Add=20component?= =?UTF-8?q?=20with=20sensor/lambda=20support=20for=20is=5Fon=20=E2=80=A6?= =?UTF-8?q?=20(#13914)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/filter_lifetime/__init__.py | 2 + .../filter_lifetime/filter_lifetime.cpp | 112 +++++++++ .../filter_lifetime/filter_lifetime.h | 50 ++++ esphome/components/filter_lifetime/sensor.py | 94 ++++++++ tests/components/filter_lifetime/__init__.py | 12 + tests/components/filter_lifetime/common.yaml | 62 +++++ .../filter_lifetime/lambda_only.yaml | 8 + .../test-lambda-only.esp32-idf.yaml | 2 + .../filter_lifetime/test.esp32-idf.yaml | 2 + .../filter_lifetime/test.esp8266-ard.yaml | 2 + .../filter_lifetime/test.rp2040-ard.yaml | 2 + .../filter_lifetime/test_filter_lifetime.cpp | 224 ++++++++++++++++++ .../components/filter_lifetime/__init__.py | 0 .../components/filter_lifetime/test_sensor.py | 99 ++++++++ 15 files changed, 672 insertions(+) create mode 100644 esphome/components/filter_lifetime/__init__.py create mode 100644 esphome/components/filter_lifetime/filter_lifetime.cpp create mode 100644 esphome/components/filter_lifetime/filter_lifetime.h create mode 100644 esphome/components/filter_lifetime/sensor.py create mode 100644 tests/components/filter_lifetime/__init__.py create mode 100644 tests/components/filter_lifetime/common.yaml create mode 100644 tests/components/filter_lifetime/lambda_only.yaml create mode 100644 tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml create mode 100644 tests/components/filter_lifetime/test.esp32-idf.yaml create mode 100644 tests/components/filter_lifetime/test.esp8266-ard.yaml create mode 100644 tests/components/filter_lifetime/test.rp2040-ard.yaml create mode 100644 tests/components/filter_lifetime/test_filter_lifetime.cpp create mode 100644 tests/unit_tests/components/filter_lifetime/__init__.py create mode 100644 tests/unit_tests/components/filter_lifetime/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fc14cb33d09..2c026b92f5b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -212,6 +212,7 @@ esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi esphome/components/file/* @esphome/core +esphome/components/filter_lifetime/* @tuct esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core esphome/components/friedrich/* @rwrozelle diff --git a/esphome/components/filter_lifetime/__init__.py b/esphome/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..99a67538bf2 --- /dev/null +++ b/esphome/components/filter_lifetime/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tuct"] +DOMAIN = "filter_lifetime" diff --git a/esphome/components/filter_lifetime/filter_lifetime.cpp b/esphome/components/filter_lifetime/filter_lifetime.cpp new file mode 100644 index 00000000000..47059a7f7a2 --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.cpp @@ -0,0 +1,112 @@ +#include "filter_lifetime.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::filter_lifetime { + +ESPHOME_LOG_TAG(TAG, "filter_lifetime"); + +static constexpr uint32_t SECONDS_PER_HOUR = 3600; +static constexpr float MINUTES_PER_DAY = 24.0f * 60.0f; + +void FilterLifetime::setup() { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&this->runtime_seconds_); + this->last_update_ = millis(); + this->publish_values_(); +} + +void FilterLifetime::update() { + const uint32_t now = App.get_loop_component_start_time(); + const uint32_t elapsed_ms = now - this->last_update_; + this->last_update_ = now; + + if (this->get_is_on_()) { + float speed = this->get_current_speed_(); + if (std::isnan(speed)) + speed = 0.0f; + speed = std::clamp(speed, 0.0f, 100.0f); + const uint32_t prev_hours = this->runtime_seconds_ / SECONDS_PER_HOUR; + this->carry_ms_ += static_cast(elapsed_ms * (speed / 100.0f)); + this->runtime_seconds_ += this->carry_ms_ / 1000; + this->carry_ms_ %= 1000; + // Save once per hour of runtime (and in on_shutdown): every save erases a flash sector on ESP8266. + if (this->runtime_seconds_ / SECONDS_PER_HOUR != prev_hours) + this->pref_.save(&this->runtime_seconds_); + ESP_LOGV(TAG, "Elapsed %" PRIu32 " ms at %.0f%%, runtime %" PRIu32 " s", elapsed_ms, speed, this->runtime_seconds_); + } + this->publish_values_(); +} + +void FilterLifetime::publish_values_() { + const float runtime_minutes = this->runtime_seconds_ / 60.0f; + const float max_minutes = this->max_lifetime_minutes_; + const float remaining_minutes = std::max(0.0f, max_minutes - runtime_minutes); + this->publish_state(100.0f * remaining_minutes / max_minutes); + if (this->runtime_hours_sensor_ != nullptr) + this->runtime_hours_sensor_->publish_state(runtime_minutes / 60.0f); + if (this->remaining_days_sensor_ != nullptr) + this->remaining_days_sensor_->publish_state(remaining_minutes / MINUTES_PER_DAY); +} + +void FilterLifetime::on_shutdown() { this->pref_.save(&this->runtime_seconds_); } + +void FilterLifetime::reset_filter() { + this->runtime_seconds_ = 0; + this->carry_ms_ = 0; + this->pref_.save(&this->runtime_seconds_); + this->publish_values_(); + ESP_LOGI(TAG, "Filter lifetime reset"); +} + +bool FilterLifetime::get_is_on_() { +#ifdef USE_BINARY_SENSOR + // Reads false until the sensor first publishes, so nothing accumulates before then. + if (this->is_on_sensor_ != nullptr) + return this->is_on_sensor_->state; +#endif + if (this->is_on_lambda_ != nullptr) + return this->is_on_lambda_(); + return true; +} + +float FilterLifetime::get_current_speed_() { + if (this->current_speed_sensor_ != nullptr) + return this->current_speed_sensor_->state; // NaN before the first publish, treated as 0 + if (this->current_speed_lambda_ != nullptr) + return this->current_speed_lambda_(); + return 100.0f; +} + +void FilterLifetime::dump_config() { + LOG_SENSOR("", "Filter Lifetime", this); + ESP_LOGCONFIG(TAG, + " Max lifetime: %" PRIu32 " min (%.1f days)\n" + " Runtime: %.1f h", + this->max_lifetime_minutes_, this->max_lifetime_minutes_ / MINUTES_PER_DAY, + this->runtime_seconds_ / 3600.0f); +#ifdef USE_BINARY_SENSOR + if (this->is_on_sensor_ != nullptr) { + LOG_BINARY_SENSOR(" ", "Is-on sensor", this->is_on_sensor_); + } else +#endif + { + ESP_LOGCONFIG(TAG, " Is-on: %s", + this->is_on_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("always")); + } + if (this->current_speed_sensor_ != nullptr) { + LOG_SENSOR(" ", "Speed sensor", this->current_speed_sensor_); + } else { + ESP_LOGCONFIG(TAG, " Speed: %s", + this->current_speed_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("100%")); + } + LOG_SENSOR(" ", "Runtime hours", this->runtime_hours_sensor_); + LOG_SENSOR(" ", "Remaining days", this->remaining_days_sensor_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::filter_lifetime diff --git a/esphome/components/filter_lifetime/filter_lifetime.h b/esphome/components/filter_lifetime/filter_lifetime.h new file mode 100644 index 00000000000..e6f40d78f5b --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/sensor/sensor.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::filter_lifetime { + +class FilterLifetime : public sensor::Sensor, public PollingComponent { + public: + void setup() override; + void update() override; + void dump_config() override; + void on_shutdown() override; + void reset_filter(); + + void set_max_lifetime(uint32_t minutes) { this->max_lifetime_minutes_ = minutes; } + void set_is_on_lambda(bool (*is_on)()) { this->is_on_lambda_ = is_on; } +#ifdef USE_BINARY_SENSOR + void set_is_on_sensor(binary_sensor::BinarySensor *sensor) { this->is_on_sensor_ = sensor; } +#endif + void set_current_speed_lambda(float (*speed)()) { this->current_speed_lambda_ = speed; } + void set_current_speed_sensor(sensor::Sensor *sensor) { this->current_speed_sensor_ = sensor; } + void set_runtime_hours_sensor(sensor::Sensor *sensor) { this->runtime_hours_sensor_ = sensor; } + void set_remaining_days_sensor(sensor::Sensor *sensor) { this->remaining_days_sensor_ = sensor; } + + protected: + bool get_is_on_(); + float get_current_speed_(); + void publish_values_(); + + bool (*is_on_lambda_)(){nullptr}; + float (*current_speed_lambda_)(){nullptr}; +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *is_on_sensor_{nullptr}; +#endif + sensor::Sensor *current_speed_sensor_{nullptr}; + sensor::Sensor *runtime_hours_sensor_{nullptr}; + sensor::Sensor *remaining_days_sensor_{nullptr}; + ESPPreferenceObject pref_; + uint32_t max_lifetime_minutes_{0}; + uint32_t last_update_{0}; + uint32_t runtime_seconds_{0}; // Speed-weighted running time, persisted + uint32_t carry_ms_{0}; // Weighted milliseconds not yet counted as a second +}; + +} // namespace esphome::filter_lifetime diff --git a/esphome/components/filter_lifetime/sensor.py b/esphome/components/filter_lifetime/sensor.py new file mode 100644 index 00000000000..58af033f329 --- /dev/null +++ b/esphome/components/filter_lifetime/sensor.py @@ -0,0 +1,94 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DURATION, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_HOUR, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CONF_MAX_LIFETIME = "max_lifetime" +CONF_IS_ON = "is_on" +CONF_IS_ON_SENSOR = "is_on_sensor" +CONF_CURRENT_SPEED = "current_speed" +CONF_CURRENT_SPEED_SENSOR = "current_speed_sensor" +CONF_RUNTIME_HOURS = "runtime_hours" +CONF_REMAINING_DAYS = "remaining_days" +UNIT_DAY = "d" + +filter_lifetime_ns = cg.esphome_ns.namespace("filter_lifetime") +FilterLifetime = filter_lifetime_ns.class_( + "FilterLifetime", sensor.Sensor, cg.PollingComponent +) + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema( + FilterLifetime, + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=2, + icon="mdi:air-filter", + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.Required(CONF_MAX_LIFETIME): cv.All( + cv.positive_time_period_minutes, + cv.Range(min=cv.TimePeriod(minutes=1)), + ), + cv.Optional(CONF_IS_ON): cv.returning_lambda, + cv.Optional(CONF_IS_ON_SENSOR): cv.use_id(binary_sensor.BinarySensor), + cv.Optional(CONF_CURRENT_SPEED): cv.returning_lambda, + cv.Optional(CONF_CURRENT_SPEED_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_RUNTIME_HOURS): sensor.sensor_schema( + unit_of_measurement=UNIT_HOUR, + accuracy_decimals=1, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_TOTAL_INCREASING, + icon="mdi:timer-outline", + ), + cv.Optional(CONF_REMAINING_DAYS): sensor.sensor_schema( + unit_of_measurement=UNIT_DAY, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + icon="mdi:calendar-clock", + ), + } + ) + .extend(cv.polling_component_schema("60s")), + cv.has_at_most_one_key(CONF_IS_ON, CONF_IS_ON_SENSOR), + cv.has_at_most_one_key(CONF_CURRENT_SPEED, CONF_CURRENT_SPEED_SENSOR), +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + + cg.add(var.set_max_lifetime(config[CONF_MAX_LIFETIME])) + + if (is_on := config.get(CONF_IS_ON)) is not None: + cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool))) + elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None: + cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor))) + + if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None: + cg.add( + var.set_current_speed_lambda(await cg.templatable(current_speed, [], float)) + ) + elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None: + cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor))) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor) + await sensors(CONF_REMAINING_DAYS, var.set_remaining_days_sensor) + + +automation.register_apply_action( + "filter_lifetime.reset_filter", + cv.Schema({cv.GenerateID(): cv.use_id(FilterLifetime)}), + automation.ApplyCall("reset_filter()"), +) diff --git a/tests/components/filter_lifetime/__init__.py b/tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..70b7f08adf3 --- /dev/null +++ b/tests/components/filter_lifetime/__init__.py @@ -0,0 +1,12 @@ +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: + for platform in ("sensor", "binary_sensor"): + CORE.testing_ensure_platform_registered(platform) + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"] diff --git a/tests/components/filter_lifetime/common.yaml b/tests/components/filter_lifetime/common.yaml new file mode 100644 index 00000000000..1cf8a3e0b18 --- /dev/null +++ b/tests/components/filter_lifetime/common.yaml @@ -0,0 +1,62 @@ +binary_sensor: + - platform: template + name: "Fan Running" + id: fan_running + +sensor: + - platform: template + name: "Fan Speed Percent" + id: fan_speed_percent + unit_of_measurement: "%" + + # Test with lambdas + - platform: filter_lifetime + name: "Filter Lifetime Lambda" + id: filter_lifetime_lambda + max_lifetime: 365d + is_on: !lambda 'return true;' + current_speed: !lambda 'return 50.0;' + update_interval: 60s + runtime_hours: + name: "Filter Runtime Hours Lambda" + remaining_days: + name: "Filter Remaining Days Lambda" + + # Test with sensor references + - platform: filter_lifetime + name: "Filter Lifetime Sensor" + id: filter_lifetime_sensor + max_lifetime: 365d + is_on_sensor: fan_running + current_speed_sensor: fan_speed_percent + update_interval: 60s + runtime_hours: + name: "Filter Runtime Hours Sensor" + remaining_days: + name: "Filter Remaining Days Sensor" + + # Test with defaults (always on at 100%) + - platform: filter_lifetime + name: "Filter Lifetime Default" + id: filter_lifetime_default + max_lifetime: 180d + update_interval: 60s + +button: + - platform: template + name: "Reset Filter Lambda" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_lambda + + - platform: template + name: "Reset Filter Sensor" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_sensor + + - platform: template + name: "Reset Filter Default" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_default diff --git a/tests/components/filter_lifetime/lambda_only.yaml b/tests/components/filter_lifetime/lambda_only.yaml new file mode 100644 index 00000000000..9affd2d469f --- /dev/null +++ b/tests/components/filter_lifetime/lambda_only.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: filter_lifetime + name: "Filter Lifetime Lambda Only" + max_lifetime: 180d + is_on: !lambda 'return true;' + current_speed: !lambda 'return 75.0f;' + remaining_days: + name: "Filter Remaining Days Lambda Only" diff --git a/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml new file mode 100644 index 00000000000..aa0d3c679ff --- /dev/null +++ b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include lambda_only.yaml diff --git a/tests/components/filter_lifetime/test.esp32-idf.yaml b/tests/components/filter_lifetime/test.esp32-idf.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.esp8266-ard.yaml b/tests/components/filter_lifetime/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.rp2040-ard.yaml b/tests/components/filter_lifetime/test.rp2040-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp new file mode 100644 index 00000000000..a4c89edd856 --- /dev/null +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -0,0 +1,224 @@ +#ifdef USE_HOST + +#include + +#include +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/filter_lifetime/filter_lifetime.h" +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::filter_lifetime::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 TestableFilterLifetime : public FilterLifetime { + public: + using FilterLifetime::last_update_; + using FilterLifetime::runtime_seconds_; +}; + +static bool g_is_on = true; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static float g_speed = 100.0f; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static constexpr uint32_t MAX_MINUTES = 600; // 10 hours +static constexpr uint32_t FILTER_HASH = 0x104001; + +class FilterLifetimeTest : public ::testing::Test { + protected: + TestableFilterLifetime filter_; + sensor::Sensor runtime_hours_; + sensor::Sensor remaining_days_; + float last_state_{NAN}; + unsigned publications_{0}; + + void SetUp() override { + new (&App) Application; + App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21); + host::setup_preferences(); + host::host_preferences->reset(); + g_is_on = true; + g_speed = 100.0f; + this->attach(this->filter_); + this->filter_.add_on_state_callback([this](float value) { + this->last_state_ = value; + ++this->publications_; + }); + } + + void TearDown() override { + host::host_preferences->reset(); + App.~Application(); + } + + void attach(FilterLifetime &filter) { + EntityIdentity::configure(filter, "Filter", FILTER_HASH); + filter.set_max_lifetime(MAX_MINUTES); + filter.set_is_on_lambda([]() { return g_is_on; }); + filter.set_current_speed_lambda([]() { return g_speed; }); + filter.set_runtime_hours_sensor(&this->runtime_hours_); + filter.set_remaining_days_sensor(&this->remaining_days_); + } + + // Run one update() as if elapsed_ms passed since the previous one + void elapse(uint32_t elapsed_ms) { + this->filter_.last_update_ = App.get_loop_component_start_time() - elapsed_ms; + this->filter_.update(); + } + + bool stored(uint32_t &value) { return this->filter_.make_entity_preference().load(&value); } + + static float expected_percent(uint32_t runtime_seconds) { + return 100.0f * (MAX_MINUTES - runtime_seconds / 60.0f) / MAX_MINUTES; + } +}; + +TEST_F(FilterLifetimeTest, PublishesFullLifeAtSetup) { + this->filter_.setup(); + EXPECT_EQ(this->publications_, 1u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, MAX_MINUTES / (24.0f * 60.0f)); +} + +TEST_F(FilterLifetimeTest, AccumulatesSpeedWeightedSeconds) { + this->filter_.setup(); + g_speed = 50.0f; + this->elapse(60000); + EXPECT_EQ(this->filter_.runtime_seconds_, 30u); + EXPECT_FLOAT_EQ(this->last_state_, expected_percent(30)); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 30.0f / 3600.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, (MAX_MINUTES - 0.5f) / (24.0f * 60.0f)); +} + +TEST_F(FilterLifetimeTest, CarriesSubSecondRemainderAcrossUpdates) { + this->filter_.setup(); + g_speed = 25.0f; + for (int i = 0; i < 3; i++) + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, NothingAccumulatesWhileOff) { + this->filter_.setup(); + g_is_on = false; + this->elapse(60000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + EXPECT_EQ(this->publications_, 2u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); +} + +TEST_F(FilterLifetimeTest, SpeedIsClampedAndNanCountsAsZero) { + this->filter_.setup(); + g_speed = 250.0f; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + g_speed = NAN; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + g_speed = -5.0f; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, SpeedSensorIsUsedOnceItHasAState) { + sensor::Sensor speed; + this->filter_.set_current_speed_lambda(nullptr); + this->filter_.set_current_speed_sensor(&speed); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + speed.state = 50.0f; + this->elapse(2000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, IsOnSensorGatesAccumulationOnceItIsOn) { + binary_sensor::BinarySensor running; + this->filter_.set_is_on_lambda(nullptr); + this->filter_.set_is_on_sensor(&running); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + running.publish_state(true); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + running.publish_state(false); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, NoInputsMeansAlwaysOnAtFullSpeed) { + this->filter_.set_is_on_lambda(nullptr); + this->filter_.set_current_speed_lambda(nullptr); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, SavesOnlyWhenAnHourOfRuntimeCompletes) { + this->filter_.setup(); + uint32_t value = 0; + this->elapse(3599000); + EXPECT_FALSE(this->stored(value)); + this->elapse(1000); + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 3600u); + this->elapse(1000); + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 3600u); +} + +TEST_F(FilterLifetimeTest, ShutdownSavesThePartialHour) { + this->filter_.setup(); + this->elapse(1000); + this->filter_.on_shutdown(); + uint32_t value = 0; + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 1u); +} + +TEST_F(FilterLifetimeTest, RestoresRuntimeAtSetup) { + this->filter_.setup(); + this->elapse(1234000); + this->filter_.on_shutdown(); + + TestableFilterLifetime restored; + this->attach(restored); + restored.setup(); + EXPECT_EQ(restored.runtime_seconds_, 1234u); + EXPECT_FLOAT_EQ(restored.state, expected_percent(1234)); +} + +TEST_F(FilterLifetimeTest, ResetClearsRuntimeAndSaves) { + this->filter_.setup(); + this->elapse(5000); + this->filter_.reset_filter(); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f); + uint32_t value = 1; + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 0u); +} + +TEST_F(FilterLifetimeTest, RemainingLifeStopsAtZero) { + this->filter_.set_max_lifetime(1); + this->filter_.setup(); + this->elapse(120000); + EXPECT_FLOAT_EQ(this->last_state_, 0.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, 0.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 2.0f / 60.0f); +} + +} // namespace esphome::filter_lifetime::testing + +#endif // USE_HOST diff --git a/tests/unit_tests/components/filter_lifetime/__init__.py b/tests/unit_tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/filter_lifetime/test_sensor.py b/tests/unit_tests/components/filter_lifetime/test_sensor.py new file mode 100644 index 00000000000..7e8d85fcda6 --- /dev/null +++ b/tests/unit_tests/components/filter_lifetime/test_sensor.py @@ -0,0 +1,99 @@ +"""Tests for the filter_lifetime configuration schema.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.filter_lifetime import sensor as filter_lifetime +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_DURATION, + STATE_CLASS_TOTAL_INCREASING, + UNIT_HOUR, + UNIT_PERCENT, +) +from esphome.core import Lambda +from esphome.types import ConfigType + + +def _validate(config: dict[str, Any]) -> ConfigType: + return filter_lifetime.CONFIG_SCHEMA({"name": "Filter", **config}) + + +def test_max_lifetime_is_stored_in_minutes() -> None: + config = _validate({"max_lifetime": "180d"}) + assert config["max_lifetime"].total_minutes == 180 * 24 * 60 + + +def test_max_lifetime_is_required() -> None: + with pytest.raises(cv.Invalid, match="required key not provided"): + _validate({}) + + +@pytest.mark.parametrize("value", ["0min", "30s", 6]) +def test_max_lifetime_rejects_values_under_one_minute(value: Any) -> None: + with pytest.raises(cv.Invalid): + _validate({"max_lifetime": value}) + + +def test_defaults() -> None: + config = _validate({"max_lifetime": "180d"}) + assert config[CONF_UPDATE_INTERVAL].total_seconds == 60 + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_PERCENT + assert config[CONF_ACCURACY_DECIMALS] == 2 + assert "is_on" not in config + assert "current_speed" not in config + + +@pytest.mark.parametrize( + ("lambda_key", "sensor_key"), + [("is_on", "is_on_sensor"), ("current_speed", "current_speed_sensor")], +) +def test_lambda_and_sensor_are_mutually_exclusive( + lambda_key: str, sensor_key: str +) -> None: + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + _validate( + { + "max_lifetime": "180d", + lambda_key: Lambda("return 1;"), + sensor_key: "some_id", + } + ) + + +def test_lambda_inputs_accepted() -> None: + config = _validate( + { + "max_lifetime": "180d", + "is_on": Lambda("return true;"), + "current_speed": Lambda("return 50.0f;"), + } + ) + assert config["is_on"].value == "return true;" + assert config["current_speed"].value == "return 50.0f;" + + +def test_sub_sensor_defaults() -> None: + config = _validate( + { + "max_lifetime": "180d", + "runtime_hours": {"name": "Runtime"}, + "remaining_days": {"name": "Remaining"}, + } + ) + runtime = config["runtime_hours"] + assert runtime[CONF_UNIT_OF_MEASUREMENT] == UNIT_HOUR + assert runtime[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION + assert runtime[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + remaining = config["remaining_days"] + assert remaining[CONF_UNIT_OF_MEASUREMENT] == filter_lifetime.UNIT_DAY + assert remaining[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION + assert remaining[CONF_ACCURACY_DECIMALS] == 0 From 95766ed3ad043f45eabf6e92b7847769427f4b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Oct 2026 20:07:32 +0200 Subject: [PATCH 277/651] [icm20689] Add ICM-20689 6-axis accelerometer/gyroscope (SPI) (#19234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <938089+lboue@users.noreply.github.com> 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 --- CODEOWNERS | 1 + esphome/components/icm20689/__init__.py | 13 ++ esphome/components/icm20689/icm20689.cpp | 126 ++++++++++++++++++ esphome/components/icm20689/icm20689.h | 66 +++++++++ esphome/components/icm20689/motion.py | 56 ++++++++ esphome/components/icm20689/sensor.py | 42 ++++++ tests/components/icm20689/common.yaml | 73 ++++++++++ tests/components/icm20689/test.esp32-idf.yaml | 3 + .../components/icm20689/test.esp8266-ard.yaml | 3 + .../components/icm20689/test.rp2040-ard.yaml | 3 + 10 files changed, 386 insertions(+) create mode 100644 esphome/components/icm20689/__init__.py create mode 100644 esphome/components/icm20689/icm20689.cpp create mode 100644 esphome/components/icm20689/icm20689.h create mode 100644 esphome/components/icm20689/motion.py create mode 100644 esphome/components/icm20689/sensor.py create mode 100644 tests/components/icm20689/common.yaml create mode 100644 tests/components/icm20689/test.esp32-idf.yaml create mode 100644 tests/components/icm20689/test.esp8266-ard.yaml create mode 100644 tests/components/icm20689/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2c026b92f5b..fc53a28c0b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -287,6 +287,7 @@ 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/icm20689/* @lboue esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core diff --git a/esphome/components/icm20689/__init__.py b/esphome/components/icm20689/__init__.py new file mode 100644 index 00000000000..b57221814e8 --- /dev/null +++ b/esphome/components/icm20689/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@lboue"] +DOMAIN = "icm20689" + +CONF_ICM20689_ID = "icm20689_id" +# C++ namespace / class +icm20689_ns = cg.esphome_ns.namespace("icm20689") +ICM20689Component = icm20689_ns.class_( + "ICM20689Component", MotionComponent, spi.SPIDevice +) diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp new file mode 100644 index 00000000000..7331a3fe4f6 --- /dev/null +++ b/esphome/components/icm20689/icm20689.cpp @@ -0,0 +1,126 @@ +#include "icm20689.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::icm20689 { + +ESPHOME_LOG_TAG(TAG, "icm20689"); + +namespace { + +// Range enum values 0..3 map to full-scale settings of 2x/4x/8x/16x the base range. +constexpr float accel_scale(uint8_t range) { return (2.0f * (1U << range)) / 32768.0f; } + +constexpr float gyro_scale(uint8_t range) { return (250.0f * (1U << range)) / 32768.0f; } + +} // namespace + +void ICM20689Component::write_register_(uint8_t reg, uint8_t value) { + this->enable(); + this->write_byte(reg & 0x7F); + this->write_byte(value); + this->disable(); +} + +void ICM20689Component::read_registers_(uint8_t reg, uint8_t *data, size_t len) { + this->enable(); + this->write_byte(reg | ICM20689_READ_BIT); + this->read_array(data, len); + this->disable(); +} + +void ICM20689Component::setup() { + MotionComponent::setup(); + + this->spi_setup(); + + if (!this->spi_is_ready()) { + ESP_LOGE(TAG, "SPI device is not ready"); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Datasheet 4.20: a soft reset must be the first operation after power-up, + // before any register initialization. WHO_AM_I is only valid afterwards. + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_SOFT_RESET); + delay(100); // NOLINT + + // The chip comes out of reset in sleep mode: wake it and select the + // auto-selected best clock source (required for full gyro performance). + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_CLKSEL_AUTO); + + // USER_CTRL is not writable in sleep mode, so I2C_IF_DIS is set once awake. + // Datasheet 6.1: disable the I2C interface when using SPI. + this->write_register_(ICM20689_REG_USER_CTRL, ICM20689_I2C_IF_DIS); + + uint8_t who_am_i = 0; + this->read_registers_(ICM20689_REG_WHO_AM_I, &who_am_i, 1); + + if (who_am_i != ICM20689_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Unexpected WHO_AM_I 0x%02X (expected 0x%02X) -- check wiring/CS", who_am_i, ICM20689_WHO_AM_I_VALUE); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Allow the sensor to settle after waking from sleep. + delay(50); // NOLINT + + // Configure the accelerometer and gyroscope full-scale ranges. + this->write_register_(ICM20689_REG_ACCEL_CONFIG, static_cast(this->accel_range_) << 3); + this->write_register_(ICM20689_REG_GYRO_CONFIG, static_cast(this->gyro_range_) << 3); +} + +void ICM20689Component::dump_config() { + ESP_LOGCONFIG(TAG, "ICM20689 IMU:"); + LOG_PIN(" CS Pin: ", this->cs_); + + if (this->is_failed()) { + ESP_LOGE(TAG, " " ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, " Accel range: ±%dg", 2 << static_cast(this->accel_range_)); + ESP_LOGCONFIG(TAG, " Gyro range: ±%d°/s", 250 << static_cast(this->gyro_range_)); + + MotionComponent::dump_config(); +} + +bool ICM20689Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + const uint8_t accel_range = static_cast(this->accel_range_); + const uint8_t gyro_range = static_cast(this->gyro_range_); + + // Read one contiguous sample: + // 0..5 accelerometer X/Y/Z + // 6..7 temperature + // 8..13 gyroscope X/Y/Z + uint8_t raw_data[14] = {}; + this->read_registers_(ICM20689_REG_ACCEL_XOUT_H, raw_data, sizeof(raw_data)); + + const float accel_factor = accel_scale(accel_range); + + data.acceleration[motion::X_AXIS] = static_cast(encode_uint16(raw_data[0], raw_data[1])) * accel_factor; + data.acceleration[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[2], raw_data[3])) * accel_factor; + data.acceleration[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[4], raw_data[5])) * accel_factor; + + const float gyro_factor = gyro_scale(gyro_range); + + data.angular_rate[motion::X_AXIS] = static_cast(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor; + data.angular_rate[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor; + data.angular_rate[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor; + + if (!this->temperature_callback_.empty()) { + // Datasheet formula: temperature (°C) = raw / 326.8 + 25. + const float temperature = static_cast(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f; + this->temperature_callback_.call(temperature); + } + + return true; +} + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/icm20689.h b/esphome/components/icm20689/icm20689.h new file mode 100644 index 00000000000..08ed9d03eff --- /dev/null +++ b/esphome/components/icm20689/icm20689.h @@ -0,0 +1,66 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::icm20689 { + +// Register map +static constexpr uint8_t ICM20689_REG_WHO_AM_I = 0x75; +static constexpr uint8_t ICM20689_REG_PWR_MGMT_1 = 0x6B; +static constexpr uint8_t ICM20689_REG_USER_CTRL = 0x6A; +static constexpr uint8_t ICM20689_REG_GYRO_CONFIG = 0x1B; +static constexpr uint8_t ICM20689_REG_ACCEL_CONFIG = 0x1C; +static constexpr uint8_t ICM20689_REG_ACCEL_XOUT_H = 0x3B; +static constexpr uint8_t ICM20689_READ_BIT = 0x80; +static constexpr uint8_t ICM20689_WHO_AM_I_VALUE = 0x98; +static constexpr uint8_t ICM20689_SOFT_RESET = 0x81; // PWR_MGMT_1: DEVICE_RESET | CLKSEL=1 (datasheet 4.20) +static constexpr uint8_t ICM20689_CLKSEL_AUTO = 0x01; // PWR_MGMT_1: SLEEP=0, CLKSEL=1 (auto-select best clock) +static constexpr uint8_t ICM20689_I2C_IF_DIS = 0x10; // USER_CTRL bit 4 + +// Accelerometer range options: FS_SEL bits [4:3] of ACCEL_CONFIG +enum ICM20689AccelRange : uint8_t { + ICM20689_ACCEL_RANGE_2G = 0x00, + ICM20689_ACCEL_RANGE_4G = 0x01, + ICM20689_ACCEL_RANGE_8G = 0x02, + ICM20689_ACCEL_RANGE_16G = 0x03, +}; + +// Gyroscope range options: FS_SEL bits [4:3] of GYRO_CONFIG +enum ICM20689GyroRange : uint8_t { + ICM20689_GYRO_RANGE_250 = 0x00, + ICM20689_GYRO_RANGE_500 = 0x01, + ICM20689_GYRO_RANGE_1000 = 0x02, + ICM20689_GYRO_RANGE_2000 = 0x03, +}; + +class ICM20689Component final : public motion::MotionComponent, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + void set_accel_range(ICM20689AccelRange range) { this->accel_range_ = range; } + void set_gyro_range(ICM20689GyroRange range) { this->gyro_range_ = range; } + + template void add_temperature_listener(F &&callback) { + this->temperature_callback_.add(std::forward(callback)); + } + + protected: + bool update_data(motion::MotionData &data) override; + + void write_register_(uint8_t reg, uint8_t value); + // Reads len bytes starting at reg in one CS-low transaction. + void read_registers_(uint8_t reg, uint8_t *data, size_t len); + + ICM20689AccelRange accel_range_{ICM20689_ACCEL_RANGE_2G}; + ICM20689GyroRange gyro_range_{ICM20689_GYRO_RANGE_250}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/motion.py b/esphome/components/icm20689/motion.py new file mode 100644 index 00000000000..7673dcf4cf4 --- /dev/null +++ b/esphome/components/icm20689/motion.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.const import CONF_ACCELEROMETER_RANGE, CONF_GYROSCOPE_RANGE +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ICM20689Component, icm20689_ns + +DEPENDENCIES = ["spi"] + +# Enum proxies (must match the C++ enum values exactly) +ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G, + "4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G, + "8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G, + "16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G, +} + +ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange") +GYRO_RANGE_OPTIONS = { + "250DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_250, + "500DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_500, + "1000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_1000, + "2000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_2000, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(ICM20689Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="250DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + } + ) + .extend( + spi.spi_device_schema( + cs_pin_required=True, default_mode="MODE0", default_data_rate="1MHz" + ) + ) +) + + +# Code generation +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await spi.register_spi_device(var, config) + + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) diff --git a/esphome/components/icm20689/sensor.py b/esphome/components/icm20689/sensor.py new file mode 100644 index 00000000000..7cd5dedba07 --- /dev/null +++ b/esphome/components/icm20689/sensor.py @@ -0,0 +1,42 @@ +# YAML config keys +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_ICM20689_ID, ICM20689Component + +AUTO_LOAD = ["icm20689"] + +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( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_ICM20689_ID): cv.use_id(ICM20689Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_ICM20689_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/components/icm20689/common.yaml b/tests/components/icm20689/common.yaml new file mode 100644 index 00000000000..522a0a2bbf7 --- /dev/null +++ b/tests/components/icm20689/common.yaml @@ -0,0 +1,73 @@ +sensor: + - platform: icm20689 + type: temperature + name: "ICM20689 Temperature" + + - platform: motion + motion_id: icm20689_motion + type: acceleration_x + name: "ICM20689 Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + motion_id: icm20689_motion + type: acceleration_y + name: "ICM20689 Accel Y" + accuracy_decimals: 4 + - platform: motion + motion_id: icm20689_motion + type: acceleration_z + name: "ICM20689 Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + motion_id: icm20689_motion + type: gyroscope_x + name: "ICM20689 Gyro X" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_y + name: "ICM20689 Gyro Y" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_z + name: "ICM20689 Gyro Z" + + - platform: motion + motion_id: icm20689_motion + type: angular_rate_x + name: "ICM20689 Angular Rate X" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_y + name: "ICM20689 Angular Rate Y" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_z + name: "ICM20689 Angular Rate Z" + + - platform: motion + motion_id: icm20689_motion + type: pitch + name: "ICM20689 Pitch" + - platform: motion + motion_id: icm20689_motion + type: roll + name: "ICM20689 Roll" + +motion: + - platform: icm20689 + id: icm20689_motion + cs_pin: GPIO5 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + # Gyroscope full-scale range: 250DPS | 500DPS | 1000DPS | 2000DPS + gyroscope_range: 500DPS + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/icm20689/test.esp32-idf.yaml b/tests/components/icm20689/test.esp32-idf.yaml new file mode 100644 index 00000000000..0c9ea68ef00 --- /dev/null +++ b/tests/components/icm20689/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.esp8266-ard.yaml b/tests/components/icm20689/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ab217e25fb8 --- /dev/null +++ b/tests/components/icm20689/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.rp2040-ard.yaml b/tests/components/icm20689/test.rp2040-ard.yaml new file mode 100644 index 00000000000..fc5a73cf62d --- /dev/null +++ b/tests/components/icm20689/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + icm20689: !include common.yaml From 40271fb59768552597151e14b5f2a340a810e3a8 Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 14 Oct 2026 05:10:36 +1100 Subject: [PATCH 278/651] [haier_tundra] New Climate IR component (#20469) 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 | 3 + esphome/components/haier_tundra/__init__.py | 2 + .../haier_tundra/button/__init__.py | 43 +++ .../button/haier_tundra_button.cpp | 18 ++ .../haier_tundra/button/haier_tundra_button.h | 25 ++ esphome/components/haier_tundra/climate.py | 51 ++++ .../components/haier_tundra/haier_tundra.cpp | 260 ++++++++++++++++++ .../components/haier_tundra/haier_tundra.h | 139 ++++++++++ .../haier_tundra/switch/__init__.py | 60 ++++ .../switch/haier_tundra_switch.cpp | 43 +++ .../haier_tundra/switch/haier_tundra_switch.h | 28 ++ tests/components/haier_tundra/common.yaml | 45 +++ .../haier_tundra/test.esp32-idf.yaml | 3 + .../haier_tundra/test.esp8266-ard.yaml | 3 + 14 files changed, 723 insertions(+) create mode 100644 esphome/components/haier_tundra/__init__.py create mode 100644 esphome/components/haier_tundra/button/__init__.py create mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.cpp create mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.h create mode 100644 esphome/components/haier_tundra/climate.py create mode 100644 esphome/components/haier_tundra/haier_tundra.cpp create mode 100644 esphome/components/haier_tundra/haier_tundra.h create mode 100644 esphome/components/haier_tundra/switch/__init__.py create mode 100644 esphome/components/haier_tundra/switch/haier_tundra_switch.cpp create mode 100644 esphome/components/haier_tundra/switch/haier_tundra_switch.h create mode 100644 tests/components/haier_tundra/common.yaml create mode 100644 tests/components/haier_tundra/test.esp32-idf.yaml create mode 100644 tests/components/haier_tundra/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index fc53a28c0b3..a770810490b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -243,6 +243,9 @@ esphome/components/haier/button/* @paveldn esphome/components/haier/sensor/* @paveldn esphome/components/haier/switch/* @paveldn esphome/components/haier/text_sensor/* @paveldn +esphome/components/haier_tundra/* @fauxpark +esphome/components/haier_tundra/button/* @fauxpark +esphome/components/haier_tundra/switch/* @fauxpark esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann diff --git a/esphome/components/haier_tundra/__init__.py b/esphome/components/haier_tundra/__init__.py new file mode 100644 index 00000000000..d74ae6947f2 --- /dev/null +++ b/esphome/components/haier_tundra/__init__.py @@ -0,0 +1,2 @@ +DOMAIN = "haier_tundra" +CODEOWNERS = ["@fauxpark"] diff --git a/esphome/components/haier_tundra/button/__init__.py b/esphome/components/haier_tundra/button/__init__.py new file mode 100644 index 00000000000..2e919b80099 --- /dev/null +++ b/esphome/components/haier_tundra/button/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import CONF_LIGHT +from esphome.types import ConfigType + +from ..climate import HaierTundra, haier_tundra_ns + +CODEOWNERS = ["@fauxpark"] + +HaierTundraButton = haier_tundra_ns.class_( + "HaierTundraButton", button.Button, cg.Component +) +ButtonKind = haier_tundra_ns.enum("ButtonKind", is_class=True) + +ICON_LED_OUTLINE = "mdi:led-outline" +ICON_SPRAY_BOTTLE = "mdi:spray-bottle" + +CONF_HAIER_TUNDRA_ID = "haier_tundra_id" +CONF_SELF_CLEAN = "self_clean" + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra), + cv.Optional(CONF_LIGHT): button.button_schema( + HaierTundraButton, icon=ICON_LED_OUTLINE + ), + cv.Optional(CONF_SELF_CLEAN): button.button_schema( + HaierTundraButton, icon=ICON_SPRAY_BOTTLE + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID]) + for key, kind in ( + (CONF_LIGHT, ButtonKind.BUTTON_KIND_LIGHT), + (CONF_SELF_CLEAN, ButtonKind.BUTTON_KIND_SELF_CLEAN), + ): + if (conf := config.get(key)) is not None: + var = await button.new_button(conf, hub, kind) + await cg.register_component(var, conf) diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.cpp b/esphome/components/haier_tundra/button/haier_tundra_button.cpp new file mode 100644 index 00000000000..39246530bc3 --- /dev/null +++ b/esphome/components/haier_tundra/button/haier_tundra_button.cpp @@ -0,0 +1,18 @@ +#include "haier_tundra_button.h" +#include "esphome/core/log.h" + +namespace esphome::haier_tundra { + +ESPHOME_LOG_TAG(TAG, "haier_tundra.button"); + +void HaierTundraButton::dump_config() { LOG_BUTTON("", "Haier Tundra Button", this); } + +void HaierTundraButton::press_action() { + if (this->kind_ == ButtonKind::BUTTON_KIND_LIGHT) { + this->parent_->toggle_light(); + } else { + this->parent_->start_self_clean(); + } +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.h b/esphome/components/haier_tundra/button/haier_tundra_button.h new file mode 100644 index 00000000000..c4888a4616a --- /dev/null +++ b/esphome/components/haier_tundra/button/haier_tundra_button.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/button/button.h" +#include "esphome/components/haier_tundra/haier_tundra.h" + +namespace esphome::haier_tundra { + +enum class ButtonKind : uint8_t { + BUTTON_KIND_LIGHT, + BUTTON_KIND_SELF_CLEAN, +}; + +class HaierTundraButton final : public button::Button, public Component, public Parented { + public: + HaierTundraButton(HaierTundra *parent, ButtonKind kind) : Parented(parent), kind_(kind) {} + void dump_config() override; + + protected: + void press_action() override; + + ButtonKind kind_; +}; + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/climate.py b/esphome/components/haier_tundra/climate.py new file mode 100644 index 00000000000..c992d14bfed --- /dev/null +++ b/esphome/components/haier_tundra/climate.py @@ -0,0 +1,51 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_PLATFORM +import esphome.final_validate as fv +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +haier_tundra_ns = cg.esphome_ns.namespace("haier_tundra") +HaierTundra = haier_tundra_ns.class_("HaierTundra", climate_ir.ClimateIR) + +CONF_UNIT = "unit" +Unit = haier_tundra_ns.enum("Unit") +UNITS = { + "a": Unit.HAIER_HEADER_UNIT_A, + "b": Unit.HAIER_HEADER_UNIT_B, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend( + { + cv.Optional(CONF_UNIT, default="a"): cv.enum(UNITS, lower=True), + } +) + + +def _final_validate_unit(config: ConfigType) -> None: + # Each transmitter may drive at most one unit A and one unit B + climates = fv.full_config.get()["climate"] + seen = set() + + for climate in climates: + if CONF_PLATFORM in climate and climate[CONF_PLATFORM] == "haier_tundra": + unit_tx_pair = ( + climate[CONF_UNIT], + climate[remote_base.CONF_TRANSMITTER_ID], + ) + + if unit_tx_pair in seen and climate[CONF_ID] == config[CONF_ID]: + raise cv.Invalid( + "Only one of each unit is allowed per transmitter", path=[CONF_UNIT] + ) + seen.add(unit_tx_pair) + + +FINAL_VALIDATE_SCHEMA = _final_validate_unit + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("haier") + await climate_ir.new_climate_ir(config, config[CONF_UNIT]) diff --git a/esphome/components/haier_tundra/haier_tundra.cpp b/esphome/components/haier_tundra/haier_tundra.cpp new file mode 100644 index 00000000000..7a0b88d3aac --- /dev/null +++ b/esphome/components/haier_tundra/haier_tundra.cpp @@ -0,0 +1,260 @@ +#include "haier_tundra.h" + +namespace esphome::haier_tundra { + +// Number of bytes in decoded packet sans checksum +static constexpr uint8_t HAIER_TUNDRA_FRAME_SIZE = 13; + +void HaierTundra::transmit_state() { + // Button value does not seem to matter too much except + // when toggling light, as it has no bit flag of its own + // So in the default case we can simply set it to POWER + this->transmit_(HAIER_BUTTON_POWER); +} + +void HaierTundra::transmit_(uint8_t button) { + // Force Quiet and Turbo off if we're not in Cool or Heat mode + if (this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT) { +#ifdef USE_SWITCH + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(false); + } + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(false); + } +#endif + + this->quiet_mode_ = false; + this->turbo_mode_ = false; + } + + remote_base::HaierData remote_state{{ + this->unit_, + static_cast(this->temperature_() << 4 | this->vertical_swing_()), + static_cast(this->horizontal_swing_() << 5), + static_cast(this->health_() << 1), + static_cast(this->power_() << 6), + static_cast(this->fan_speed_() << 5), + static_cast(this->quiet_() << 7 | this->turbo_() << 6), + static_cast(this->operation_mode_() << 5), + 0x00, + 0x00, + static_cast((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4), + 0x00, + button, + }}; + + auto transmit = this->transmitter_->transmit(); + remote_base::HaierProtocol().encode(transmit.get_data(), remote_state); + transmit.perform(); +} + +uint8_t HaierTundra::temperature_() { + return (uint8_t) roundf(clamp(this->target_temperature, HAIER_TEMP_MIN, HAIER_TEMP_MAX)) - HAIER_TEMP_MIN; +} + +uint8_t HaierTundra::vertical_swing_() { + if (this->mode == climate::CLIMATE_MODE_OFF) { + return HAIER_SWING_V_OFF; + } + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + case climate::CLIMATE_SWING_BOTH: + return HAIER_SWING_V_AUTO; + default: + if (this->mode == climate::CLIMATE_MODE_HEAT) { + return HAIER_SWING_V_1BOT; + } + return HAIER_SWING_V_1TOP; // Cool/Dry/Auto + } +} + +uint8_t HaierTundra::horizontal_swing_() { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_HORIZONTAL: + case climate::CLIMATE_SWING_BOTH: + return HAIER_SWING_H_AUTO; + default: + return HAIER_SWING_H_MIDDLE; + } +} + +uint8_t HaierTundra::health_() { return this->health_mode_ ? 1 : 0; } + +uint8_t HaierTundra::power_() { return this->mode == climate::CLIMATE_MODE_OFF ? 0 : 1; } + +uint8_t HaierTundra::fan_speed_() { + switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { + case climate::CLIMATE_FAN_LOW: + return HAIER_FAN_LOW; + case climate::CLIMATE_FAN_MEDIUM: + return HAIER_FAN_MED; + case climate::CLIMATE_FAN_HIGH: + return HAIER_FAN_HIGH; + case climate::CLIMATE_FAN_AUTO: + default: + return HAIER_FAN_AUTO; + } +} + +uint8_t HaierTundra::quiet_() { return this->quiet_mode_ ? 1 : 0; } + +uint8_t HaierTundra::turbo_() { return this->turbo_mode_ ? 1 : 0; } + +uint8_t HaierTundra::operation_mode_() { + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + return HAIER_MODE_COOL; + case climate::CLIMATE_MODE_DRY: + return HAIER_MODE_DRY; + case climate::CLIMATE_MODE_HEAT: + return HAIER_MODE_HEAT; + case climate::CLIMATE_MODE_FAN_ONLY: + return HAIER_MODE_FAN; + case climate::CLIMATE_MODE_HEAT_COOL: + default: + return HAIER_MODE_AUTO; + } +} + +bool HaierTundra::on_receive(remote_base::RemoteReceiveData data) { + auto haier = remote_base::HaierProtocol().decode(data); + if (haier.has_value()) { + return this->on_haier_(*haier); + } + return false; +} + +bool HaierTundra::on_haier_(const remote_base::HaierData &haier) { + // Verify frame length and header + if (haier.data.size() != HAIER_TUNDRA_FRAME_SIZE || haier.data[0] != this->unit_) { + return false; + } + + this->target_temperature = this->get_temperature_(haier.data[1] >> 4); + this->swing_mode = this->get_swing_mode_(haier.data[1] & 0xF, haier.data[2] >> 5); + this->health_mode_ = haier.data[3] & 0x2; + this->mode = this->get_operation_mode_(haier.data[4] & 0x40, haier.data[7] >> 5); + this->fan_mode = this->get_fan_speed_(haier.data[5] >> 5); + this->quiet_mode_ = haier.data[6] & 0x80; + this->turbo_mode_ = haier.data[6] & 0x40; + +#ifdef USE_SWITCH + if (this->health_mode_switch_ != nullptr) { + this->health_mode_switch_->publish_state(this->health_mode_); + } + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(this->quiet_mode_); + } + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(this->turbo_mode_); + } +#endif + + this->publish_state(); + + return true; +} + +float HaierTundra::get_temperature_(uint8_t temp) { return temp + HAIER_TEMP_MIN; } + +climate::ClimateSwingMode HaierTundra::get_swing_mode_(uint8_t swing_v, uint8_t swing_h) { + if (swing_v == HAIER_SWING_V_AUTO) { + if (swing_h == HAIER_SWING_H_AUTO) { + return climate::CLIMATE_SWING_BOTH; + } + + return climate::CLIMATE_SWING_VERTICAL; + } else if (swing_h == HAIER_SWING_H_AUTO) { + return climate::CLIMATE_SWING_HORIZONTAL; + } + + return climate::CLIMATE_SWING_OFF; +} + +climate::ClimateMode HaierTundra::get_operation_mode_(uint8_t power, uint8_t mode) { + if (!power) { + return climate::CLIMATE_MODE_OFF; + } + + switch (mode) { + case HAIER_MODE_COOL: + return climate::CLIMATE_MODE_COOL; + case HAIER_MODE_DRY: + return climate::CLIMATE_MODE_DRY; + case HAIER_MODE_HEAT: + return climate::CLIMATE_MODE_HEAT; + case HAIER_MODE_FAN: + return climate::CLIMATE_MODE_FAN_ONLY; + case HAIER_MODE_AUTO: + default: + return climate::CLIMATE_MODE_HEAT_COOL; + } +} + +climate::ClimateFanMode HaierTundra::get_fan_speed_(uint8_t fan) { + switch (fan) { + case HAIER_FAN_LOW: + return climate::CLIMATE_FAN_LOW; + case HAIER_FAN_MED: + return climate::CLIMATE_FAN_MEDIUM; + case HAIER_FAN_HIGH: + return climate::CLIMATE_FAN_HIGH; + case HAIER_FAN_AUTO: + default: + return climate::CLIMATE_FAN_AUTO; + } +} + +void HaierTundra::set_health_mode(bool health, bool transmit) { + this->health_mode_ = health; + + if (transmit && this->mode != climate::CLIMATE_MODE_OFF) { + this->transmit_(HAIER_BUTTON_HEALTH); + } +} + +void HaierTundra::set_quiet_mode(bool quiet, bool transmit) { + // Quiet & Turbo cannot be enabled at the same time + if (quiet) { +#ifdef USE_SWITCH + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(false); + } +#endif + this->turbo_mode_ = false; + } + + this->quiet_mode_ = quiet; + if (transmit) + this->transmit_(HAIER_BUTTON_TURBO_QUIET); +} + +void HaierTundra::set_turbo_mode(bool turbo, bool transmit) { + // Quiet & Turbo cannot be enabled at the same time + if (turbo) { +#ifdef USE_SWITCH + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(false); + } +#endif + this->quiet_mode_ = false; + } + + this->turbo_mode_ = turbo; + if (transmit) + this->transmit_(HAIER_BUTTON_TURBO_QUIET); +} + +void HaierTundra::toggle_light() { + if (this->mode != climate::CLIMATE_MODE_OFF) { + this->transmit_(HAIER_BUTTON_LIGHT); + } +} + +void HaierTundra::start_self_clean() { + // Can be started when off + this->transmit_(HAIER_BUTTON_SELF_CLEAN); +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/haier_tundra.h b/esphome/components/haier_tundra/haier_tundra.h new file mode 100644 index 00000000000..b2bb1e51ce9 --- /dev/null +++ b/esphome/components/haier_tundra/haier_tundra.h @@ -0,0 +1,139 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/components/remote_base/haier_protocol.h" + +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +namespace esphome::haier_tundra { + +// Byte 0 - Header value (Unit ID) +enum Unit : uint8_t { + HAIER_HEADER_UNIT_B = 0x59, + HAIER_HEADER_UNIT_A = 0xA6, +}; + +// Byte 1[7:4] - Temperature (˚C) 0x0..0xE +static constexpr uint8_t HAIER_TEMP_MIN = 16; +static constexpr uint8_t HAIER_TEMP_MAX = 30; + +// Byte 1[3:0] - Vertical Swing +static constexpr uint8_t HAIER_SWING_V_OFF = 0x0; // Power off value; from Auto, stops at current position +static constexpr uint8_t HAIER_SWING_V_2TOP = 0x1; // [|/ ] +static constexpr uint8_t HAIER_SWING_V_1TOP = 0x2; // [| ] (Not available in Heat mode) +static constexpr uint8_t HAIER_SWING_V_2BOT = 0x3; // [ //] +static constexpr uint8_t HAIER_SWING_V_1BOT = 0xA; // [ /] (Heat mode only) +static constexpr uint8_t HAIER_SWING_V_AUTO = 0xC; + +// Byte 2[7:5] - Horizontal Swing +static constexpr uint8_t HAIER_SWING_H_MIDDLE = 0x0; // [ || ] +static constexpr uint8_t HAIER_SWING_H_LEFT = 0x3; // [/ ] +static constexpr uint8_t HAIER_SWING_H_MIDDLE_LEFT = 0x4; // [ | ] +static constexpr uint8_t HAIER_SWING_H_MIDDLE_RIGHT = 0x5; // [ | ] +static constexpr uint8_t HAIER_SWING_H_RIGHT = 0x6; // [ \] +static constexpr uint8_t HAIER_SWING_H_AUTO = 0x7; // [/||\] + +// Byte 3[7:5] - Timer Mode +static constexpr uint8_t HAIER_TIMER_MODE_DISABLED = 0x0; +static constexpr uint8_t HAIER_TIMER_MODE_OFF = 0x1; // OFF +static constexpr uint8_t HAIER_TIMER_MODE_ON = 0x2; // ON +static constexpr uint8_t HAIER_TIMER_MODE_ON_OFF = 0x4; // ON → OFF +static constexpr uint8_t HAIER_TIMER_MODE_OFF_ON = 0x5; // ON ← OFF + +// Byte 3[1] - Health +// Byte 4[6] - Power + +// Byte 5[7:5] - Fan Speed +static constexpr uint8_t HAIER_FAN_HIGH = 0x1; +static constexpr uint8_t HAIER_FAN_MED = 0x2; +static constexpr uint8_t HAIER_FAN_LOW = 0x3; +static constexpr uint8_t HAIER_FAN_AUTO = 0x5; + +// Byte 5[4:0] - Timer Off Hours (0-23) 0x00..0x17 +// Byte 6[7] - Quiet Mode +// Byte 6[6] - Turbo Mode +// Byte 6[5:0] - Timer Off Minutes (0-59) 0x00..0x3B + +// Byte 7[7:5] - Mode +static constexpr uint8_t HAIER_MODE_AUTO = 0x0; +static constexpr uint8_t HAIER_MODE_COOL = 0x1; +static constexpr uint8_t HAIER_MODE_DRY = 0x2; +static constexpr uint8_t HAIER_MODE_HEAT = 0x4; +static constexpr uint8_t HAIER_MODE_FAN = 0x6; + +// Byte 7[4:0] - Timer On Hours (0-23) 0x00..0x17 +// Byte 8[7] - Sleep Mode +// Byte 8[5:0] - Timer On Minutes (0-59) 0x00..0x3B +// Byte 10[4] - Self Clean + +// Byte 12 - Button +static constexpr uint8_t HAIER_BUTTON_TEMP_UP = 0x00; +static constexpr uint8_t HAIER_BUTTON_TEMP_DOWN = 0x01; +static constexpr uint8_t HAIER_BUTTON_SWING_V = 0x02; +static constexpr uint8_t HAIER_BUTTON_SWING_H = 0x03; +static constexpr uint8_t HAIER_BUTTON_FAN_SPEED = 0x04; +static constexpr uint8_t HAIER_BUTTON_POWER = 0x05; +static constexpr uint8_t HAIER_BUTTON_MODE = 0x06; +static constexpr uint8_t HAIER_BUTTON_HEALTH = 0x07; +static constexpr uint8_t HAIER_BUTTON_TURBO_QUIET = 0x08; +static constexpr uint8_t HAIER_BUTTON_SLEEP = 0x0B; +static constexpr uint8_t HAIER_BUTTON_TIMER = 0x10; +static constexpr uint8_t HAIER_BUTTON_LIGHT = 0x15; +static constexpr uint8_t HAIER_BUTTON_SELF_CLEAN = 0x19; + +class HaierTundra final : public climate_ir::ClimateIR { + public: + explicit HaierTundra(Unit unit) + : climate_ir::ClimateIR(HAIER_TEMP_MIN, HAIER_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}), + unit_(unit) {} + +#ifdef USE_SWITCH + SUB_SWITCH(health_mode); + SUB_SWITCH(quiet_mode); + SUB_SWITCH(turbo_mode); +#endif + + void set_health_mode(bool health, bool transmit = true); + void set_quiet_mode(bool quiet, bool transmit = true); + void set_turbo_mode(bool turbo, bool transmit = true); + + void toggle_light(); + void start_self_clean(); + + protected: + // Transmit via IR the state of this climate controller. + void transmit_state() override; + + void transmit_(uint8_t button); + uint8_t temperature_(); + uint8_t vertical_swing_(); + uint8_t horizontal_swing_(); + uint8_t health_(); + uint8_t power_(); + uint8_t fan_speed_(); + uint8_t quiet_(); + uint8_t turbo_(); + uint8_t operation_mode_(); + + // Handle received IR Buffer + bool on_receive(remote_base::RemoteReceiveData data) override; + bool on_haier_(const remote_base::HaierData &haier); + + float get_temperature_(uint8_t temp); + climate::ClimateSwingMode get_swing_mode_(uint8_t swing_v, uint8_t swing_h); + climate::ClimateMode get_operation_mode_(uint8_t power, uint8_t mode); + climate::ClimateFanMode get_fan_speed_(uint8_t fan); + + Unit unit_; + bool health_mode_{false}; + bool quiet_mode_{false}; + bool turbo_mode_{false}; +}; + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/switch/__init__.py b/esphome/components/haier_tundra/switch/__init__.py new file mode 100644 index 00000000000..f62769c1350 --- /dev/null +++ b/esphome/components/haier_tundra/switch/__init__.py @@ -0,0 +1,60 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import HaierTundra, haier_tundra_ns + +CODEOWNERS = ["@fauxpark"] + +HaierTundraSwitch = haier_tundra_ns.class_( + "HaierTundraSwitch", switch.Switch, cg.Component +) +SwitchKind = haier_tundra_ns.enum("SwitchKind", is_class=True) + +ICON_LEAF = "mdi:leaf" +ICON_ARROW_DOWN_RIGHT = "mdi:arrow-down-right" +ICON_ARROW_UP_RIGHT_BOLD = "mdi:arrow-up-right-bold" + +CONF_HAIER_TUNDRA_ID = "haier_tundra_id" +CONF_HEALTH_MODE = "health_mode" +CONF_QUIET_MODE = "quiet_mode" +CONF_TURBO_MODE = "turbo_mode" + + +def _switch_schema(icon: str) -> cv.Schema: + return switch.switch_schema( + HaierTundraSwitch, + icon=icon, + default_restore_mode="RESTORE_DEFAULT_OFF", + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra), + cv.Optional(CONF_HEALTH_MODE): _switch_schema(ICON_LEAF), + cv.Optional(CONF_QUIET_MODE): _switch_schema(ICON_ARROW_DOWN_RIGHT), + cv.Optional(CONF_TURBO_MODE): _switch_schema(ICON_ARROW_UP_RIGHT_BOLD), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID]) + switches = switch.sub_switches(config) + if sw := await switches( + CONF_HEALTH_MODE, hub.set_health_mode_switch, hub, SwitchKind.SWITCH_KIND_HEALTH + ): + await cg.register_component(sw, config[CONF_HEALTH_MODE]) + if sw := await switches( + CONF_QUIET_MODE, hub.set_quiet_mode_switch, hub, SwitchKind.SWITCH_KIND_QUIET + ): + await cg.register_component(sw, config[CONF_QUIET_MODE]) + if sw := await switches( + CONF_TURBO_MODE, hub.set_turbo_mode_switch, hub, SwitchKind.SWITCH_KIND_TURBO + ): + await cg.register_component(sw, config[CONF_TURBO_MODE]) diff --git a/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp b/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp new file mode 100644 index 00000000000..e24ada79b25 --- /dev/null +++ b/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp @@ -0,0 +1,43 @@ +#include "haier_tundra_switch.h" +#include "esphome/core/log.h" + +namespace esphome::haier_tundra { + +ESPHOME_LOG_TAG(TAG, "haier_tundra.switch"); + +void HaierTundraSwitch::setup() { + auto initial = this->get_initial_state_with_restore_mode(); + if (initial.has_value()) { + const bool state = initial.value(); + this->apply_(state, false); + this->publish_state(state); + } +} + +void HaierTundraSwitch::dump_config() { LOG_SWITCH("", "Haier Tundra Switch", this); } + +void HaierTundraSwitch::write_state(bool state) { + // Quiet and Turbo can only be turned on in Cool or Heat mode + if (state && this->kind_ != SwitchKind::SWITCH_KIND_HEALTH && this->parent_->mode != climate::CLIMATE_MODE_COOL && + this->parent_->mode != climate::CLIMATE_MODE_HEAT) { + return; + } + this->apply_(state, true); + this->publish_state(state); +} + +void HaierTundraSwitch::apply_(bool state, bool transmit) { + switch (this->kind_) { + case SwitchKind::SWITCH_KIND_HEALTH: + this->parent_->set_health_mode(state, transmit); + break; + case SwitchKind::SWITCH_KIND_QUIET: + this->parent_->set_quiet_mode(state, transmit); + break; + case SwitchKind::SWITCH_KIND_TURBO: + this->parent_->set_turbo_mode(state, transmit); + break; + } +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/switch/haier_tundra_switch.h b/esphome/components/haier_tundra/switch/haier_tundra_switch.h new file mode 100644 index 00000000000..d718b940a15 --- /dev/null +++ b/esphome/components/haier_tundra/switch/haier_tundra_switch.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/switch/switch.h" +#include "esphome/components/haier_tundra/haier_tundra.h" + +namespace esphome::haier_tundra { + +enum class SwitchKind : uint8_t { + SWITCH_KIND_HEALTH, + SWITCH_KIND_QUIET, + SWITCH_KIND_TURBO, +}; + +class HaierTundraSwitch final : public switch_::Switch, public Component, public Parented { + public: + HaierTundraSwitch(HaierTundra *parent, SwitchKind kind) : Parented(parent), kind_(kind) {} + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; + void apply_(bool state, bool transmit); + + SwitchKind kind_; +}; + +} // namespace esphome::haier_tundra diff --git a/tests/components/haier_tundra/common.yaml b/tests/components/haier_tundra/common.yaml new file mode 100644 index 00000000000..0388220cacd --- /dev/null +++ b/tests/components/haier_tundra/common.yaml @@ -0,0 +1,45 @@ +climate: + - platform: haier_tundra + name: Haier AC A + id: my_haier_ac_a + transmitter_id: xmitr + + - platform: haier_tundra + name: Haier AC B + id: my_haier_ac_b + transmitter_id: xmitr + unit: b + +switch: + - platform: haier_tundra + haier_tundra_id: my_haier_ac_a + quiet_mode: + name: "Quiet Mode (Unit A)" + turbo_mode: + name: "Turbo Mode (Unit A)" + health_mode: + name: "Health Mode (Unit A)" + + - platform: haier_tundra + haier_tundra_id: my_haier_ac_b + quiet_mode: + name: "Quiet Mode (Unit B)" + turbo_mode: + name: "Turbo Mode (Unit B)" + health_mode: + name: "Health Mode (Unit B)" + +button: + - platform: haier_tundra + haier_tundra_id: my_haier_ac_a + light: + name: "Toggle Light (Unit A)" + self_clean: + name: "Start Self Clean (Unit A)" + + - platform: haier_tundra + haier_tundra_id: my_haier_ac_b + light: + name: "Toggle Light (Unit B)" + self_clean: + name: "Start Self Clean (Unit B)" diff --git a/tests/components/haier_tundra/test.esp32-idf.yaml b/tests/components/haier_tundra/test.esp32-idf.yaml new file mode 100644 index 00000000000..b88eb95efe7 --- /dev/null +++ b/tests/components/haier_tundra/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + haier_tundra: !include common.yaml diff --git a/tests/components/haier_tundra/test.esp8266-ard.yaml b/tests/components/haier_tundra/test.esp8266-ard.yaml new file mode 100644 index 00000000000..888edbf140b --- /dev/null +++ b/tests/components/haier_tundra/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + haier_tundra: !include common.yaml From 5acbd44067417199f89c8c8d6abcbdd01de6b159 Mon Sep 17 00:00:00 2001 From: Ivan Kruglov <4630547+ikruglov@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:12:28 +0200 Subject: [PATCH 279/651] [pcf8574] Add reset option to preserve outputs across MCU reboots (#20589) Co-authored-by: J. Nick Koston --- esphome/components/pcf8574/__init__.py | 6 + esphome/components/pcf8574/pcf8574.cpp | 33 ++-- esphome/components/pcf8574/pcf8574.h | 11 +- tests/components/pcf8574/common.yaml | 32 ++++ tests/components/pcf8574/test_pcf8574.cpp | 189 ++++++++++++++++++++ tests/unit_tests/components/test_pcf8574.py | 35 ++++ 6 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 tests/components/pcf8574/test_pcf8574.cpp create mode 100644 tests/unit_tests/components/test_pcf8574.py diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 6c9ed3cd98b..5ede39d7145 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_MODE, CONF_NUMBER, CONF_OUTPUT, + CONF_RESET, ) from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -26,11 +27,14 @@ PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin) CONF_PCF8574 = "pcf8574" CONF_PCF8575 = "pcf8575" +# Keep in sync with PCF8574Component::reset_ in pcf8574.h. +DEFAULT_RESET = True CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, + cv.Optional(CONF_RESET, default=DEFAULT_RESET): cv.boolean, cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) @@ -44,6 +48,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) + if (reset := config[CONF_RESET]) != DEFAULT_RESET: + cg.add(var.set_reset(reset)) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 727e9a20265..ea1e894a530 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -12,8 +12,13 @@ void PCF8574Component::setup() { return; } - this->write_gpio_(); - this->read_gpio_(); + if (this->reset_) { + this->write_gpio_(); + this->read_gpio_(); + } else { + this->output_mask_ = this->input_mask_; + this->mode_mask_ = ~this->configured_input_pins_; + } if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); @@ -40,8 +45,9 @@ void PCF8574Component::loop() { void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:\n" - " Is PCF8575: %s", - YESNO(this->pcf8575_)); + " Is PCF8575: %s\n" + " Reset: %s", + YESNO(this->pcf8575_), YESNO(this->reset_)); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { @@ -82,20 +88,12 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { bool PCF8574Component::read_gpio_() { if (this->is_failed()) return false; - bool success; - uint8_t data[2]; - if (this->pcf8575_) { - success = this->read_bytes_raw(data, 2); - this->input_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); - } else { - success = this->read_bytes_raw(data, 1); - this->input_mask_ = data[0]; - } - - if (!success) { + uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. + if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) { this->status_set_warning(); return false; } + this->input_mask_ = (uint16_t(data[1]) << 8) | data[0]; this->status_clear_warning(); return true; } @@ -123,6 +121,11 @@ bool PCF8574Component::write_gpio_() { float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } void PCF8574GPIOPin::setup() { pin_mode(flags_); } +void PCF8574GPIOPin::set_flags(gpio::Flags flags) { + this->flags_ = flags; + if (flags & gpio::FLAG_INPUT) + this->parent_->register_input_pin(this->pin_); +} void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 9879d6a47eb..465bbd7a918 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -16,6 +16,9 @@ class PCF8574Component final : public Component, PCF8574Component() = default; void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; } + void set_reset(bool reset) { this->reset_ = reset; } + // Called by pin setters before any component's setup or GPIO writes. + void register_input_pin(uint8_t pin) { this->configured_input_pins_ |= 1 << pin; } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } /// Check i2c availability and setup masks @@ -44,6 +47,11 @@ class PCF8574Component final : public Component, uint16_t output_mask_{0x00}; /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; + // Pins configured as inputs before setup; 1 means input. With reset disabled, all other pins + // (including undeclared pins) are treated as outputs seeded from their observed levels. + uint16_t configured_input_pins_{0x00}; + // Keep in sync with DEFAULT_RESET in __init__.py; codegen omits the setter for the default. + bool reset_{true}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 InternalGPIOPin *interrupt_pin_{nullptr}; }; @@ -63,7 +71,8 @@ class PCF8574GPIOPin final : public GPIOPin { void set_parent(PCF8574Component *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } + // Call after set_parent() and set_pin(): input flags register the pin with the parent. + void set_flags(gpio::Flags flags); gpio::Flags get_flags() const override { return this->flags_; } diff --git a/tests/components/pcf8574/common.yaml b/tests/components/pcf8574/common.yaml index 8a26b930152..4613f4b8859 100644 --- a/tests/components/pcf8574/common.yaml +++ b/tests/components/pcf8574/common.yaml @@ -8,6 +8,16 @@ pcf8574: address: 0x22 pcf8575: false interrupt_pin: ${interrupt_pin} + reset: true + - id: pcf8574_preserved + i2c_id: i2c_bus + address: 0x23 + reset: false + - id: pcf8575_preserved + i2c_id: i2c_bus + address: 0x24 + pcf8575: true + reset: false binary_sensor: - platform: gpio @@ -18,6 +28,18 @@ binary_sensor: number: 1 mode: INPUT inverted: true + - platform: gpio + id: pcf8574_preserved_input + pin: + pcf8574: pcf8574_preserved + number: 1 + mode: INPUT + - platform: gpio + id: pcf8575_preserved_input + pin: + pcf8574: pcf8575_preserved + number: 15 + mode: INPUT output: - platform: gpio @@ -27,3 +49,13 @@ output: number: 0 mode: OUTPUT inverted: false + - platform: gpio + id: pcf8574_preserved_output + pin: + pcf8574: pcf8574_preserved + number: 0 + - platform: gpio + id: pcf8575_preserved_output + pin: + pcf8574: pcf8575_preserved + number: 8 diff --git a/tests/components/pcf8574/test_pcf8574.cpp b/tests/components/pcf8574/test_pcf8574.cpp new file mode 100644 index 00000000000..3500069ba10 --- /dev/null +++ b/tests/components/pcf8574/test_pcf8574.cpp @@ -0,0 +1,189 @@ +#include + +#include "esphome/components/pcf8574/pcf8574.h" + +namespace esphome::pcf8574::testing { + +// Model quasi-bidirectional readback separately from the output latch. +class PortBus : public i2c::I2CBus { + public: + 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 { + EXPECT_EQ(address, 0x21); + if (read_count != 0) { + EXPECT_EQ(read_count, this->width); + this->reads++; + if (this->fail_read) + return i2c::ERROR_TIMEOUT; + uint16_t levels = this->latch & ~this->external_low; + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = levels >> (8 * i); + } + if (write_count != 0) { + EXPECT_EQ(write_count, this->width); + this->writes++; + this->latch = write_buffer[0]; + if (write_count == 2) + this->latch |= uint16_t(write_buffer[1]) << 8; + } + return i2c::ERROR_OK; + } + + size_t width{1}; + uint16_t latch{0}; + uint16_t external_low{0}; + unsigned reads{0}; + bool fail_read{false}; + unsigned writes{0}; +}; + +class PCF8574ResetTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + this->bus_.width = GetParam() ? 2 : 1; + this->mask_ = GetParam() ? 0xFFFF : 0xFF; + this->high_pin_ = GetParam() ? 15 : 7; + this->bus_.latch = 0xA55A & this->mask_; + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(0x21); + this->component_.set_pcf8575(GetParam()); + } + + void configure_input_(uint8_t number) { + PCF8574GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 0); + } + + PortBus bus_; + PCF8574Component component_; + uint16_t mask_; + uint8_t high_pin_; +}; + +TEST_P(PCF8574ResetTest, DefaultResetRetainsStartupAndPinModeBehavior) { + this->configure_input_(1); + + this->component_.setup(); + ASSERT_EQ(this->bus_.writes, 1); + EXPECT_EQ(this->bus_.latch, this->mask_); + EXPECT_EQ(this->bus_.reads, 2); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 1); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, this->mask_ & ~1); +} + +TEST_P(PCF8574ResetTest, PreservesOtherOutputsBeforeAndAfterTheirPinSetup) { + this->component_.set_reset(false); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 1); + EXPECT_EQ(this->bus_.latch, initial); + + // Consumers can write before calling their GPIO pin's setup(). + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + const uint16_t high_pin_mask = 1 << this->high_pin_; + this->component_.digital_write(this->high_pin_, (initial & high_pin_mask) == 0); + const uint16_t expected = (initial | 1) ^ high_pin_mask; + EXPECT_EQ(this->bus_.latch, expected); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, expected & ~1); + ASSERT_EQ(this->bus_.writes, 3); +} + +TEST_P(PCF8574ResetTest, MixedInputsAreReleasedBeforeTheirConsumersInitialize) { + const uint16_t inputs = (1 << 1) | (1 << this->high_pin_); + this->bus_.latch |= inputs; + const uint16_t initial = this->bus_.latch; + this->bus_.external_low = inputs; + + this->component_.set_reset(false); + this->configure_input_(1); + this->configure_input_(this->high_pin_); + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial | 1); + EXPECT_FALSE(this->component_.digital_read(1)); +} + +TEST_P(PCF8574ResetTest, InputSetupBeforeOutputSetupPreservesOutputs) { + this->component_.set_reset(false); + this->configure_input_(1); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + this->component_.pin_mode(1, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); +} + +TEST_P(PCF8574ResetTest, LoadedHighOutputIsLatchedLowOnFirstWrite) { + const uint16_t loaded_pin = 1 << this->high_pin_; + this->bus_.latch |= loaded_pin; + this->bus_.external_low = loaded_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~loaded_pin); +} + +TEST_P(PCF8574ResetTest, UndeclaredLowPinRemainsDrivenAfterExternalRelease) { + const uint16_t undeclared_pin = 1 << this->high_pin_; + this->bus_.latch |= undeclared_pin; + this->bus_.external_low = undeclared_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + this->bus_.external_low = 0; + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~undeclared_pin); + EXPECT_FALSE(this->component_.digital_read(this->high_pin_)); +} + +TEST_P(PCF8574ResetTest, InitialReadFailurePreventsAllWrites) { + this->bus_.fail_read = true; + + this->component_.set_reset(false); + this->component_.setup(); + EXPECT_TRUE(this->component_.is_failed()); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.writes, 0); +} + +INSTANTIATE_TEST_SUITE_P(PCF8574AndPCF8575, PCF8574ResetTest, ::testing::Bool()); + +} // namespace esphome::pcf8574::testing diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py new file mode 100644 index 00000000000..43751815358 --- /dev/null +++ b/tests/unit_tests/components/test_pcf8574.py @@ -0,0 +1,35 @@ +"""PCF8574 reset configuration and code generation.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from esphome.components import pcf8574 +from esphome.const import CONF_ID, CONF_RESET + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "options, expected", + [ + ({}, True), + ({"reset": True}, True), + ({"reset": False}, False), + ], +) +async def test_reset_configuration(options: dict, expected: bool) -> None: + config = pcf8574.CONFIG_SCHEMA({CONF_ID: "hub", **options}) + assert config[CONF_RESET] is expected + var = Mock() + with ( + patch.object(pcf8574.cg, "new_Pvariable", return_value=var), + patch.object(pcf8574.cg, "register_component", new_callable=AsyncMock), + patch.object(pcf8574.i2c, "register_i2c_device", new_callable=AsyncMock), + patch.object(pcf8574.cg, "add"), + ): + await pcf8574.to_code(config) + + if expected: + var.set_reset.assert_not_called() + else: + var.set_reset.assert_called_once_with(False) From d27ec8ec4966634d59dee9002bd631e71f71d458 Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:13:26 +0200 Subject: [PATCH 280/651] [voice_assistant] Make TTS playback start timeout configurable (#18707) Co-authored-by: n-IA-hane --- .../components/voice_assistant/__init__.py | 16 ++++++- .../voice_assistant/voice_assistant.cpp | 2 +- .../voice_assistant/voice_assistant.h | 3 ++ .../voice_assistant/__init__.py | 0 .../voice_assistant/test_playback_timeout.py | 44 +++++++++++++++++++ .../voice_assistant/common-idf.yaml | 1 + tests/components/voice_assistant/common.yaml | 1 + .../components/voice_assistant/__init__.py | 0 .../test_tts_playback_timeout.py | 35 +++++++++++++++ 9 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/voice_assistant/__init__.py create mode 100644 tests/component_tests/voice_assistant/test_playback_timeout.py create mode 100644 tests/unit_tests/components/voice_assistant/__init__.py create mode 100644 tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d306947ec13..488e16a3839 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation +from esphome import automation, core import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -47,6 +47,9 @@ CONF_MICRO_WAKE_WORD = "micro_wake_word" CONF_WAKE_WORD = "wake_word" CONF_CONVERSATION_TIMEOUT = "conversation_timeout" +CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout" +# Keep in sync with VoiceAssistant::tts_playback_start_timeout_ in voice_assistant.h. +TTS_PLAYBACK_START_TIMEOUT_DEFAULT = core.TimePeriod(milliseconds=2000) CONF_ON_TIMER_STARTED = "on_timer_started" CONF_ON_TIMER_UPDATED = "on_timer_updated" @@ -110,6 +113,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_CONVERSATION_TIMEOUT, default="300s" ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TTS_PLAYBACK_START_TIMEOUT, + default=TTS_PLAYBACK_START_TIMEOUT_DEFAULT, + ): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=core.TimePeriod(milliseconds=1)), + ), cv.Optional(CONF_VOLUME_MULTIPLIER, default=1.0): cv.float_range( min=0.0, min_included=False ), @@ -218,6 +228,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auto_gain(config[CONF_AUTO_GAIN])) cg.add(var.set_volume_multiplier(config[CONF_VOLUME_MULTIPLIER])) cg.add(var.set_conversation_timeout(config[CONF_CONVERSATION_TIMEOUT])) + if ( + timeout := config[CONF_TTS_PLAYBACK_START_TIMEOUT] + ) != TTS_PLAYBACK_START_TIMEOUT_DEFAULT: + cg.add(var.set_tts_playback_start_timeout(timeout)) if CONF_ON_LISTENING in config: await automation.build_automation( diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 039410abdbf..1d66ca5950f 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -760,7 +760,7 @@ void VoiceAssistant::signal_stop_() { } void VoiceAssistant::start_playback_timeout_() { - this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() { + this->set_timeout(PLAYBACK_TIMEOUT_ID, this->tts_playback_start_timeout_, [this]() { this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index ae5eee3e227..139a1c84c38 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -193,6 +193,7 @@ class VoiceAssistant final : public Component { void set_auto_gain(uint8_t auto_gain) { this->auto_gain_ = auto_gain; } void set_volume_multiplier(float volume_multiplier) { this->volume_multiplier_ = volume_multiplier; } void set_conversation_timeout(uint32_t conversation_timeout) { this->conversation_timeout_ = conversation_timeout; } + void set_tts_playback_start_timeout(uint32_t timeout) { this->tts_playback_start_timeout_ = timeout; } void reset_conversation_id(); Trigger<> *get_intent_end_trigger() { return &this->intent_end_trigger_; } @@ -318,6 +319,8 @@ class VoiceAssistant final : public Component { float volume_multiplier_; uint32_t conversation_timeout_; + // Keep in sync with TTS_PLAYBACK_START_TIMEOUT_DEFAULT in __init__.py. + uint32_t tts_playback_start_timeout_{2000}; Configuration config_{}; diff --git a/tests/component_tests/voice_assistant/__init__.py b/tests/component_tests/voice_assistant/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/voice_assistant/test_playback_timeout.py b/tests/component_tests/voice_assistant/test_playback_timeout.py new file mode 100644 index 00000000000..cae93e18bf8 --- /dev/null +++ b/tests/component_tests/voice_assistant/test_playback_timeout.py @@ -0,0 +1,44 @@ +"""Test playback timeout C++ generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("timeout", [None, "2s", "2000ms", "1ms", "10s"]) +def test_playback_timeout_codegen( + timeout: str | None, + tmp_path: Path, + generate_main: Callable[[Path], str], +) -> None: + config = tmp_path / "voice_assistant.yaml" + config.write_text( + """esphome: + name: test-voice-assistant +esp32: + board: esp32dev + framework: + type: esp-idf +wifi: + ssid: test-network +api: +i2s_audio: + i2s_lrclk_pin: GPIO25 + i2s_bclk_pin: GPIO26 +microphone: + - platform: i2s_audio + id: test_mic + adc_type: external + i2s_din_pin: GPIO34 +voice_assistant: + microphone: test_mic +""" + + (f" tts_playback_start_timeout: {timeout}\n" if timeout else "") + ) + main = generate_main(config) + if timeout in (None, "2s", "2000ms"): + assert "set_tts_playback_start_timeout(" not in main + else: + milliseconds = 1 if timeout == "1ms" else 10000 + assert f"set_tts_playback_start_timeout({milliseconds});" in main diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 998ba90dd99..d08c76879e1 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -55,6 +55,7 @@ voice_assistant: speaker: va_speaker_id micro_wake_word: mww_id conversation_timeout: 60s + tts_playback_start_timeout: 10s on_listening: - logger.log: "Voice assistant microphone listening" on_start: diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index 5a5791899e2..df1fa50eac9 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -36,6 +36,7 @@ voice_assistant: channels: 0 speaker: va_speaker_id conversation_timeout: 60s + tts_playback_start_timeout: 10s on_listening: - logger.log: "Voice assistant microphone listening" on_start: diff --git a/tests/unit_tests/components/voice_assistant/__init__.py b/tests/unit_tests/components/voice_assistant/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py new file mode 100644 index 00000000000..96f44a09e32 --- /dev/null +++ b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py @@ -0,0 +1,35 @@ +"""Validate Voice Assistant playback timeout configuration.""" + +import pytest + +from esphome.components import voice_assistant +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE + + +@pytest.mark.parametrize( + "value", + ["0s", "0ms", {"seconds": 0}, "-1s", "500us", "1ns", "500ns", {"nanoseconds": 1}], +) +def test_playback_timeout_rejects_invalid_duration(value: str | dict[str, int]) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + with pytest.raises(cv.Invalid): + voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value}) + + +@pytest.mark.parametrize( + "value, milliseconds", [("1ms", 1), ("2s", 2000), ("10s", 10000)] +) +def test_playback_timeout_accepts_positive_milliseconds( + value: str, milliseconds: int +) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value}) + assert config["tts_playback_start_timeout"].total_milliseconds == milliseconds + + +def test_playback_timeout_default_is_two_seconds() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = voice_assistant.CONFIG_SCHEMA({}) + assert config["tts_playback_start_timeout"].total_milliseconds == 2000 From 1d8423edd802f8cb8de213c1020e1b36b769e3dc Mon Sep 17 00:00:00 2001 From: cociweb Date: Tue, 13 Oct 2026 20:14:43 +0200 Subject: [PATCH 281/651] [es8388] Add initial option configuration for DAC output and ADC input mic (#16342) Co-authored-by: J. Nick Koston --- esphome/components/es8388/es8388.cpp | 40 +++++++++++------ esphome/components/es8388/es8388.h | 15 ++++++- esphome/components/es8388/select/__init__.py | 46 ++++++++++++++++++-- esphome/core/defines.h | 1 + tests/components/es8388/common.yaml | 4 ++ 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index b57949ccd0a..56ee1ac4a59 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -111,24 +111,36 @@ void ES8388::setup() { // Power on ADC, Enable LIN&RIN, Power off MICBIAS, set int1lp to low power mode ES8388_ERROR_FAILED(this->write_byte(ES8388_ADCPOWER, 0x09)); -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT if (this->dac_output_select_ != nullptr) { - auto dac_power = this->get_dac_power(); - if (dac_power.has_value()) { - if (this->dac_output_select_->has_index(dac_power.value())) { - this->dac_output_select_->publish_state(dac_power.value()); - } else { - ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value()); + if (this->dac_output_initial_.has_value()) { + const DacOutputLine line = this->dac_output_initial_.value(); + ES8388_ERROR_FAILED(this->set_dac_output(line)); + this->dac_output_select_->publish_state(static_cast(line)); + } else { + auto dac_power = this->get_dac_power(); + if (dac_power.has_value()) { + if (this->dac_output_select_->has_index(dac_power.value())) { + this->dac_output_select_->publish_state(dac_power.value()); + } else { + ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value()); + } } } } if (this->adc_input_mic_select_ != nullptr) { - auto mic_input = this->get_mic_input(); - if (mic_input.has_value()) { - if (this->adc_input_mic_select_->has_index(mic_input.value())) { - this->adc_input_mic_select_->publish_state(mic_input.value()); - } else { - ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value()); + if (this->adc_input_mic_initial_.has_value()) { + const AdcInputMicLine line = this->adc_input_mic_initial_.value(); + ES8388_ERROR_FAILED(this->set_adc_input_mic(line)); + this->adc_input_mic_select_->publish_state(static_cast(line)); + } else { + auto mic_input = this->get_mic_input(); + if (mic_input.has_value()) { + if (this->adc_input_mic_select_->has_index(mic_input.value())) { + this->adc_input_mic_select_->publish_state(mic_input.value()); + } else { + ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value()); + } } } } @@ -138,7 +150,7 @@ void ES8388::setup() { void ES8388::dump_config() { ESP_LOGCONFIG(TAG, "ES8388 Audio Codec:"); LOG_I2C_DEVICE(this); -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT LOG_SELECT(" ", "DacOutputSelect", this->dac_output_select_); LOG_SELECT(" ", "ADCInputMicSelect", this->adc_input_mic_select_); #endif diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index b01acb69c1e..b55538c94f8 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -5,7 +5,7 @@ #include "esphome/components/audio_dac/audio_dac.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT #include "esphome/components/select/select.h" #endif @@ -26,7 +26,7 @@ enum AdcInputMicLine : uint8_t { }; class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT SUB_SELECT(dac_output) SUB_SELECT(adc_input_mic) #endif @@ -68,11 +68,22 @@ class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I bool set_dac_output(DacOutputLine line); bool set_adc_input_mic(AdcInputMicLine line); +#ifdef USE_ES8388_SELECT + /// Line to apply in setup() instead of keeping what the codec reports. + void set_dac_output_initial(DacOutputLine line) { this->dac_output_initial_ = line; } + void set_adc_input_mic_initial(AdcInputMicLine line) { this->adc_input_mic_initial_ = line; } +#endif + protected: /// @brief Mutes or unmutes the DAC audio out /// @param mute_state True to mute, false to unmute /// @return True if successful and false otherwise bool set_mute_state_(bool mute_state); + +#ifdef USE_ES8388_SELECT + optional dac_output_initial_{}; + optional adc_input_mic_initial_{}; +#endif }; } // namespace esphome::es8388 diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index 62ddd6cb38a..f0e3f5b7c89 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -1,7 +1,11 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_CHIP # noqa: F401 +from esphome.const import ( + CONF_INITIAL_OPTION, + ENTITY_CATEGORY_CONFIG, + ICON_CHIP, # noqa: F401 +) from esphome.types import ConfigType from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns @@ -9,6 +13,21 @@ from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns CONF_DAC_OUTPUT = "dac_output" CONF_ADC_INPUT_MIC = "adc_input_mic" +DacOutputLine = es8388_ns.enum("DacOutputLine") +DAC_OUTPUT_OPTIONS = { + "LINE1": DacOutputLine.DAC_OUTPUT_LINE1, + "LINE2": DacOutputLine.DAC_OUTPUT_LINE2, + "BOTH": DacOutputLine.DAC_OUTPUT_BOTH, +} +DAC_OUTPUT_OPTION_NAMES = list(DAC_OUTPUT_OPTIONS) +AdcInputMicLine = es8388_ns.enum("AdcInputMicLine") +ADC_INPUT_MIC_OPTIONS = { + "LINE1": AdcInputMicLine.ADC_INPUT_MIC_LINE1, + "LINE2": AdcInputMicLine.ADC_INPUT_MIC_LINE2, + "DIFFERENCE": AdcInputMicLine.ADC_INPUT_MIC_DIFFERENCE, +} +ADC_INPUT_MIC_OPTION_NAMES = list(ADC_INPUT_MIC_OPTIONS) + DacOutputSelect = es8388_ns.class_("DacOutputSelect", select.Select) ADCInputMicSelect = es8388_ns.class_("ADCInputMicSelect", select.Select) @@ -19,24 +38,45 @@ CONFIG_SCHEMA = cv.All( DacOutputSelect, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_CHIP, + ).extend( + { + cv.Optional(CONF_INITIAL_OPTION): cv.enum( + DAC_OUTPUT_OPTIONS, upper=True + ), + } ), cv.Optional(CONF_ADC_INPUT_MIC): select.select_schema( ADCInputMicSelect, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_CHIP, + ).extend( + { + cv.Optional(CONF_INITIAL_OPTION): cv.enum( + ADC_INPUT_MIC_OPTIONS, upper=True + ), + } ), } ) async def to_code(config: ConfigType) -> None: + cg.add_define("USE_ES8388_SELECT") hub = await cg.get_variable(config[CONF_ES8388_ID]) selects = select.sub_selects(config, parent=hub) await selects( - CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"] + CONF_DAC_OUTPUT, hub.set_dac_output_select, options=DAC_OUTPUT_OPTION_NAMES ) await selects( CONF_ADC_INPUT_MIC, hub.set_adc_input_mic_select, - options=["LINE1", "LINE2", "DIFFERENCE"], + options=ADC_INPUT_MIC_OPTION_NAMES, ) + if ( + initial := config.get(CONF_DAC_OUTPUT, {}).get(CONF_INITIAL_OPTION) + ) is not None: + cg.add(hub.set_dac_output_initial(initial)) + if ( + initial := config.get(CONF_ADC_INPUT_MIC, {}).get(CONF_INITIAL_OPTION) + ) is not None: + cg.add(hub.set_adc_input_mic_initial(initial)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d9b12fdddb1..08f1e31d6c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -72,6 +72,7 @@ #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT +#define USE_ES8388_SELECT #define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED diff --git a/tests/components/es8388/common.yaml b/tests/components/es8388/common.yaml index cc7465ab935..aa08b9b0470 100644 --- a/tests/components/es8388/common.yaml +++ b/tests/components/es8388/common.yaml @@ -17,5 +17,9 @@ select: es8388_id: es8388_parent dac_output: name: "DAC Output" + id: es8388_test_dac_output_select + initial_option: BOTH adc_input_mic: name: "ADC Input MIC" + id: es8388_test_adc_input_mic_select + initial_option: LINE2 From 55261ae7e5df30e846d2e84bc1b6775b3eab49ad Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 13 Oct 2026 20:17:25 +0200 Subject: [PATCH 282/651] [tmc2209] Add driver diagnostics and stall detection (#20618) Co-authored-by: Claude Opus 5.5 --- esphome/components/tmc2209/__init__.py | 44 +++++ esphome/components/tmc2209/stepper.py | 28 ++- esphome/components/tmc2209/tmc2209.cpp | 20 ++ esphome/components/tmc2209/tmc2209.h | 39 ++++ esphome/components/tmc22xx/__init__.py | 18 +- esphome/components/tmc22xx/tmc22xx.cpp | 186 ++++++++++++++++-- esphome/components/tmc22xx/tmc22xx.h | 55 ++++++ tests/components/tmc2209/common.yaml | 29 +++ tests/components/tmc2209/test.esp32-idf.yaml | 1 + .../components/tmc2209/test.esp8266-ard.yaml | 1 + tests/components/tmc2209/test.rp2040-ard.yaml | 1 + 11 files changed, 404 insertions(+), 18 deletions(-) diff --git a/esphome/components/tmc2209/__init__.py b/esphome/components/tmc2209/__init__.py index b3822f7582e..06dd8603ff5 100644 --- a/esphome/components/tmc2209/__init__.py +++ b/esphome/components/tmc2209/__init__.py @@ -1,10 +1,54 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import tmc22xx +import esphome.config_validation as cv +from esphome.const import CONF_THRESHOLD CODEOWNERS = ["@remcom"] DOMAIN = "tmc2209" +CONF_SEDN = "sedn" +CONF_SEIMIN = "seimin" +CONF_SEMAX = "semax" +CONF_SEMIN = "semin" +CONF_SEUP = "seup" +CONF_TCOOL_THRESHOLD = "tcool_threshold" + tmc2209_ns = cg.esphome_ns.namespace("tmc2209") TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper) tmc22xx.register_actions("tmc2209", TMC2209Stepper) + +automation.register_apply_action( + "tmc2209.stallguard", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_THRESHOLD): cv.templatable(cv.uint8_t), + cv.Optional(CONF_TCOOL_THRESHOLD): cv.templatable( + cv.int_range(0, 2**20 - 1) + ), + } + ), + automation.ApplyField(CONF_THRESHOLD, "set_stallguard_threshold", cg.uint8), + automation.ApplyField(CONF_TCOOL_THRESHOLD, "set_tcool_threshold", cg.uint32), +) + +automation.register_apply_action( + "tmc2209.coolconf", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_SEMIN): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_SEMAX): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_SEUP): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_SEDN): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_SEIMIN): cv.templatable(cv.boolean), + } + ), + automation.ApplyField(CONF_SEMIN, "set_semin", cg.uint8), + automation.ApplyField(CONF_SEMAX, "set_semax", cg.uint8), + automation.ApplyField(CONF_SEUP, "set_seup", cg.uint8), + automation.ApplyField(CONF_SEDN, "set_sedn", cg.uint8), + automation.ApplyField(CONF_SEIMIN, "set_seimin", cg.bool_), +) diff --git a/esphome/components/tmc2209/stepper.py b/esphome/components/tmc2209/stepper.py index 5ef91dc591f..8867242aa25 100644 --- a/esphome/components/tmc2209/stepper.py +++ b/esphome/components/tmc2209/stepper.py @@ -1,4 +1,6 @@ +from esphome import automation from esphome.components import tmc22xx +import esphome.config_validation as cv from esphome.types import ConfigType from . import TMC2209Stepper @@ -6,9 +8,31 @@ from . import TMC2209Stepper AUTO_LOAD = ["tmc22xx"] DEPENDENCIES = ["uart"] -CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3) +CONF_ON_STALL = "on_stall" + + +def _validate_stall_needs_diag(config: ConfigType) -> ConfigType: + if CONF_ON_STALL in config and tmc22xx.CONF_DIAG_PIN not in config: + raise cv.Invalid( + f"'{CONF_ON_STALL}' requires '{tmc22xx.CONF_DIAG_PIN}', stalls are only reported on DIAG" + ) + return config + + +CONFIG_SCHEMA = cv.All( + tmc22xx.tmc22xx_schema( + TMC2209Stepper, + max_address=3, + extra={cv.Optional(CONF_ON_STALL): automation.validate_automation({})}, + ), + _validate_stall_needs_diag, +) FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate async def to_code(config: ConfigType) -> None: - await tmc22xx.new_tmc22xx(config) + var = await tmc22xx.new_tmc22xx(config) + for conf in config.get(CONF_ON_STALL, []): + await automation.build_callback_automation( + var, "add_on_stall_callback", [], conf + ) diff --git a/esphome/components/tmc2209/tmc2209.cpp b/esphome/components/tmc2209/tmc2209.cpp index 040b362a8d5..21625a5147d 100644 --- a/esphome/components/tmc2209/tmc2209.cpp +++ b/esphome/components/tmc2209/tmc2209.cpp @@ -10,4 +10,24 @@ void TMC2209Stepper::dump_config() { TMC22XXStepper::dump_config(); } +uint32_t *TMC2209Stepper::shadow_register(uint8_t reg) { + switch (reg) { + case REG_TCOOLTHRS: + return &this->tcoolthrs_; + case REG_SGTHRS: + return &this->sgthrs_; + case REG_COOLCONF: + return &this->coolconf_; + default: + return TMC22XXStepper::shadow_register(reg); + } +} + +void TMC2209Stepper::restore_registers() { + TMC22XXStepper::restore_registers(); + this->write_register(REG_TCOOLTHRS, this->tcoolthrs_); + this->write_register(REG_SGTHRS, this->sgthrs_); + this->write_register(REG_COOLCONF, this->coolconf_); +} + } // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index 2f880251e56..f1c83485fa3 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -4,12 +4,51 @@ namespace esphome::tmc2209 { +using tmc22xx::RegisterField; + +// StallGuard and CoolStep registers, which the TMC2208 does not have +static constexpr uint8_t REG_TCOOLTHRS = 0x14; +static constexpr uint8_t REG_SGTHRS = 0x40; +static constexpr uint8_t REG_COOLCONF = 0x42; + +static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; +static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false}; +static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false}; +static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false}; +static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false}; +static constexpr RegisterField SEDN{REG_COOLCONF, 13, 2, false}; +static constexpr RegisterField SEIMIN{REG_COOLCONF, 15, 1, false}; + class TMC2209Stepper : public tmc22xx::TMC22XXStepper { public: void dump_config() override; + /// Called when DIAG reports a stall. StallGuard only works in StealthChop, between TCOOLTHRS and TPWMTHRS. + template void add_on_stall_callback(F &&callback) { + this->stall_callback_.add(std::forward(callback)); + } + + /// A stall is reported when the StallGuard result drops below twice this value. + void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); } + /// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG. + void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); } + void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); } + void set_semax(uint8_t semax) { this->write_field(SEMAX, semax); } + void set_seup(uint8_t seup) { this->write_field(SEUP, seup); } + void set_sedn(uint8_t sedn) { this->write_field(SEDN, sedn); } + void set_seimin(bool seimin) { this->write_field(SEIMIN, seimin); } + protected: uint8_t expected_version() const override { return 0x21; } + uint32_t *shadow_register(uint8_t reg) override; + void restore_registers() override; + void on_diag_without_error() override { this->stall_callback_.call(); } + + LazyCallbackManager stall_callback_; + // Write-only registers with their power-on values + uint32_t tcoolthrs_{0}; + uint32_t sgthrs_{0}; + uint32_t coolconf_{0}; }; } // namespace esphome::tmc2209 diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index ad7221f0b21..da760dde2c5 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -21,6 +21,7 @@ DEPENDENCIES = ["uart"] CONF_ANALOG_CURRENT_SCALE = "analog_current_scale" CONF_CLOCK_FREQUENCY = "clock_frequency" +CONF_DIAG_PIN = "diag_pin" CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" CONF_ENN_PIN = "enn_pin" CONF_HOLD_CURRENT = "hold_current" @@ -31,6 +32,7 @@ CONF_INTERPOLATION = "interpolation" CONF_INVERSE_DIRECTION = "inverse_direction" CONF_IRUN = "irun" CONF_MICROSTEPS = "microsteps" +CONF_ON_STATUS = "on_status" CONF_OTTRIM = "ottrim" CONF_RSENSE = "rsense" CONF_RUN_CURRENT = "run_current" @@ -44,6 +46,7 @@ TMC22XXStepper = tmc22xx_ns.class_( "TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice ) StandstillMode = tmc22xx_ns.enum("StandstillMode") +DriverEvent = tmc22xx_ns.enum("DriverEvent") STANDSTILL_MODES = { "normal": StandstillMode.STANDSTILL_MODE_NORMAL, @@ -56,7 +59,9 @@ 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: +def tmc22xx_schema( + cls: MockObjClass, max_address: int, extra: dict | None = None +) -> 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( @@ -67,6 +72,7 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.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_DIAG_PIN): pins.internal_gpio_input_pin_schema, cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All( cv.frequency, cv.int_range(min=1) ), @@ -79,6 +85,8 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: cv.Optional(CONF_RUN_CURRENT): validate_current, cv.Optional(CONF_HOLD_CURRENT): validate_current, cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps, + cv.Optional(CONF_ON_STATUS): automation.validate_automation({}), + **(extra or {}), } ) .extend(cv.COMPONENT_SCHEMA) @@ -133,6 +141,7 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: (CONF_STEP_PIN, var.set_step_pin), (CONF_DIR_PIN, var.set_dir_pin), (CONF_INDEX_PIN, var.set_index_pin), + (CONF_DIAG_PIN, var.set_diag_pin), ): if (pin := config.get(key)) is not None: cg.add(setter(await cg.gpio_pin_expression(pin))) @@ -145,6 +154,13 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: ): if (value := config.get(key)) is not None: cg.add(setter(value)) + for conf in config.get(CONF_ON_STATUS, []): + await automation.build_callback_automation( + var, + "add_on_status_callback", + [(DriverEvent, "event"), (cg.bool_, "active")], + conf, + ) return var diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 1628572d406..2aed97bea10 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -23,6 +23,49 @@ static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resis 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; +static constexpr uint32_t STATUS_POLL_INTERVAL_MS = 1000; +// While the driver does not answer, for example without motor supply, only poll every 10 s; each failed read +// blocks the loop for the UART timeout +static constexpr uint8_t UNREACHABLE_POLL_SKIP = 9; +static constexpr uint32_t GSTAT_FLAGS = 0x07; // reset, drv_err, uv_cp +static constexpr uint32_t DRV_STATUS_FLAGS = 0x0FFF; + +static const LogString *driver_event_to_string(DriverEvent event) { + switch (event) { + case DRIVER_EVENT_RESET: + return LOG_STR("Driver reset"); + case DRIVER_EVENT_DRIVER_ERROR: + return LOG_STR("Driver error"); + case DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE: + return LOG_STR("Charge pump undervoltage"); + case DRIVER_EVENT_OVERTEMPERATURE_PREWARNING: + return LOG_STR("Overtemperature prewarning"); + case DRIVER_EVENT_OVERTEMPERATURE: + return LOG_STR("Overtemperature shutdown"); + case DRIVER_EVENT_SHORT_TO_GROUND_A: + return LOG_STR("Short to ground on phase A"); + case DRIVER_EVENT_SHORT_TO_GROUND_B: + return LOG_STR("Short to ground on phase B"); + case DRIVER_EVENT_LOW_SIDE_SHORT_A: + return LOG_STR("Low side short on phase A"); + case DRIVER_EVENT_LOW_SIDE_SHORT_B: + return LOG_STR("Low side short on phase B"); + case DRIVER_EVENT_OPEN_LOAD_A: + return LOG_STR("Open load on phase A"); + case DRIVER_EVENT_OPEN_LOAD_B: + return LOG_STR("Open load on phase B"); + case DRIVER_EVENT_TEMPERATURE_120C: + return LOG_STR("Temperature above 120C"); + case DRIVER_EVENT_TEMPERATURE_143C: + return LOG_STR("Temperature above 143C"); + case DRIVER_EVENT_TEMPERATURE_150C: + return LOG_STR("Temperature above 150C"); + case DRIVER_EVENT_TEMPERATURE_157C: + return LOG_STR("Temperature above 157C"); + default: + return LOG_STR("Unknown"); + } +} // 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) { @@ -34,8 +77,19 @@ static uint8_t datagram_crc(const uint8_t *data, size_t len) { return crc; } +// Index into RESTORED_REGISTERS, or -1 when the register is not restored after a driver reset +static int restored_index(uint8_t reg) { + for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) { + if (RESTORED_REGISTERS[i] == reg) + return static_cast(i); + } + return -1; +} + void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; } +void IRAM_ATTR DiagPinStore::gpio_intr(DiagPinStore *arg) { arg->triggered = true; } + void TMC22XXStepper::setup() { if (this->enn_pin_ != nullptr) { this->enn_pin_->setup(); @@ -53,13 +107,54 @@ void TMC22XXStepper::setup() { 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->restore_registers(); + this->write_register(REG_GSTAT, GSTAT_FLAGS); + this->apply_config_(); + // Currents live in a write-only register, so later changes survive a driver reset through its cached value + 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) { + this->index_pin_->setup(); + this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); + } else { + this->step_pin_->setup(); + this->step_pin_->digital_write(false); + this->dir_pin_->setup(); + this->dir_pin_->digital_write(false); + } + + if (this->diag_pin_ != nullptr) { + this->diag_pin_->setup(); + this->diag_pin_->attach_interrupt(DiagPinStore::gpio_intr, &this->diag_store_, gpio::INTERRUPT_RISING_EDGE); + } + // Polling notices a driver reset, and conditions that clear without a DIAG edge + this->set_interval(STATUS_POLL_INTERVAL_MS, [this]() { + if (this->status_poll_skip_ > 0) { + this->status_poll_skip_--; + return; + } + if (!this->update_status_()) + this->status_poll_skip_ = UNREACHABLE_POLL_SKIP; + }); + + this->set_enabled(true); +} + +void TMC22XXStepper::restore_registers() { 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); + for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) { + if ((this->restored_written_ & (1u << i)) != 0) + this->write_register(RESTORED_REGISTERS[i], this->restored_values_[i]); + } +} +void TMC22XXStepper::apply_config_() { this->write_field(PDN_DISABLE, true); this->write_field(MSTEP_REG_SELECT, true); this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_)); @@ -76,28 +171,59 @@ void TMC22XXStepper::setup() { 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); +bool TMC22XXStepper::update_status_() { + auto gstat = this->read_register(REG_GSTAT); + if (!gstat.has_value()) + return false; + uint32_t drv_status = 0; + // DRV_STATUS only feeds on_status and the DIAG check, skip the transfer when neither is used + if (this->diag_pin_ != nullptr || !this->status_callback_.empty()) { + auto value = this->read_register(REG_DRV_STATUS); + if (!value.has_value()) + return false; + drv_status = *value; + } + // GSTAT flags stay set until written with 1; they are set again while the condition lasts + if ((*gstat & GSTAT_FLAGS) != 0) + this->write_register(REG_GSTAT, *gstat & GSTAT_FLAGS); + + uint16_t status = (*gstat & GSTAT_FLAGS) | ((drv_status & DRV_STATUS_FLAGS) << 3); + // Restore before reporting, so an on_status automation for the reset sees and can change the restored settings + if ((status & (1u << DRIVER_EVENT_RESET)) != 0) { + ESP_LOGW(TAG, "Restoring settings after driver reset"); + this->restore_registers(); + // The motor stopped with the reset, ramp up again from standstill + this->current_speed_ = 0.0f; + this->set_enabled(this->enabled_); + } + uint16_t changed = status ^ this->status_; + this->status_ = status; + for (uint8_t event = 0; event < DRIVER_EVENT_COUNT; event++) { + if ((changed & (1u << event)) == 0) + continue; + bool active = (status & (1u << event)) != 0; + // Open load and temperature levels are informational, the driver keeps running + if (active && event < DRIVER_EVENT_OPEN_LOAD_A) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(driver_event_to_string(static_cast(event)))); + } else { + ESP_LOGD(TAG, "%s: %s", LOG_STR_ARG(driver_event_to_string(static_cast(event))), + active ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("cleared")); + } + this->status_callback_.call(static_cast(event), active); + } + return true; } void TMC22XXStepper::dump_config() { @@ -128,6 +254,7 @@ void TMC22XXStepper::dump_config() { LOG_PIN(" STEP Pin: ", this->step_pin_); LOG_PIN(" DIR Pin: ", this->dir_pin_); LOG_PIN(" INDEX Pin: ", this->index_pin_); + LOG_PIN(" DIAG Pin: ", this->diag_pin_); LOG_STEPPER(this); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -137,12 +264,29 @@ void TMC22XXStepper::dump_config() { void TMC22XXStepper::loop() { if (this->index_pin_ != nullptr) this->add_index_pulses_(); + if (this->diag_store_.triggered) { + this->diag_store_.triggered = false; + // DIAG rises on a driver error, any other rise is chip specific + if (!this->update_status_()) { + ESP_LOGW(TAG, "DIAG rose, but the driver status could not be read"); + } else if ((this->status_ & GSTAT_FLAGS) == 0) { + this->on_diag_without_error(); + } + } + if (this->has_reached_target()) { this->high_freq_.stop(); + this->moving_ = false; } else { this->high_freq_.start(); if (!this->enabled_) this->set_enabled(true); + // An unreachable driver is polled only every 10 s. Read it before the first step, so settings lost when the + // motor supply dropped are restored before the move runs on the chip defaults. + if (!this->moving_ && this->status_poll_skip_ > 0) { + this->status_poll_skip_ = this->update_status_() ? 0 : UNREACHABLE_POLL_SKIP; + } + this->moving_ = true; } if (this->index_pin_ != nullptr) { @@ -311,8 +455,12 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { } this->status_clear_warning(); - if (uint32_t *shadow = this->shadow_register(reg)) + if (uint32_t *shadow = this->shadow_register(reg)) { *shadow = value; + } else if (int index = restored_index(reg); index >= 0) { + this->restored_values_[index] = value; + this->restored_written_ |= 1u << index; + } return true; } @@ -346,7 +494,15 @@ 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); + optional data; + int index = restored_index(field.reg); + if (index >= 0 && (this->restored_written_ & (1u << index)) != 0) { + // Modify the last written value, not the chip: after a driver reset that the poll has not seen yet, the chip + // holds its defaults, and those would replace the values to restore + data = this->restored_values_[index]; + } else { + data = this->read_register(field.reg); + } if (!data.has_value()) return false; uint32_t mask = field.mask(); diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 8b0c92d3520..546a27e70d3 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/components/stepper/stepper.h" @@ -30,8 +31,12 @@ 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_DRV_STATUS = 0x6F; static constexpr uint8_t REG_PWMCONF = 0x70; +// Readable registers that a driver reset sets back to their defaults; the last written values are restored +static constexpr std::array RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF}; + 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}; @@ -63,6 +68,33 @@ enum StandstillMode : uint8_t { STANDSTILL_MODE_COIL_SHORT_HS = 3, }; +/// Driver conditions reported to on_status, in the order of their status bits: GSTAT bits 0-2, then DRV_STATUS bits +/// 0-11. +enum DriverEvent : uint8_t { + DRIVER_EVENT_RESET = 0, + DRIVER_EVENT_DRIVER_ERROR, + DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE, + DRIVER_EVENT_OVERTEMPERATURE_PREWARNING, + DRIVER_EVENT_OVERTEMPERATURE, + DRIVER_EVENT_SHORT_TO_GROUND_A, + DRIVER_EVENT_SHORT_TO_GROUND_B, + DRIVER_EVENT_LOW_SIDE_SHORT_A, + DRIVER_EVENT_LOW_SIDE_SHORT_B, + DRIVER_EVENT_OPEN_LOAD_A, + DRIVER_EVENT_OPEN_LOAD_B, + DRIVER_EVENT_TEMPERATURE_120C, + DRIVER_EVENT_TEMPERATURE_143C, + DRIVER_EVENT_TEMPERATURE_150C, + DRIVER_EVENT_TEMPERATURE_157C, + DRIVER_EVENT_COUNT, +}; + +/// Set by a rising edge on DIAG, which signals a driver error or, on chips that support it, a stall. +struct DiagPinStore { + volatile bool triggered{false}; + static void gpio_intr(DiagPinStore *arg); +}; + /// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL. struct IndexPulseStore { volatile int32_t pulses{0}; @@ -84,6 +116,7 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U 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_diag_pin(InternalGPIOPin *pin) { this->diag_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; } @@ -97,6 +130,11 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_enabled(bool enabled); bool is_enabled() const { return this->enabled_; } + /// Called with the condition and whether it became active or cleared. + template void add_on_status_callback(F &&callback) { + this->status_callback_.add(std::forward(callback)); + } + void set_microsteps(uint16_t microsteps); uint16_t get_microsteps(); void set_interpolation(bool enable) { this->write_field(INTPOL, enable); } @@ -127,6 +165,15 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U 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); + /// Write the cached write-only registers and the last written configuration registers to the driver, at setup + /// and after a driver reset. + virtual void restore_registers(); + /// Write the settings from the configuration to the readable registers. + void apply_config_(); + /// Called when DIAG rises without a driver error. + virtual void on_diag_without_error() {} + /// Read GSTAT and DRV_STATUS and report changes. Returns false when the status could not be read. + bool update_status_(); optional current_to_scale_(float current); float full_scale_current_(); int32_t get_vactual_() const { return static_cast(extract_field(this->vactual_reg_, VACTUAL)); } @@ -139,28 +186,36 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U GPIOPin *step_pin_{nullptr}; GPIOPin *dir_pin_{nullptr}; InternalGPIOPin *index_pin_{nullptr}; + InternalGPIOPin *diag_pin_{nullptr}; + LazyCallbackManager status_callback_; 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}; + std::array restored_values_{}; // 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}; + uint16_t status_{0}; optional vsense_{}; optional ottrim_{}; uint8_t address_{0}; uint8_t toff_{3}; uint8_t version_{0}; + uint8_t restored_written_{0}; // Bit per RESTORED_REGISTERS entry that has been written + uint8_t status_poll_skip_{0}; int8_t direction_{0}; + DiagPinStore diag_store_{}; bool analog_current_scale_{false}; bool enabled_{false}; bool vsense_active_{false}; bool step_state_{false}; + bool moving_{false}; HighFrequencyLoopRequester high_freq_; }; diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index ff10c79edcf..e53c3ba8233 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -6,6 +6,7 @@ stepper: step_pin: ${step_pin} dir_pin: ${dir_pin} enn_pin: ${enn_pin} + diag_pin: ${diag_pin} rsense: 110 mOhm vsense: false ottrim: 1 @@ -15,6 +16,17 @@ stepper: max_speed: 2000 steps/s acceleration: 1000 steps/s^2 deceleration: 1000 steps/s^2 + on_stall: + - stepper.report_position: + id: tmc2209_step_dir + position: 0 + - stepper.set_target: + id: tmc2209_step_dir + target: 0 + on_status: + - logger.log: + format: "Driver event %u active %d" + args: [event, active] - platform: tmc2209 id: tmc2209_serial @@ -24,6 +36,12 @@ stepper: clock_frequency: 12MHz analog_current_scale: true max_speed: 1000 steps/s + on_status: + - if: + condition: + lambda: "return event == tmc22xx::DRIVER_EVENT_OVERTEMPERATURE_PREWARNING && active;" + then: + - tmc2209.disable: tmc2209_serial esphome: on_boot: @@ -49,6 +67,17 @@ esphome: id: tmc2209_serial irun: 20 ihold: !lambda "return 5;" + - tmc2209.stallguard: + id: tmc2209_step_dir + threshold: 50 + tcool_threshold: !lambda "return 400;" + - tmc2209.coolconf: + id: tmc2209_step_dir + semin: 5 + semax: 2 + seup: 1 + sedn: 0 + seimin: true - tmc2209.enable: tmc2209_serial - stepper.set_target: id: tmc2209_serial diff --git a/tests/components/tmc2209/test.esp32-idf.yaml b/tests/components/tmc2209/test.esp32-idf.yaml index 5634c186860..18fcfbcaed0 100644 --- a/tests/components/tmc2209/test.esp32-idf.yaml +++ b/tests/components/tmc2209/test.esp32-idf.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO13 enn_pin: GPIO14 index_pin: GPIO15 + diag_pin: GPIO4 packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml diff --git a/tests/components/tmc2209/test.esp8266-ard.yaml b/tests/components/tmc2209/test.esp8266-ard.yaml index 3e4ea33f7a5..fe1c6289af0 100644 --- a/tests/components/tmc2209/test.esp8266-ard.yaml +++ b/tests/components/tmc2209/test.esp8266-ard.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO13 enn_pin: GPIO14 index_pin: GPIO15 + diag_pin: GPIO2 packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml diff --git a/tests/components/tmc2209/test.rp2040-ard.yaml b/tests/components/tmc2209/test.rp2040-ard.yaml index 722f8f201fc..d7ceff9ee1f 100644 --- a/tests/components/tmc2209/test.rp2040-ard.yaml +++ b/tests/components/tmc2209/test.rp2040-ard.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO3 enn_pin: GPIO6 index_pin: GPIO7 + diag_pin: GPIO8 packages: uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml From 3063b76210acbbf4102910dfe6a5d10d5ce24c00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 08:25:36 -1000 Subject: [PATCH 283/651] [haier_tundra] Build the IR frame on the stack (#20635) --- esphome/components/haier_tundra/haier_tundra.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/haier_tundra/haier_tundra.cpp b/esphome/components/haier_tundra/haier_tundra.cpp index 7a0b88d3aac..a3a5e0a8400 100644 --- a/esphome/components/haier_tundra/haier_tundra.cpp +++ b/esphome/components/haier_tundra/haier_tundra.cpp @@ -28,7 +28,7 @@ void HaierTundra::transmit_(uint8_t button) { this->turbo_mode_ = false; } - remote_base::HaierData remote_state{{ + const uint8_t frame[HAIER_TUNDRA_FRAME_SIZE] = { this->unit_, static_cast(this->temperature_() << 4 | this->vertical_swing_()), static_cast(this->horizontal_swing_() << 5), @@ -42,10 +42,10 @@ void HaierTundra::transmit_(uint8_t button) { static_cast((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4), 0x00, button, - }}; + }; auto transmit = this->transmitter_->transmit(); - remote_base::HaierProtocol().encode(transmit.get_data(), remote_state); + remote_base::HaierProtocol().encode(transmit.get_data(), frame, sizeof(frame)); transmit.perform(); } From a82705892800258d590e630fc8b105a00b08283d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:27:50 +1100 Subject: [PATCH 284/651] [lvgl] Change deprecated calls to current version (#20620) --- esphome/components/lvgl/color_picker.h | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/color_picker.h b/esphome/components/lvgl/color_picker.h index e2392694f07..1949ac00de0 100644 --- a/esphome/components/lvgl/color_picker.h +++ b/esphome/components/lvgl/color_picker.h @@ -85,7 +85,7 @@ class LvColorPickerType : public LvCompound { 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); + lv_obj_set_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true); } // The text rows keep their natural height and the bars between them take what is left, @@ -233,7 +233,7 @@ class LvColorPickerType : public LvCompound { 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); + lv_obj_set_flag(obj, LV_OBJ_FLAG_SCROLLABLE, false); return obj; } @@ -326,7 +326,7 @@ class LvColorPickerType : public LvCompound { 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); + lv_obj_set_flag(disc, LV_OBJ_FLAG_SCROLLABLE, false); return disc; } @@ -336,7 +336,7 @@ class LvColorPickerType : public LvCompound { 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); + lv_obj_set_flag(overlay, LV_OBJ_FLAG_CLICKABLE, false); this->wheel_handle_ = lv_obj_create(this->wheel_); lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE); @@ -345,8 +345,8 @@ class LvColorPickerType : public LvCompound { 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); + lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE, false); + lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE, false); // 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); @@ -359,7 +359,7 @@ class LvColorPickerType : public LvCompound { 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); + lv_obj_set_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true); auto *label = lv_label_create(slider); this->values_[index] = label; lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN); @@ -509,7 +509,7 @@ class LvColorPickerType : public LvCompound { 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); + lv_obj_set_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE, true); bubble_events(child); } } From 0821c84b7aea3c9c47aadb16c23aa7bb736a04eb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:35:24 -0400 Subject: [PATCH 285/651] [core] Let a nested repeat shadow the outer iteration variable (#20634) --- esphome/automation.py | 17 +++++++- tests/components/script/common.yaml | 17 ++++++++ tests/unit_tests/test_automation.py | 65 ++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 4a2c10cb52d..32f423d2a34 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -906,6 +906,21 @@ async def while_action_to_code( return var +def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType: + """Rename an inherited `iteration` so the inner repeat's own counter can shadow it.""" + names = {name for _, name in args} + renamed: TemplateArgsType = [] + for type_, name in args: + if name == "iteration": + depth = 1 + while f"iteration_{depth}" in names: + depth += 1 + name = f"iteration_{depth}" + names.add(name) + renamed.append((type_, name)) + return renamed + + @register_action( "repeat", RepeatAction, @@ -929,7 +944,7 @@ async def repeat_action_to_code( actions = await build_action_list( config[CONF_THEN], cg.TemplateArguments(cg.uint32, *template_arg.args), - [(cg.uint32, "iteration"), *args], + [(cg.uint32, "iteration"), *_shadow_iteration(args)], ) for action in actions: cg.add(var.add_then_action(action)) diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index 6b7290277a1..f8396e9648f 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -23,6 +23,10 @@ esphome: then: - logger.log: my_script is running +globals: + - id: nested_total + type: int + script: - id: my_script mode: single @@ -71,3 +75,16 @@ script: count: 5 then: - logger.log: looping! + - repeat: + count: 2 + then: + - repeat: + count: 2 + then: + - delay: 200ms + - lambda: 'ESP_LOGD("main", "inner iteration %u", iteration);' + - globals.set: + id: nested_total + value: !lambda |- + int total = iteration; + return total; diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index fe0b1b02ed1..b56d0a2920e 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -13,9 +13,13 @@ from esphome.automation import ( ApplyCondition, ApplyField, CallbackAutomation, + LambdaAction, + RepeatAction, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + _shadow_iteration, + build_action, build_callback_automation, build_callback_automations, build_parent_callback_automation, @@ -41,7 +45,13 @@ from esphome.automation import ( ) import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN +from esphome.const import ( + CONF_AUTOMATION_ID, + CONF_COUNT, + CONF_ID, + CONF_THEN, + CONF_TYPE_ID, +) 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 @@ -1545,3 +1555,56 @@ async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None assert str(mock_cg.add.call_args.args[0]) == ( f"parent->add_cb(TriggerForwarder{{{NEW_OBJ}}})" ) + + +def test_shadow_iteration_leaves_other_names() -> None: + args = [(cg.std_string, "prefix"), (cg.uint32, "iteration_1")] + assert _shadow_iteration(args) == args + + +def test_shadow_iteration_renames_inherited_counter() -> None: + assert _shadow_iteration([(cg.uint32, "iteration"), (cg.bool_, "x")]) == [ + (cg.uint32, "iteration_1"), + (cg.bool_, "x"), + ] + + +def test_shadow_iteration_picks_first_unused_suffix() -> None: + """A counter keeps its suffix at deeper levels; the new one takes the next free name.""" + args = [ + (cg.uint32, "iteration"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + assert _shadow_iteration(args) == [ + (cg.uint32, "iteration_2"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + + +def _repeat_conf(id_: str, then: list[ConfigType]) -> ConfigType: + return { + "repeat": {CONF_COUNT: 2, CONF_THEN: then}, + CONF_TYPE_ID: ID(id_, is_declaration=True, type=RepeatAction), + } + + +@pytest.mark.asyncio +async def test_nested_repeat_lambda_parameters_are_distinct() -> None: + """Each repeat level adds its own `iteration`; inherited ones are renamed, not duplicated.""" + CORE.config = {} + log = { + "lambda": Lambda('ESP_LOGD("t", "%u", iteration);'), + CONF_TYPE_ID: ID("log_action", is_declaration=True, type=LambdaAction), + } + conf = _repeat_conf( + "outer", [_repeat_conf("middle", [_repeat_conf("inner", [log])])] + ) + await build_action(conf, cg.TemplateArguments(cg.uint8), [(cg.uint8, "level")]) + text = CORE.cpp_main_section + assert ( + "(uint32_t iteration, uint32_t iteration_2, uint32_t iteration_1, uint8_t level)" + in text + ) + assert "uint32_t iteration, uint32_t iteration)" not in text From 417ab4d2e940d54fa2d9b11eaa79946e196df1d9 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Tue, 13 Oct 2026 15:00:45 -0400 Subject: [PATCH 286/651] [openthread] Add core_options for OpenThread compile-time options (#20595) Co-authored-by: Claude Opus 5.5 --- esphome/components/openthread/__init__.py | 68 +++++++ esphome/components/openthread/const.py | 3 + .../openthread/config/custom_header.yaml | 19 ++ .../openthread/config/no_custom_header.yaml | 16 ++ tests/component_tests/openthread/test_init.py | 171 +++++++++++++++++- .../openthread/test.esp32-c6-idf.yaml | 2 + 6 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/openthread/config/custom_header.yaml create mode 100644 tests/component_tests/openthread/config/no_custom_header.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 6fca8bfdd9a..2c278f12f49 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,4 @@ +import re from typing import Any from esphome import automation @@ -44,9 +45,12 @@ from esphome.core import ( coroutine_with_priority, ) import esphome.final_validate as fv +from esphome.helpers import write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_build from .const import ( + CONF_CORE_OPTIONS, CONF_DEVICE_TYPE, CONF_EXT_PAN_ID, CONF_FORCE_DATASET, @@ -59,6 +63,7 @@ from .const import ( CONF_PSKC, CONF_SRP_ID, CONF_TLV, + OPENTHREAD_CONFIG_PREFIX, ) CODEOWNERS = ["@mrene"] @@ -84,6 +89,10 @@ CONF_DEVICE_TYPES = [ "MTD", ] +OT_HEADER_DIR = "openthread" +OT_HEADER_FILE = "esphome_ot_config.h" +OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+") + def _validate_txpower(value: Any) -> int | float: if CORE.is_esp32: @@ -158,6 +167,31 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) +def _write_custom_header(config: ConfigType) -> None: + """Write core_options to the OpenThread custom header.""" + if not (options := config.get(CONF_CORE_OPTIONS)): + return + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_HEADER_CUSTOM", True) + # IDF resolves the path relative to the project (build) directory. + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH", OT_HEADER_DIR) + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME", OT_HEADER_FILE + ) + content = ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + + "".join( + f"#define {name} {value}\n" for name, value in sorted(options.items()) + ) + ) + # PlatformIO does not track this header as an OpenThread input; the esp-idf + # toolchain does. + header_path = CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE) + if write_file_if_changed(header_path, content) and not CORE.using_toolchain_esp_idf: + clean_build(clear_pio_cache=False) + + openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) @@ -220,6 +254,34 @@ def _validate_platform(config: ConfigType) -> ConfigType: )(config) +def _validate_core_option_names(options: dict[str, str]) -> dict[str, str]: + for name in options: + if ( + not name.startswith(OPENTHREAD_CONFIG_PREFIX) + or name == OPENTHREAD_CONFIG_PREFIX + ): + raise cv.Invalid( + f"'{name}' is not an OpenThread core option; names must be " + f"{OPENTHREAD_CONFIG_PREFIX} followed by the option name", + path=[name], + ) + if not OT_CORE_OPTION_NAME.fullmatch(name): + raise cv.Invalid(f"'{name}' may only contain A-Z, 0-9 and _", path=[name]) + return options + + +def _validate_core_option_value(value: Any) -> str: + value = cv.string_strict(value) + if not value.strip(): + raise cv.Invalid("OpenThread core option value must not be empty") + if "\n" in value or "\r" in value: + raise cv.Invalid("OpenThread core option value must be a single line") + # GCC treats a backslash followed by spaces as a line continuation too. + if value.rstrip().endswith("\\"): + raise cv.Invalid("OpenThread core option value must not end with \\") + return value + + def _validate_tlv_hex(value: Any) -> str: s = cv.string_strict(value) if len(s) % 2 != 0: @@ -252,6 +314,11 @@ CONFIG_SCHEMA = cv.All( _validate_txpower, ), cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CORE_OPTIONS): cv.All( + cv.only_on_esp32, + {cv.string_strict: _validate_core_option_value}, + _validate_core_option_names, + ), } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -325,6 +392,7 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32: set_sdkconfig_options(config) + _write_custom_header(config) elif CORE.using_zephyr: # Crypto through PSA: mbedtls, plus Oberon from SDK 2.7 include_west_project("mbedtls") diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py index f0274a8c9e5..bd9c8cfa1c7 100644 --- a/esphome/components/openthread/const.py +++ b/esphome/components/openthread/const.py @@ -1,3 +1,4 @@ +CONF_CORE_OPTIONS = "core_options" CONF_DEVICE_TYPE = "device_type" CONF_EXT_PAN_ID = "ext_pan_id" CONF_FORCE_DATASET = "force_dataset" @@ -10,3 +11,5 @@ CONF_POLL_PERIOD = "poll_period" CONF_PSKC = "pskc" CONF_SRP_ID = "srp_id" CONF_TLV = "tlv" + +OPENTHREAD_CONFIG_PREFIX = "OPENTHREAD_CONFIG_" diff --git a/tests/component_tests/openthread/config/custom_header.yaml b/tests/component_tests/openthread/config/custom_header.yaml new file mode 100644 index 00000000000..767497e9b2f --- /dev/null +++ b/tests/component_tests/openthread/config/custom_header.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + core_options: + OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE: "1" + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" diff --git a/tests/component_tests/openthread/config/no_custom_header.yaml b/tests/component_tests/openthread/config/no_custom_header.yaml new file mode 100644 index 00000000000..df0634e98d7 --- /dev/null +++ b/tests/component_tests/openthread/config/no_custom_header.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py index 7ba9bdd4bba..c0e155ec7e3 100644 --- a/tests/component_tests/openthread/test_init.py +++ b/tests/component_tests/openthread/test_init.py @@ -1,11 +1,22 @@ -"""Tests for the openthread component schema.""" +"""Tests for the openthread component.""" + +from collections.abc import Callable +from pathlib import Path +from unittest.mock import MagicMock, patch 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.components.esp32 import ( + KEY_ESP32, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, + VARIANT_ESP32C6, +) +from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE +from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX +import esphome.config_validation as cv +from esphome.const import PlatformFramework, Toolchain +from esphome.core import CORE, HexInt from esphome.yaml_util import Sensitive from tests.component_tests.types import SetCoreConfigCallable @@ -37,3 +48,153 @@ def test_credentials_are_sensitive( assert isinstance(validated[field], Sensitive) assert validated[field] == expected assert isinstance(validated[field], HexInt) == isinstance(expected, int) + + +def _generate_in( + tmp_path: Path, yaml_text: str, generate_main: Callable[[str | Path], str] +) -> tuple[Path, MagicMock]: + """Generate from ``yaml_text`` written to tmp_path, so the build directory lands there. + + Returns the header path and the mocked ``clean_build``. + """ + config_path = tmp_path / "test.yaml" + config_path.write_text(yaml_text, encoding="utf-8") + with patch("esphome.components.openthread.clean_build") as clean_build: + generate_main(config_path) + return CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE), clean_build + + +def test_core_options_written_to_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, _ = _generate_in( + tmp_path, + component_config_path("custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_OPENTHREAD_HEADER_CUSTOM"] is True + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH"] == OT_HEADER_DIR + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME"] == OT_HEADER_FILE + assert header.read_text(encoding="utf-8") == ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + "#define OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE 1\n" + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 5\n" + ) + + +def test_no_header_options_no_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, clean_build = _generate_in( + tmp_path, + component_config_path("no_custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_OPENTHREAD_HEADER_CUSTOM" not in sdkconfig + assert not header.exists() + clean_build.assert_not_called() + + +@pytest.mark.parametrize( + ("toolchain", "cleans"), + [(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)], +) +def test_header_change_cleans_only_on_platformio( + toolchain: Toolchain, + cleans: bool, + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = component_config_path("custom_header.yaml").read_text(encoding="utf-8") + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + assert clean_build.called is cleans + # Unchanged content: no clean on either toolchain. + CORE.reset() + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + clean_build.assert_not_called() + # A changed value leaves sdkconfig as it was, so only this clean rebuilds OpenThread. + changed = source.replace('RETRIES_DIRECT: "5"', 'RETRIES_DIRECT: "7"') + assert changed != source + CORE.reset() + CORE.toolchain = toolchain + header, clean_build = _generate_in(tmp_path, changed, generate_main) + assert clean_build.called is cleans + assert ( + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 7\n" + in header.read_text(encoding="utf-8") + ) + + +@pytest.mark.parametrize( + "name", + [ + "CONFIG_OPENTHREAD_JOINER", + "openthread_config_x", + "OT_CONFIG_X", + OPENTHREAD_CONFIG_PREFIX, + ], +) +def test_core_option_without_prefix_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=OPENTHREAD_CONFIG_PREFIX): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + "name", + ["OPENTHREAD_CONFIG_a", "OPENTHREAD_CONFIG_A B", "OPENTHREAD_CONFIG_A-B"], +) +def test_core_option_bad_characters_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match="may only contain A-Z, 0-9 and _"): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ("", "must not be empty"), + (" ", "must not be empty"), + ("1\n#define X 2", "must be a single line"), + ("1\r2", "must be a single line"), + ("1 \\", "must not end with"), + ("1 \\ ", "must not end with"), + ], +) +def test_core_option_bad_value_rejected( + value: str, message: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=message): + CONFIG_SCHEMA( + { + "network_key": hex(NETWORK_KEY), + "core_options": {"OPENTHREAD_CONFIG_X": value}, + } + ) + + +def test_core_options_only_on_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="ESP32"): + CONFIG_SCHEMA({"tlv": TLV, "core_options": {"OPENTHREAD_CONFIG_X": "1"}}) diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 92d120e5d19..746b7151f30 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -9,3 +9,5 @@ openthread: pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 mesh_local_prefix: fd53:145f:ed22:ad81::/64 output_power: 1dBm + core_options: + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" From 918b7d255357a814327b8b7052d4cb2a3c3a9437 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 21:31:19 +0200 Subject: [PATCH 287/651] [script] Keep the running script.wait item out of the queue while its chain runs (#20570) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/script/script.h | 10 ++- .../script/test_script_wait_action.cpp | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index f00ad7f4afa..efd75d27e0e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -295,8 +295,11 @@ template class ScriptWaitAction final : public Actionnum_running_ == 0) + if (this->num_running_ == 0) { + // Nothing left to wait for, e.g. once the last queued item has run + this->disable_loop(); return; + } if (this->script_->is_running()) return; @@ -305,9 +308,10 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { - auto ¶ms = this->param_queue_.front(); - std::apply([this](auto &...args) { this->play_next_(args...); }, params); + // Take the item out first: the chain may call stop(), which clears the queue + auto params = std::move(this->param_queue_.front()); this->param_queue_.pop_front(); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); } else { // Queue is now empty - disable loop until next play_complex this->disable_loop(); diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp index 17f553996ea..c3cd55fc8ca 100644 --- a/tests/components/script/test_script_wait_action.cpp +++ b/tests/components/script/test_script_wait_action.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/components/script/script.h" @@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { EXPECT_EQ(entity.value, 1); } +class StopWaitAction : public Action { + public: + void play(const std::string &value) override { + this->wait->stop_complex(); + this->seen = value; + } + Action *wait{nullptr}; + std::string seen; +}; + +// The chain after the wait may stop it, e.g. a script with mode: restart +TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + StopWaitAction stop; + stop.wait = &wait; + ActionList actions; + actions.add_actions({&wait, &stop}); + + // With a second item waiting, stop() clears the queue while the first item's chain runs + actions.play(std::string("argument longer than the small string buffer")); + actions.play(std::string("second argument, also longer than the buffer")); + script.running = false; + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); +} + +class RestartAction : public Action { + public: + void play(const std::string &value) override { + this->seen.push_back(value); + if (this->seen.size() > 1) + return; + // What a script with mode: restart does: stop, then run again while the awaited script is busy + this->wait->stop_complex(); + this->script->running = true; + this->actions->play(std::string("restarted, longer than the small string buffer")); + } + Action *wait{nullptr}; + FakeScript *script{nullptr}; + ActionList *actions{nullptr}; + std::vector seen; +}; + +// The old pop_front() after the chain removed the item queued by the restart +TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) { + FakeScript script; + ScriptWaitAction wait(&script); + RestartAction restart; + ActionList actions; + actions.add_actions({&wait, &restart}); + restart.wait = &wait; + restart.script = &script; + restart.actions = &actions; + + // With a second item waiting, the stop really clears the queue; the restart then queues a new item + actions.play(std::string("first argument, longer than the small string buffer")); + actions.play(std::string("second argument, dropped by the stop")); + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 1u); + + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 2u); + EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer"); +} + +TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + actions.play(std::string("argument")); + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument"); + wait.loop(); + EXPECT_TRUE(wait.is_idle()); +} + } // namespace esphome::script::testing From 05bf7d34408e7358411c5bd1a5e24717093d7bf5 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 13 Oct 2026 22:40:56 +0200 Subject: [PATCH 288/651] [epaper_spi] Write pixel blocks straight into mono buffers (#20552) --- .../components/display/display_color_utils.h | 52 +++++ esphome/components/epaper_spi/colorconv.h | 19 +- esphome/components/epaper_spi/epaper_spi.cpp | 114 +++++++++++ esphome/components/epaper_spi/epaper_spi.h | 6 + .../components/epaper_spi/epaper_spi_mono.h | 1 + .../epaper_spi/epaper_spi_ssd1677.cpp | 4 +- .../epaper_spi/epaper_spi_ssd1677.h | 3 - .../epaper_spi/epaper_spi_ssd1677_gray4.h | 2 +- .../components/epaper_spi/epaper_spi_uc8179.h | 1 + esphome/components/it8951/it8951.cpp | 10 +- .../epaper_spi/display/test_draw_pixels.cpp | 193 ++++++++++++++++++ 11 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_draw_pixels.cpp diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index 608642caa44..be39e2a3e2f 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -1,4 +1,6 @@ #pragma once +#include +#include #include "esphome/core/color.h" namespace esphome::display { @@ -154,4 +156,54 @@ class ColorUtil { return color; } }; + +/** Rec.601 luma weights (0.299/0.587/0.114), scaled by 256 */ +static constexpr uint16_t REC601_LUMA_R = 77; +static constexpr uint16_t REC601_LUMA_G = 150; +static constexpr uint16_t REC601_LUMA_B = 29; + +/** Rec.601 luma of an RGB color, for optimum perceptual brightness */ +constexpr uint8_t rec601_luma(Color color) { + return (uint8_t) ((REC601_LUMA_R * color.r + REC601_LUMA_G * color.g + REC601_LUMA_B * color.b + 128u) >> 8); +} + +/** Map an RGB color to a single monochrome bit: 1 = white, 0 = black */ +constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } + +/** color_to_mono() of an RGB565 value as ColorUtil::to_color() reads it, from table lookups instead of a division + * per channel. + */ +class Rgb565ToMono { + public: + explicit Rgb565ToMono(ColorOrder order) { + // Rec.601 weight of the 5-bit, 6-bit and 5-bit fields, by the channel each one holds in this order + uint16_t high = REC601_LUMA_R, middle = REC601_LUMA_G, low = REC601_LUMA_B; + if (order == COLOR_ORDER_BGR) { + high = REC601_LUMA_B; + low = REC601_LUMA_R; + } else if (order == COLOR_ORDER_GRB) { + high = REC601_LUMA_G; + middle = REC601_LUMA_R; + } + // Each field value scaled to 0..255, then weighted + for (unsigned i = 0; i != 32; i++) { + this->high_[i] = high * esp_scale(i, 31); + this->low_[i] = low * esp_scale(i, 31); + } + for (unsigned i = 0; i != 64; i++) + this->middle_[i] = middle * esp_scale(i, 63); + } + + uint8_t operator()(uint16_t value) const { + const uint32_t weighted = this->high_[value >> 11] + this->middle_[(value >> 5) & 0x3F] + this->low_[value & 0x1F]; + return ((weighted + 128u) >> 8) >= 128 ? 1 : 0; + } + + protected: + // Weighted contribution of each value of each field + std::array high_{}; + std::array middle_{}; + std::array low_{}; +}; + } // namespace esphome::display diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index 0240aa5cccf..cb59538abab 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -2,6 +2,7 @@ #include #include +#include "esphome/components/display/display_color_utils.h" #include "esphome/core/color.h" /* Utility for converting internal \a Color RGB representation to supported IC hardware color keys @@ -13,21 +14,15 @@ namespace esphome::epaper_spi { +// The Rec.601 luma helpers and the RGB565 mono lookup live in the shared display utilities, so other +// drivers (e.g. it8951 in its 1 bit per pixel mode) can use them too. +using display::color_to_mono; +using display::rec601_luma; +using display::Rgb565ToMono; + /** Delta for when to regard as gray */ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; -/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */ -constexpr uint8_t rec601_luma(Color color) { - return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); -} - -/** Map RGB color to a single monochrome bit - * - * @param color RGB color to convert from - * @return 1 = white, 0 = black - */ -constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } - /** Map RGB color to one of 4 discrete gray levels (2 bits per pixel) * * @param color RGB color to convert from diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 0c1d9b0fafb..2a2f74763f0 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -345,6 +345,120 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { } } +namespace { +// One source row of an RGB565 block, written into a contiguous 1 bit per pixel buffer. Without SwapAxes the row lies +// along a buffer row and the bit moves by step; with it the row lies along a bit column and the byte moves by step +// rows. +template +void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_width, const uint8_t *source, int count, + const Rgb565ToMono &to_mono) { + const ptrdiff_t byte_step = SwapAxes ? step * static_cast(row_width) : 0; + for (; count != 0; count--, source += 2) { + const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8); + if (to_mono(value)) { + *byte |= mask; + } else { + *byte &= ~mask; + } + if constexpr (SwapAxes) { + byte += byte_step; + } else if (step > 0) { + mask >>= 1; + if (mask == 0) { + mask = 0x80; + byte++; + } + } else { + mask <<= 1; + if (mask == 0) { + mask = 0x01; + byte--; + } + } + } +} +} // namespace + +// Each source column maps to one buffer coordinate and each source row to the other, each moving by 1 per pixel, so +// the visible part of a block and its dirty area are worked out once for the whole block. +void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, + ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad) { + const bool swap = this->effective_transform_ & SWAP_XY; + const bool mirror_x = this->effective_transform_ & MIRROR_X; + const bool mirror_y = this->effective_transform_ & MIRROR_Y; + // The buffer axis a source column moves along, and the one a source row moves along + const int column_limit = swap ? this->height_ : this->width_; + const int row_limit = swap ? this->width_ : this->height_; + const bool column_mirrored = swap ? mirror_y : mirror_x; + const bool row_mirrored = swap ? mirror_x : mirror_y; + const int first_column = std::max(0, -x_start); + const int end_column = std::min(w, column_limit - x_start); + const int first_row = std::max(0, -y_start); + const int end_row = std::min(h, row_limit - y_start); + if (first_column >= end_column || first_row >= end_row) + return; + auto column_to_buffer = [&](int column) { + const int u = x_start + column; + return column_mirrored ? column_limit - 1 - u : u; + }; + auto row_to_buffer = [&](int row) { + const int v = y_start + row; + return row_mirrored ? row_limit - 1 - v : v; + }; + + const int column_low = std::min(column_to_buffer(first_column), column_to_buffer(end_column - 1)); + const int row_low = std::min(row_to_buffer(first_row), row_to_buffer(end_row - 1)); + const int columns = end_column - first_column; + const int rows = end_row - first_row; + const int x_low = swap ? row_low : column_low; + const int y_low = swap ? column_low : row_low; + const int x_high = x_low + (swap ? rows : columns); + const int y_high = y_low + (swap ? columns : rows); + + const Rgb565ToMono to_mono(order); + const size_t line_stride = x_offset + w + x_pad; + const size_t row_width = this->row_width_; + const int step = column_mirrored ? -1 : 1; + for (int row = first_row; row != end_row; row++) { + const uint8_t *source = ptr + ((y_offset + row) * line_stride + x_offset + first_column) * 2; + const int along = column_to_buffer(first_column); + const int across = row_to_buffer(row); + const int x = swap ? across : along; + const int y = swap ? along : across; + uint8_t *byte = buffer + y * row_width + x / 8; + const uint8_t mask = 0x80u >> (x % 8); + if (swap) { + if (big_endian) { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } else { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } + } else if (big_endian) { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } else { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } + } + this->x_low_ = clamp_at_most(this->x_low_, x_low); + this->x_high_ = clamp_at_least(this->x_high_, x_high); + this->y_low_ = clamp_at_most(this->y_low_, y_low); + this->y_high_ = clamp_at_least(this->y_high_, y_high); +} + +// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the +// usual case): the same result as draw_pixel_at() for each pixel. Anything else goes through draw_pixel_at(). +void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + size_t contiguous_length; + uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length); + if (!this->buffer_is_mono() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || + contiguous_length != this->buffer_length_) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, x_pad); +} + void EPaperBase::dump_config() { LOG_DISPLAY("", "E-Paper SPI", this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 96755a404ec..c7ef90f1160 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -107,6 +107,8 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } void draw_pixel_at(int x, int y, Color color) override; + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; // Make the next update a full one. Applied when that update starts, so one in progress is not affected. void request_full_update() { this->full_update_requested_ = true; } // True from the start of an update until the display has refreshed and gone back to sleep. @@ -128,6 +130,10 @@ class EPaperBase : public Display, bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); + void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + bool big_endian, int x_offset, int y_offset, int x_pad); + // Whether the buffer holds 1 bit per pixel as draw_pixel_at() writes it, so draw_pixels_at() can write it directly + virtual bool buffer_is_mono() const { return false; } /** * Methods that must be implemented by concrete classes to control the display diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index d0740595fc5..0f570149de4 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -15,6 +15,7 @@ class EPaperMono : public EPaperBase { } protected: + bool buffer_is_mono() const override { return true; } void refresh_screen(bool partial) override; void power_on() override {} void power_off() override{}; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp index a9245fa1c2b..a79bbc3dc91 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -58,7 +58,9 @@ bool HOT EPaperSSD1677::transfer_data() { // A full update ignores 0x26, and the copy may not hold a real frame yet (first update after // boot): send the new frame to both planes. const bool send_copy = this->plane_ == 0 && this->update_count_ != 0; - const bool direct = send_copy || this->buffer_is_plane(); + // A mono buffer already holds the frame as a RAM plane does, so it can be sent as it is; a deeper + // buffer (the four-level model) returns false and packs the plane in plane_row(). + const bool direct = send_copy || this->buffer_is_mono(); const auto &source = send_copy ? this->sent_ : this->buffer_; const size_t row_length = this->plane_row_length_(); const size_t plane_length = row_length * this->height_; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h index ddf3d4e0892..b8c93a1ecb8 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -34,9 +34,6 @@ class EPaperSSD1677 : public EPaperMono { void init_comparison_frame_(); // Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds. size_t plane_row_length_() const { return (this->width_ + 7) / 8; } - // Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is. - // A subclass with a deeper buffer returns false and overrides plane_row(). - virtual bool buffer_is_plane() const { return true; } // Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white. virtual void plane_row(size_t y, uint8_t *out); bool reset() override; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h index 47436d2db1d..f21ab3eb069 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -38,9 +38,9 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 { void draw_pixel_at(int x, int y, Color color) override; protected: + bool buffer_is_mono() const override { return false; } // A partial update if partial updates are enabled and this is not a full one. bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); } - bool buffer_is_plane() const override { return false; } void plane_row(size_t y, uint8_t *out) override; void refresh_screen(bool partial) override; bool transfer_data() override; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index 85c0eb623ee..b12ed54ef6d 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -37,6 +37,7 @@ class EPaperUC8179 final : public EPaperBase { } protected: + bool buffer_is_mono() const override { return true; } bool initialise(bool partial) override; bool transfer_data() override; void refresh_screen(bool partial) override; diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index fe99017774d..565f6f7f8bb 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -891,12 +891,10 @@ static uint8_t color_to_nibble(const Color &color) { if (color.raw_32 == 0xFFFFFFFF) return 0x0F; // white - // Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by - // 256). Rec.601 is the standard for converting SDR images to grayscale and - // spreads saturated colours across the mid-range; Rec.709 instead crams them - // against white/black where the 16 panel levels are hard to tell apart. - auto luma = static_cast((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); - return quantize_8bit_to_nibble(luma); + // Rec.601 luma is the standard for converting SDR images to grayscale and spreads saturated colours + // across the mid-range; Rec.709 instead crams them against white/black where the 16 panel levels are + // hard to tell apart. + return quantize_8bit_to_nibble(display::rec601_luma(color)); } // 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535). diff --git a/tests/components/epaper_spi/display/test_draw_pixels.cpp b/tests/components/epaper_spi/display/test_draw_pixels.cpp new file mode 100644 index 00000000000..a3b47ea8fc1 --- /dev/null +++ b/tests/components/epaper_spi/display/test_draw_pixels.cpp @@ -0,0 +1,193 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/colorconv.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" + +namespace esphome::epaper_spi::testing { + +/// Exposes the buffer and the dirty bounds so the fast path can be compared with drawing pixel by pixel. +template class TestableDisplay : public Driver { + public: + TestableDisplay(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) { + EXPECT_TRUE(this->init_buffer_(this->buffer_length_)); + this->reset_bounds(); + } + + void reset_bounds() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + + std::vector buffer() const { + std::vector out; + for (size_t i = 0; i != this->buffer_length_; i++) + out.push_back(this->buffer_[i]); + return out; + } + + /// Reallocates the buffer in blocks of at most max_block bytes, as SplitBuffer does when memory is fragmented. + void split_buffer(size_t max_block) { + ASSERT_TRUE(this->buffer_.init(this->buffer_length_, max_block)); + ASSERT_GT(this->buffer_.get_buffer_count(), 1u); + this->clear(); + this->reset_bounds(); + } + + std::vector bounds() const { return {this->x_low_, this->y_low_, this->x_high_, this->y_high_}; } + + void draw_one_by_one(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // The per-pixel path the fast one must match + display::Display::draw_pixels_at( // NOLINT(bugprone-parent-virtual-call) + x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + } +}; + +/// RGB565 data with a spread of light and dark pixels, including values either side of the threshold. +static std::vector rgb565(size_t pixels, uint32_t seed) { + std::vector out; + uint32_t state = seed; + for (size_t i = 0; i != pixels; i++) { + state = state * 1103515245u + 12345u; + const uint16_t pixel = static_cast(state >> 8); + out.push_back(pixel & 0xFF); + out.push_back(pixel >> 8); + } + return out; +} + +/// Random bytes for a block of pixels, enough for 3 bytes a pixel. +static std::vector random_bytes(size_t pixels, uint32_t seed) { + std::vector out; + uint32_t state = seed; + for (size_t i = 0; i != pixels * 3; i++) { + state = state * 1103515245u + 12345u; + out.push_back(static_cast(state >> 16)); + } + return out; +} + +/// For every rotation, mirroring and byte order, with source offsets and padding, and with blocks inside the panel, +/// running off each edge and larger than the panel, drawing an RGB565 block directly gives the same buffer and the same +/// dirty area as drawing it pixel by pixel. The panel width is not a multiple of 8. +TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) { + struct Block { + int x, y, w, h; + }; + const Block blocks[] = {{6, 5, 12, 6}, {-3, -2, 10, 8}, {14, 8, 12, 9}, {-5, 4, 30, 3}, {-2, -3, 26, 20}}; + const display::DisplayRotation rotations[] = { + display::DISPLAY_ROTATION_0_DEGREES, display::DISPLAY_ROTATION_90_DEGREES, display::DISPLAY_ROTATION_180_DEGREES, + display::DISPLAY_ROTATION_270_DEGREES}; + // Swapping the axes comes from the 90 and 270 degree rotations + const uint8_t mirrorings[] = {NONE, MIRROR_X, MIRROR_Y, MIRROR_X | MIRROR_Y}; + uint32_t seed = 1; + for (const auto rotation : rotations) { + for (const uint8_t mirroring : mirrorings) { + for (const bool big_endian : {false, true}) { + for (const auto &block : blocks) { + TestableDisplay fast(21, 13); + TestableDisplay slow(21, 13); + for (auto *display : {&fast, &slow}) { + display->set_transform(mirroring); + display->set_rotation(rotation); + } + // a 3 pixel left offset, 2 rows skipped and 4 pixels of padding + const auto data = random_bytes((3 + block.w + 4) * (2 + block.h), seed++); + fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + const auto where = ::testing::Message() << "rotation " << rotation << " mirroring " << int(mirroring) + << " big endian " << big_endian << " block " << block.x << "," + << block.y << " " << block.w << "x" << block.h; + EXPECT_EQ(fast.buffer(), slow.buffer()) << where; + EXPECT_EQ(fast.bounds(), slow.bounds()) << where; + } + } + } + } +} + +/// The colour order reaches the RGB565 block writer. +TEST(EPaperDrawPixels, ColourOrderIsHonoured) { + for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { + TestableDisplay fast(21, 13); + TestableDisplay slow(21, 13); + const auto data = random_bytes(21 * 13, 99); + fast.draw_pixels_at(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "order " << int(order); + } +} + +/// With a clipping region set the block goes through draw_pixel_at(), which honours it. +TEST(EPaperDrawPixels, ClippingFallsBackToDrawingPixelByPixel) { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + const auto data = rgb565(24 * 16, 7); + for (auto *display : {&fast, &slow}) + display->start_clipping(4, 4, 12, 10); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()); + EXPECT_EQ(fast.bounds(), (std::vector{4, 4, 12, 10})); +} + +/// A driver whose buffer is not 1 bit per pixel keeps its own draw_pixel_at() for blocks too. +TEST(EPaperDrawPixels, OtherBufferLayoutsDrawPixelByPixel) { + TestableDisplay fast(16, 4); + TestableDisplay slow(16, 4); + const auto data = rgb565(16 * 4, 11); + fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()); +} + +/// The RGB565 lookup gives exactly what converting the colour and thresholding it does, for every value and order. +TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) { + for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { + const Rgb565ToMono to_mono(order); + size_t mismatches = 0; + for (uint32_t value = 0; value != 0x10000; value++) { + const uint8_t expected = color_to_mono(ColorUtil::to_color(value, order, display::COLOR_BITNESS_565)); + if (to_mono(static_cast(value)) != expected) + mismatches++; + } + EXPECT_EQ(mismatches, 0u) << "order " << int(order); + } +} + +/// Other bit depths, and a buffer split into several blocks, go through draw_pixel_at() and give the same result. +TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) { + { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + fast.split_buffer(7); + slow.split_buffer(7); + const auto data = rgb565(24 * 16, 5); + fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "split buffer"; + EXPECT_EQ(fast.bounds(), slow.bounds()) << "split buffer"; + } + for (const auto bitness : {display::COLOR_BITNESS_888, display::COLOR_BITNESS_332}) { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + const auto data = random_bytes(24 * 16, 9); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "bitness " << int(bitness); + EXPECT_EQ(fast.bounds(), slow.bounds()) << "bitness " << int(bitness); + } +} + +} // namespace esphome::epaper_spi::testing From 8721976066be7f191ae6b481ee812d3558adb7b0 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 13 Oct 2026 22:47:09 +0200 Subject: [PATCH 289/651] [epaper_spi] Abandon an update when the busy pin never clears (#20555) --- esphome/components/epaper_spi/epaper_spi.cpp | 21 ++- esphome/components/epaper_spi/epaper_spi.h | 9 +- .../epaper_spi/epaper_spi_t133a01.cpp | 9 ++ .../epaper_spi/epaper_spi_t133a01.h | 1 + .../epaper_spi/display/test_busy_timeout.cpp | 145 ++++++++++++++++++ 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_busy_timeout.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 2a2f74763f0..7b962d59ea8 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -131,9 +131,7 @@ void EPaperBase::update() { } void EPaperBase::wait_for_idle_(bool should_wait) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE this->waiting_for_idle_start_ = millis(); -#endif this->waiting_for_idle_ = should_wait; } @@ -156,6 +154,10 @@ void EPaperBase::loop() { ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); #endif } else { + if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) { + this->abandon_update_(); + return; + } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_()); @@ -168,6 +170,20 @@ void EPaperBase::loop() { this->process_state_(); } +// The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full +void EPaperBase::abandon_update_() { + ESP_LOGE(TAG, "Display stayed busy for %u s in state %s; check the busy pin", (unsigned) this->busy_timeout_seconds_, + this->epaper_state_to_string_()); + this->status_set_warning(LOG_STR("Busy pin timeout")); + if (this->state_ == EPaperState::TRANSFER_DATA) + this->abort_transfer(); + // Leave the panel powered down rather than with its high-voltage drivers enabled + this->power_off(); + this->full_update_requested_ = true; + this->current_data_index_ = 0; + this->set_state_(EPaperState::IDLE); +} + /** * Process the state machine. * Typical state sequence: @@ -242,6 +258,7 @@ void EPaperBase::process_state_() { break; case EPaperState::DEEP_SLEEP: this->deep_sleep(); + this->status_clear_warning(); this->set_state_(EPaperState::IDLE); ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_); break; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index c7ef90f1160..7f1167ae6a4 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -127,6 +127,7 @@ class EPaperBase : public Display, virtual bool initialise(bool partial); void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); + void abandon_update_(); bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); @@ -143,6 +144,8 @@ class EPaperBase : public Display, * @return true if done, false if it should be called next loop */ virtual bool transfer_data() = 0; + // Called when an update is abandoned in the middle of transfer_data(), to release anything it left open + virtual void abort_transfer() {} /** * Refresh the screen after data transfer */ @@ -193,18 +196,20 @@ class EPaperBase : public Display, // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; + uint32_t waiting_for_idle_start_{}; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE uint32_t waiting_for_idle_last_print_{}; - uint32_t waiting_for_idle_start_{}; #endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t update_start_time_{}; #endif // properties with specific initialisers go last - EPaperState state_{EPaperState::IDLE}; uint32_t reset_duration_{10}; + // Longer than any panel takes to refresh; past it the busy pin is assumed stuck + uint16_t busy_timeout_seconds_{120}; uint8_t full_update_every_{1}; + EPaperState state_{EPaperState::IDLE}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp index bd5a4c9b6e2..585b21f807a 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -364,6 +364,15 @@ bool HOT EPaperT133A01::transfer_data() { return true; } +void EPaperT133A01::abort_transfer() { + // A data stream is open, with one of the chip selects held low, whenever transfer_data() has started sending rows + if (this->current_data_index_ == 0) + return; + this->disable(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->digital_write(true); +} + void EPaperT133A01::dump_config() { EPaperBase::dump_config(); LOG_PIN(" CS Pin: ", this->cs_pin_); diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h index 0d07fc03ae8..5d356f90b01 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.h +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -46,6 +46,7 @@ class EPaperT133A01 : public EPaperBase { void deep_sleep() override; bool transfer_data() override; + void abort_transfer() override; /** * Send a command (and optional data) selecting one or both controllers. diff --git a/tests/components/epaper_spi/display/test_busy_timeout.cpp b/tests/components/epaper_spi/display/test_busy_timeout.cpp new file mode 100644 index 00000000000..7080619b2ff --- /dev/null +++ b/tests/components/epaper_spi/display/test_busy_timeout.cpp @@ -0,0 +1,145 @@ +#include + +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { + +/// A busy pin the test can hold at "busy" and release. +class SwitchableBusyPin : public RecordingPin { + public: + bool digital_read() override { return this->busy; } + + bool busy{false}; +}; + +template void loop_until_idle(T &display) { + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::IDLE && millis() < deadline) + display.loop(); +} + +class TestableSSD1683 : public EPaperSSD1683 { + public: + TestableSSD1683() : EPaperSSD1683("test", 16, 2, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_busy_pin(&this->busy); + this->set_full_update_every(5); + this->set_auto_clear(false); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait + } + + void draw_whole_panel() { + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + EPaperState state() const { return this->state_; } + uint8_t update_count() const { return this->update_count_; } + void set_update_count(uint8_t count) { this->update_count_ = count; } + + RecordingPin dc; + SwitchableBusyPin busy; +}; + +/// If the busy pin never clears, the update is abandoned instead of waiting for ever: the display goes back to +/// idle with a warning. Once the pin works again the next update is accepted and is a full one, even though nothing +/// was drawn since, and completing it clears the warning. +TEST(EPaperBusyTimeout, StuckBusyPinAbandonsTheUpdateAndRecovers) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_update_count(3); + + // Stick the pin after the data has been sent, when the dirty area has already been emptied + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (display.state() <= EPaperState::TRANSFER_DATA && millis() < deadline) + display.loop(); + ASSERT_EQ(display.state(), EPaperState::POWER_ON); + display.busy.busy = true; + loop_until_idle(display); + + EXPECT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + EXPECT_TRUE(display.status_has_warning()); + + display.busy.busy = false; + display.update(); + EXPECT_NE(display.state(), EPaperState::IDLE) << "a later update was refused"; + loop_until_idle(display); + + EXPECT_EQ(display.state(), EPaperState::IDLE) << "the later update did not complete"; + EXPECT_EQ(display.update_count(), 1) << "the later update was not a full one"; + EXPECT_FALSE(display.status_has_warning()); +} + +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + this->set_busy_pin(&this->busy); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait + } + + void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); } + EPaperState state() const { return this->state_; } + + RecordingPin dc, cs, cs1; + SwitchableBusyPin busy; +}; + +/// The T133A01 waits for the busy pin with a data stream open. Abandoning the update there must close the SPI +/// transaction and release both chip selects, or the next command would block. +TEST(EPaperBusyTimeout, AbandonedT133A01TransferReleasesTheBus) { + TestableT133A01 display; + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + display.start_transfer(); + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + ASSERT_FALSE(display.cs.level) << "the transfer should have yielded with its data stream open"; + + display.busy.busy = true; + loop_until_idle(display); + + ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + EXPECT_EQ(delegate.begin_count, delegate.end_count) << "SPI transaction left open"; + EXPECT_TRUE(display.cs.level) << "CS left asserted"; + EXPECT_TRUE(display.cs1.level) << "CS1 left asserted"; +} + +/// Abandoning an update powers the panel off, so it is not left with its high-voltage drivers enabled. +TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) { + TestableT133A01 display; + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 5); // burn time so the transfer yields mid-stream + display.install(&bus); + + display.start_transfer(); + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + + display.busy.busy = true; + loop_until_idle(display); + ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + + EXPECT_NE(std::find(bus.commands.begin(), bus.commands.end(), 0x02), bus.commands.end()) + << "the panel was not powered off (POF) when the update was abandoned"; +} + +} // namespace esphome::epaper_spi::testing From f941fee5edb459fe17f1159800ee6294c4b4d34b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 11:10:14 -1000 Subject: [PATCH 290/651] [homeassistant] Add sensor platform benchmarks (#20637) --- .../bench_homeassistant_sensor.cpp | 196 ++++++++++++++++++ .../components/homeassistant/benchmark.yaml | 3 + .../homeassistant/sensor/__init__.py | 13 ++ 3 files changed, 212 insertions(+) create mode 100644 tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp create mode 100644 tests/benchmarks/components/homeassistant/benchmark.yaml create mode 100644 tests/benchmarks/components/homeassistant/sensor/__init__.py diff --git a/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp b/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp new file mode 100644 index 00000000000..8ecf56ed130 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp @@ -0,0 +1,196 @@ +#include "esphome/core/defines.h" +#if defined(USE_API_PLAINTEXT) && defined(USE_API_HOMEASSISTANT_STATES) && defined(USE_SENSOR) + +#include +#include + +#include +#include + +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/components/homeassistant/sensor/homeassistant_sensor.h" +#include "esphome/components/socket/socket.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Each benchmark gets its own API server so subscriptions from one do not +// stay in the list scanned by the next. The constructor installs itself as +// global_api_server, which is what HomeassistantSensor::setup() subscribes to. +class ScopedApiServer { + public: + ScopedApiServer() : previous_(api::global_api_server) {} + ~ScopedApiServer() { api::global_api_server = this->previous_; } + api::APIServer *get() { return &this->server_; } + + protected: + api::APIServer *previous_; + api::APIServer server_; +}; + +// The handler only reads the server's subscription list, so the connection is +// never started and its socket never used. +static std::unique_ptr create_connection(api::APIServer *server) { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + return std::make_unique(std::make_unique(fd), server); +} + +// State strings as Home Assistant sends them. The handler writes a terminator +// one byte past the state, so the buffers are mutable and the terminator it +// writes lands on the one the literal already has. +static constexpr int kStateCount = 4; +static constexpr size_t kStateBufferSize = 16; +using StateBuffers = char[kStateCount][kStateBufferSize]; +using Messages = std::array; + +static StateBuffers NUMERIC_STATES = {"100.50", "101.25", "99.75", "100.00"}; +static StateBuffers INTEGER_STATES = {"100", "101", "99", "0"}; +static StateBuffers UNAVAILABLE_STATES = {"unavailable", "unknown", "unavailable", "unknown"}; + +static Messages make_messages(const char *entity_id, const char *attribute, StateBuffers &states) { + Messages msgs; + for (int i = 0; i < kStateCount; i++) { + msgs[i].entity_id = StringRef(entity_id); + if (attribute != nullptr) { + msgs[i].attribute = StringRef(attribute); + } + msgs[i].state = StringRef(states[i]); + } + return msgs; +} + +// --- One imported sensor, numeric state that changes every message --- +// Exercises: subscription match, in-place terminator, parse_number, publish_state. + +static void HomeassistantSensor_StateResponse(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, NUMERIC_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse); + +// --- The same sensor with integer states such as a percentage or a count --- +// Exercises: the same path with no fractional digits to parse. Only the state +// differs from the decimal case, so the gap between the two is the parse. + +static void HomeassistantSensor_StateResponse_Integer(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, INTEGER_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Integer); + +// --- Attribute import: the subscription and the message both carry an attribute --- +// Exercises: the extra attribute compare before the callback. + +static void HomeassistantSensor_StateResponse_Attribute(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("climate.living_room", "current_temperature", NUMERIC_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("climate.living_room"); + sensor.set_attribute("current_temperature"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Attribute); + +// --- Sixteen imported sensors, message for the last one --- +// Exercises: the linear scan over the subscription list with a string compare +// per entry, which is what a device with many imported entities pays per update. +// The IDs share a length and differ only in the last two bytes, so every +// entry runs a near-full compare, the worst case for the scan. + +static constexpr int kSensorCount = 16; + +static void HomeassistantSensor_StateResponse_ManySubscriptions(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + + static constexpr const char *const ENTITY_IDS[kSensorCount] = { + "sensor.import_00", "sensor.import_01", "sensor.import_02", "sensor.import_03", + "sensor.import_04", "sensor.import_05", "sensor.import_06", "sensor.import_07", + "sensor.import_08", "sensor.import_09", "sensor.import_10", "sensor.import_11", + "sensor.import_12", "sensor.import_13", "sensor.import_14", "sensor.import_15", + }; + HomeassistantSensor sensors[kSensorCount]; + for (int i = 0; i < kSensorCount; i++) { + sensors[i].set_entity_id(ENTITY_IDS[i]); + sensors[i].setup(); + } + const Messages msgs = make_messages(ENTITY_IDS[kSensorCount - 1], nullptr, NUMERIC_STATES); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensors[kSensorCount - 1].state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_ManySubscriptions); + +// --- Non-numeric state such as "unavailable" --- +// Exercises: the failed parse, the warning the platform logs for it, and the +// NAN publish. The benchmark logger runs at the production default level with +// console output off, so a log line at DEBUG or above costs its formatting +// here, the same as on a device, and a log level change in the platform shows +// up as a delta. + +static void HomeassistantSensor_StateResponse_Unavailable(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, UNAVAILABLE_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Unavailable); + +} // namespace esphome::homeassistant::benchmarks + +#endif // USE_API_PLAINTEXT && USE_API_HOMEASSISTANT_STATES && USE_SENSOR diff --git a/tests/benchmarks/components/homeassistant/benchmark.yaml b/tests/benchmarks/components/homeassistant/benchmark.yaml new file mode 100644 index 00000000000..e3924a22117 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/benchmark.yaml @@ -0,0 +1,3 @@ +# The homeassistant platforms subscribe through the API server. +api: +sensor: diff --git a/tests/benchmarks/components/homeassistant/sensor/__init__.py b/tests/benchmarks/components/homeassistant/sensor/__init__.py new file mode 100644 index 00000000000..c4b3f4d8d67 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/sensor/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # In production the platform's to_code emits this define; the host build + # suppresses that to_code, and the API server needs the define for the + # state subscription path the benchmarks drive. + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_STATES") + + manifest.to_code = to_code From 42341d05f9a3fda0c2afb410e8b3cc4afa65b713 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 13 Oct 2026 23:12:08 +0200 Subject: [PATCH 291/651] [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661) Co-authored-by: Claude --- esphome/components/nrf52/__init__.py | 6 ++++-- .../components/nrf52/test_run_compile_hex.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index f2a78259973..fcdffc0c645 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1040,11 +1040,13 @@ def run_compile(args, config: ConfigType) -> bool: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] bootloader = zephyr_data()[KEY_BOOTLOADER] - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes. + # The bootloader resets on a package whose sd_req does not list its SoftDevice, + # so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840). _GENPKG_PARAMS = { BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"), } # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant _UF2_FAMILY_IDS = { diff --git a/tests/unit_tests/components/nrf52/test_run_compile_hex.py b/tests/unit_tests/components/nrf52/test_run_compile_hex.py index f86a29a9d24..4684f6189d0 100644 --- a/tests/unit_tests/components/nrf52/test_run_compile_hex.py +++ b/tests/unit_tests/components/nrf52/test_run_compile_hex.py @@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building( app_hex = str(build_dir / "zephyr" / "zephyr.hex") assert _uf2_hex(run_cmd) == app_hex assert _dfu_hex(run_cmd) == app_hex + + +def test_sd140_v7_dfu_accepts_all_s140_7_softdevices( + build_dir: Path, run_cmd: Mock +) -> None: + """Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1.""" + _build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd) + cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) + sd_req = cmd[cmd.index("--sd-req") + 1].split(",") + assert sd_req == ["0x00CA", "0x0100", "0x0123"] From 0dc1d3b5a19ddd47603dc6938b419c2997acfa66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 11:25:18 -1000 Subject: [PATCH 292/651] [toshiba] Move the lookup tables to PROGMEM (#20636) --- esphome/components/toshiba/toshiba.cpp | 50 +++++++++----------- tests/components/toshiba/toshiba_tests.cpp | 55 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 406a1bc55db..eae24641c40 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -3,8 +3,6 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -#include - namespace esphome::toshiba { struct RacPt1411hwruFanSpeed { @@ -69,8 +67,8 @@ const uint8_t RAC_PT1411HWRU_CS_FOOTER_COOL = 0x72; const uint8_t RAC_PT1411HWRU_CS_FOOTER_HEAT = 0x7E; // RAC-PT1411HWRU Swing const uint8_t RAC_PT1411HWRU_SWING_HEADER = 0xB9; -const std::vector RAC_PT1411HWRU_SWING_VERTICAL{0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; -const std::vector RAC_PT1411HWRU_SWING_OFF{0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_VERTICAL[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_OFF[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; // RAC-PT1411HWRU Fan speeds const uint8_t RAC_PT1411HWRU_FAN_OFF = 0x7B; constexpr RacPt1411hwruFanSpeed RAC_PT1411HWRU_FAN_AUTO{0xBF, 0x66}; @@ -92,12 +90,12 @@ const uint8_t RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY = 0x0E; // Hence these look-up tables. In addition, the upper nibble is used here for additional // "negative" and "fractional value" flags as required for some temperatures. // RAC-PT1411HWRU °C Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_C{0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, - 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_C[] PROGMEM = {0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, + 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; // RAC-PT1411HWRU °F Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_F{0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, - 0x22, 0x06, 0x26, 0x07, 0x05, 0x25, 0x04, 0x24, 0x0C, - 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_F[] PROGMEM = { + 0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, 0x22, 0x06, 0x26, 0x07, 0x05, + 0x25, 0x04, 0x24, 0x0C, 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; // RAS-2819T protocol constants const uint16_t RAS_2819T_HEADER1 = 0xC23D; @@ -213,7 +211,7 @@ 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[] = { +static constexpr uint8_t RAS_2819T_TEMP_CODES[] PROGMEM = { 0x10, // 18°C 0x30, // 19°C 0x20, // 20°C @@ -328,7 +326,7 @@ static uint8_t get_ras_2819t_temp_code(float temperature) { return 0x40; // Default to 24°C } - return RAS_2819T_TEMP_CODES[temp_index]; + return progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]); } /** @@ -339,7 +337,7 @@ static float decode_ras_2819t_temperature(uint8_t temp_code) { // Find the code in the temperature array for (size_t temp_index = 0; temp_index < sizeof(RAS_2819T_TEMP_CODES); temp_index++) { - if (RAS_2819T_TEMP_CODES[temp_index] == base_temp_code) { + if (progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]) == base_temp_code) { return static_cast(temp_index + 18); // 18°C is the minimum } } @@ -680,10 +678,10 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { index = static_cast(roundf(temp_adjd)); if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - code = RAC_PT1411HWRU_TEMPERATURE_F[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[index]); message[9] |= RAC_PT1411HWRU_FLAG_FAH; } else { - code = RAC_PT1411HWRU_TEMPERATURE_C[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[index]); } if ((this->mode == climate::CLIMATE_MODE_FAN_ONLY) || (this->mode == climate::CLIMATE_MODE_OFF)) { code = RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY; @@ -752,15 +750,11 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { // Swing Mode data->reset(); data->space(TOSHIBA_PACKET_SPACE); - switch (this->swing_mode) { - case climate::CLIMATE_SWING_VERTICAL: - this->encode_(data, &RAC_PT1411HWRU_SWING_VERTICAL[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - break; - - case climate::CLIMATE_SWING_OFF: - default: - this->encode_(data, &RAC_PT1411HWRU_SWING_OFF[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - } + const uint8_t *swing_frame = + this->swing_mode == climate::CLIMATE_SWING_VERTICAL ? RAC_PT1411HWRU_SWING_VERTICAL : RAC_PT1411HWRU_SWING_OFF; + uint8_t swing[RAC_PT1411HWRU_MESSAGE_LENGTH]; + progmem_memcpy(swing, swing_frame, sizeof(swing)); + this->encode_(data, swing, sizeof(swing), 1); data->space(TOSHIBA_PACKET_SPACE); transmit.perform(); @@ -1300,14 +1294,14 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { temperature_code = (message[4] >> 4) | (message[14] & RAC_PT1411HWRU_FLAG_FRAC) | (message[15] & RAC_PT1411HWRU_FLAG_NEG); if (message[15] & RAC_PT1411HWRU_FLAG_FAH) { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_F.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_F[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_F); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[i]) == temperature_code) { this->target_temperature = static_cast((i + TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN - 32) * 5) / 9; } } } else { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_C.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_C[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_C); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[i]) == temperature_code) { this->target_temperature = i + TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN; } } @@ -1333,7 +1327,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { break; // Swing mode case RAC_PT1411HWRU_SWING_HEADER: - if (message[4] == RAC_PT1411HWRU_SWING_VERTICAL[4]) { + if (message[4] == progmem_read_byte(&RAC_PT1411HWRU_SWING_VERTICAL[4])) { this->swing_mode = climate::CLIMATE_SWING_VERTICAL; } else { this->swing_mode = climate::CLIMATE_SWING_OFF; diff --git a/tests/components/toshiba/toshiba_tests.cpp b/tests/components/toshiba/toshiba_tests.cpp index 528af888544..3c0ce6cd6ce 100644 --- a/tests/components/toshiba/toshiba_tests.cpp +++ b/tests/components/toshiba/toshiba_tests.cpp @@ -130,6 +130,61 @@ TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) { expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9); } +// The RAC-PT1411HWRU sends its main message, then a separate swing packet +// wrapped in packet spaces; both use the generic timings. +static void expect_swing_packet(const remote_base::RawTimings &actual, const std::vector &bytes) { + ASSERT_GE(actual.size(), 2u); + EXPECT_EQ(actual.front(), -10500); + EXPECT_EQ(actual.back(), -10500); + const remote_base::RawTimings frame(actual.begin() + 1, actual.end() - 1); + expect_frame(frame, waveform(bytes, GENERIC), 6); +} + +TEST(ToshibaRacPt1411hwru, SendsTheSwingFrameFromFlash) { + Fixture f(MODEL_RAC_PT1411HWRU_C); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}); +} + +// The temperature tables are read on the way out and searched on the way back +// in, so what one fixture sends must decode to the same state in another. +TEST(ToshibaRacPt1411hwru, TemperatureCodesRoundTrip) { + for (const Model model : {MODEL_RAC_PT1411HWRU_C, MODEL_RAC_PT1411HWRU_F}) { + for (int temperature = 16; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "model " << model << " temperature " << temperature); + Fixture tx(model); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + // The main message is sent before the swing packet + const auto &main_frame = tx.transmitter.frames[tx.transmitter.frames.size() - 2]; + + Fixture rx(model); + remote_base::RemoteReceiveData data(main_frame, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_NEAR(rx.climate.target_temperature, temperature, 0.3f); + } + } +} + +// The RAS-2819T table is read for the code on the way out and searched on the way back in. +TEST(ToshibaRas2819t, TemperatureCodesRoundTrip) { + for (int temperature = 18; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "temperature " << temperature); + Fixture tx(MODEL_RAS_2819T); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + + Fixture rx(MODEL_RAS_2819T); + remote_base::RemoteReceiveData data(tx.last_frame(), 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(rx.climate.target_temperature, temperature); + } +} + 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); From 6c24d4aa43c72962619579038a62334126244f6d 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 293/651] 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 b7df6ca4fc7..a54897ef36d 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 f4a6d6f18bc3252daf851e199e239666857cfb63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:28:07 -1000 Subject: [PATCH 294/651] [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 12aaae315e0..769b86519fa 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 991164eb993..a86b5a63b97 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 b9fbdec38d0..cde79af0b68 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 3f01fb22e6d..8a60e9c3881 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 007455f45a3..b108cf298ab 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 39ea6a32204b8c085e6f7b25c6990c93126b502d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 12 Oct 2026 11:22:59 -0400 Subject: [PATCH 295/651] [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 1480faa8c3f..332638e75bb 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 3d9f1431f67..a490f007d94 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 c9664d4e4282149fe2daeafefe2ee6840b56d2ad Mon Sep 17 00:00:00 2001 From: Firat Hajiyev Date: Mon, 12 Oct 2026 13:15:13 -0400 Subject: [PATCH 296/651] [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 f357bf75b35..f500bc6e3f0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -344,6 +344,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): @@ -522,6 +528,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 00000000000..e0e5ca98053 --- /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 b43630cf8e4075d8daf6cdde01f18a471a592c90 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 297/651] [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 23346ca6f4b..1ac37f8e456 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -194,6 +194,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 915ab73b876..96ed422de2d 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -7,9 +7,9 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] -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", @@ -20,8 +20,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 00000000000..25135f1d390 --- /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 bb0546fc0b2633db98f53afd09d5cce81c8caacd 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 21:31:19 +0200 Subject: [PATCH 298/651] [script] Keep the running script.wait item out of the queue while its chain runs (#20570) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/script/script.h | 10 ++- .../script/test_script_wait_action.cpp | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index f00ad7f4afa..efd75d27e0e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -295,8 +295,11 @@ template class ScriptWaitAction final : public Actionnum_running_ == 0) + if (this->num_running_ == 0) { + // Nothing left to wait for, e.g. once the last queued item has run + this->disable_loop(); return; + } if (this->script_->is_running()) return; @@ -305,9 +308,10 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { - auto ¶ms = this->param_queue_.front(); - std::apply([this](auto &...args) { this->play_next_(args...); }, params); + // Take the item out first: the chain may call stop(), which clears the queue + auto params = std::move(this->param_queue_.front()); this->param_queue_.pop_front(); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); } else { // Queue is now empty - disable loop until next play_complex this->disable_loop(); diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp index 17f553996ea..c3cd55fc8ca 100644 --- a/tests/components/script/test_script_wait_action.cpp +++ b/tests/components/script/test_script_wait_action.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/components/script/script.h" @@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { EXPECT_EQ(entity.value, 1); } +class StopWaitAction : public Action { + public: + void play(const std::string &value) override { + this->wait->stop_complex(); + this->seen = value; + } + Action *wait{nullptr}; + std::string seen; +}; + +// The chain after the wait may stop it, e.g. a script with mode: restart +TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + StopWaitAction stop; + stop.wait = &wait; + ActionList actions; + actions.add_actions({&wait, &stop}); + + // With a second item waiting, stop() clears the queue while the first item's chain runs + actions.play(std::string("argument longer than the small string buffer")); + actions.play(std::string("second argument, also longer than the buffer")); + script.running = false; + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); +} + +class RestartAction : public Action { + public: + void play(const std::string &value) override { + this->seen.push_back(value); + if (this->seen.size() > 1) + return; + // What a script with mode: restart does: stop, then run again while the awaited script is busy + this->wait->stop_complex(); + this->script->running = true; + this->actions->play(std::string("restarted, longer than the small string buffer")); + } + Action *wait{nullptr}; + FakeScript *script{nullptr}; + ActionList *actions{nullptr}; + std::vector seen; +}; + +// The old pop_front() after the chain removed the item queued by the restart +TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) { + FakeScript script; + ScriptWaitAction wait(&script); + RestartAction restart; + ActionList actions; + actions.add_actions({&wait, &restart}); + restart.wait = &wait; + restart.script = &script; + restart.actions = &actions; + + // With a second item waiting, the stop really clears the queue; the restart then queues a new item + actions.play(std::string("first argument, longer than the small string buffer")); + actions.play(std::string("second argument, dropped by the stop")); + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 1u); + + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 2u); + EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer"); +} + +TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + actions.play(std::string("argument")); + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument"); + wait.loop(); + EXPECT_TRUE(wait.is_idle()); +} + } // namespace esphome::script::testing From 53d504137b9219b1c853beac247712f16a085176 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 13 Oct 2026 23:12:08 +0200 Subject: [PATCH 299/651] [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661) Co-authored-by: Claude --- esphome/components/nrf52/__init__.py | 6 ++++-- .../components/nrf52/test_run_compile_hex.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index f8649f968ed..16fddadc878 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1039,11 +1039,13 @@ def run_compile(args, config: ConfigType) -> bool: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] bootloader = zephyr_data()[KEY_BOOTLOADER] - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes. + # The bootloader resets on a package whose sd_req does not list its SoftDevice, + # so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840). _GENPKG_PARAMS = { BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"), } # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant _UF2_FAMILY_IDS = { diff --git a/tests/unit_tests/components/nrf52/test_run_compile_hex.py b/tests/unit_tests/components/nrf52/test_run_compile_hex.py index f86a29a9d24..4684f6189d0 100644 --- a/tests/unit_tests/components/nrf52/test_run_compile_hex.py +++ b/tests/unit_tests/components/nrf52/test_run_compile_hex.py @@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building( app_hex = str(build_dir / "zephyr" / "zephyr.hex") assert _uf2_hex(run_cmd) == app_hex assert _dfu_hex(run_cmd) == app_hex + + +def test_sd140_v7_dfu_accepts_all_s140_7_softdevices( + build_dir: Path, run_cmd: Mock +) -> None: + """Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1.""" + _build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd) + cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) + sd_req = cmd[cmd.index("--sd-req") + 1].split(",") + assert sd_req == ["0x00CA", "0x0100", "0x0123"] From 5a78f636b99f53f01c0bb21cf3ec6b368075b6cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:43:50 +1300 Subject: [PATCH 300/651] Bump version to 2026.10.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a72069f8449..614b9a6fc05 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.0b3 +PROJECT_NUMBER = 2026.10.0b4 # 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 15dcce03ff9..3888f8f95d3 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.0b3" +__version__ = "2026.10.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a97ce3a20140c2deddcbc7227926c2775f7ebc48 Mon Sep 17 00:00:00 2001 From: guillempages Date: Tue, 13 Oct 2026 23:58:21 +0200 Subject: [PATCH 301/651] [image] Fix pillow deprecation warning (#20679) --- esphome/components/file/image.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index ab769954124..20eda9a3278 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -330,7 +330,9 @@ async def write_image( if byte_order is not None: # Check for valid type has already been done in validate_settings encoder.set_big_endian(byte_order == "BIG_ENDIAN") - pixels = encoder.convert(image.resize((width, height)), path).getdata() + pixels = encoder.convert( + image.resize((width, height)), path + ).get_flattened_data() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) From 6562c49686969f6c23baf5dcdb3cb73a12b6ec01 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:08:13 +0100 Subject: [PATCH 302/651] [thermostat] Allow a dry-only thermostat without cool_action (#20682) --- esphome/components/thermostat/climate.py | 17 +++++---- .../config/cooling_timer_without_action.yaml | 27 ++++++++++++++ .../thermostat/config/dry_only.yaml | 30 ++++++++++++++++ .../thermostat/test_validation.py | 36 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/thermostat/config/cooling_timer_without_action.yaml create mode 100644 tests/component_tests/thermostat/config/dry_only.yaml create mode 100644 tests/component_tests/thermostat/test_validation.py diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 8a954b181eb..6d8c24918d3 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -240,12 +240,6 @@ def validate_thermostat(config: ConfigType) -> ConfigType: CONF_SUPPLEMENTAL_HEATING_ACTION, CONF_SUPPLEMENTAL_HEATING_DELTA, ], - CONF_MIN_COOLING_OFF_TIME: [ - CONF_COOL_ACTION, - ], - CONF_MIN_COOLING_RUN_TIME: [ - CONF_COOL_ACTION, - ], CONF_MIN_FANNING_OFF_TIME: [ CONF_FAN_ONLY_ACTION, ], @@ -284,6 +278,17 @@ def validate_thermostat(config: ConfigType) -> ConfigType: f"{req_trigger} must be defined to use {config_trigger}" ) + # Dry mode runs on the cooling timers, so a dehumidifier needs no cool_action. + for timer in (CONF_MIN_COOLING_OFF_TIME, CONF_MIN_COOLING_RUN_TIME): + if ( + timer in config + and CONF_COOL_ACTION not in config + and CONF_DRY_ACTION not in config + ): + raise cv.Invalid( + f"{CONF_COOL_ACTION} or {CONF_DRY_ACTION} must be defined to use {timer}" + ) + if CONF_FAN_ONLY_ACTION in config: # determine validation requirements based on fan_only_action_uses_fan_mode_timer setting if config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] is True: diff --git a/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml new file mode 100644 index 00000000000..31e2818d936 --- /dev/null +++ b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml @@ -0,0 +1,27 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: heater + name: Heater + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle diff --git a/tests/component_tests/thermostat/config/dry_only.yaml b/tests/component_tests/thermostat/config/dry_only.yaml new file mode 100644 index 00000000000..f4dd0d6ca3b --- /dev/null +++ b/tests/component_tests/thermostat/config/dry_only.yaml @@ -0,0 +1,30 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: dehumidifier + name: Dehumidifier + sensor: temp_sensor + min_idle_time: 0s + min_cooling_off_time: 0s + min_cooling_run_time: 0s + min_fanning_off_time: 0s + min_fanning_run_time: 0s + idle_action: + - logger.log: idle + fan_only_action: + - logger.log: fan only + dry_action: + - logger.log: dry diff --git a/tests/component_tests/thermostat/test_validation.py b/tests/component_tests/thermostat/test_validation.py new file mode 100644 index 00000000000..53be07d1f72 --- /dev/null +++ b/tests/component_tests/thermostat/test_validation.py @@ -0,0 +1,36 @@ +"""Tests for the thermostat config validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_dry_only_config_is_valid( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A dehumidifier has dry mode but no cool_action; dry mode uses the cooling timers.""" + main_cpp = generate_main(component_config_path("dry_only.yaml")) + + assert "dehumidifier->set_cooling_minimum_off_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_cooling_minimum_run_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_supports_dry(true);" in main_cpp + assert "dehumidifier->set_supports_cool(" not in main_cpp + + +def test_cooling_timer_needs_cool_or_dry_action( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + """A cooling timer with neither cool_action nor dry_action is still rejected.""" + CORE.config_path = component_config_path("cooling_timer_without_action.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "cool_action or dry_action must be defined to use min_cooling_off_time" + in captured.out + ) From a0c7d754cbe037e43499283f32bc30e76de597c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 12:22:35 -1000 Subject: [PATCH 303/651] [syslog] Keep the log level to severity table in flash (#20662) --- esphome/components/syslog/esphome_syslog.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index a40829f3153..e9149e0dae1 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -2,12 +2,13 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/time.h" namespace esphome::syslog { // Map log levels to syslog severity using an array, indexed by ESPHome log level (1-7) -constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { +static constexpr uint8_t LOG_LEVEL_TO_SYSLOG_SEVERITY[] PROGMEM = { 3, // NONE 3, // ERROR 4, // WARN @@ -35,7 +36,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t // Syslog PRI calculation: facility * 8 + severity int severity = 7; if ((unsigned) level <= 7) { - severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level]; + severity = progmem_read_byte(&LOG_LEVEL_TO_SYSLOG_SEVERITY[level]); } int pri = this->facility_ * 8 + severity; From e265c0c0fc8e78300f8a9897585d826545dd0820 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:30:42 -0400 Subject: [PATCH 304/651] [core] Sync the static analysis dependency pins with the code (#20681) --- esphome/components/qr_code/__init__.py | 2 +- esphome/components/runtime_image/__init__.py | 2 +- esphome/idf_component.yml | 2 ++ platformio.ini | 10 +++++----- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 222041837bd..356d78469fe 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config: list[ConfigType]) -> None: - cg.add_library("wjtje/qr-code-generator-library", "^1.7.0") + cg.add_library("wjtje/qr-code-generator-library", "1.7.0") for entry in config: var = cg.new_Pvariable(entry[CONF_ID]) diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index fcf5cb770c1..bccf5248687 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -115,7 +115,7 @@ class PNGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_PNG") - cg.add_library("pngle", "1.1.0") + cg.add_library("kikuchan98/pngle", "1.1.0") class QOIFormat(Format): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1ede1f4d8de..2ef48602d1b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -21,6 +21,8 @@ dependencies: version: "1.8.2" espressif/esp-tflite-micro: version: 1.3.3~1 + espressif/esp-nn: + version: 1.1.2 espressif/esp32-camera: version: 2.1.7 espressif/mdns: diff --git a/platformio.ini b/platformio.ini index 0d6338d6cd1..96f3d25b6eb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,9 +46,9 @@ lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea improv/Improv@1.2.7 ; improv_serial / improv_ble - kikuchan98/pngle@1.1.0 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -82,7 +82,7 @@ lib_deps = esphome/noise-c@0.1.31 ; noise (api, ota) SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) - heman/AsyncMqttClient-esphome@1.0.0 ; mqtt + heman/AsyncMqttClient-esphome@2.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = @@ -252,8 +252,8 @@ lib_deps = DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt improv/Improv@1.2.7 ; improv_serial - kikuchan98/pngle@1.1.0 ; online_image - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY From 5c23a827b65351be1a1692bc9d5c478a245793af Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 00:35:55 +0200 Subject: [PATCH 305/651] [tmc2209] Add current and diagnostic sensors (#20638) --- esphome/components/tmc2209/sensor/__init__.py | 87 +++++++++++++++++ .../tmc2209/sensor/tmc2209_sensor.cpp | 95 +++++++++++++++++++ .../tmc2209/sensor/tmc2209_sensor.h | 35 +++++++ esphome/components/tmc2209/tmc2209.h | 3 + esphome/components/tmc22xx/tmc22xx.h | 9 ++ tests/components/tmc2209/common.yaml | 23 +++++ 6 files changed, 252 insertions(+) create mode 100644 esphome/components/tmc2209/sensor/__init__.py create mode 100644 esphome/components/tmc2209/sensor/tmc2209_sensor.cpp create mode 100644 esphome/components/tmc2209/sensor/tmc2209_sensor.h diff --git a/esphome/components/tmc2209/sensor/__init__.py b/esphome/components/tmc2209/sensor/__init__.py new file mode 100644 index 00000000000..dd5aadcf7ca --- /dev/null +++ b/esphome/components/tmc2209/sensor/__init__.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.tmc22xx import CONF_ANALOG_CURRENT_SCALE +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_CURRENT, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_PERCENT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import TMC2209Stepper, tmc2209_ns + +CONF_ACTUAL_CURRENT = "actual_current" +CONF_MOTOR_LOAD = "motor_load" +CONF_PWM_GRAD_AUTO = "pwm_grad_auto" +CONF_PWM_OFS_AUTO = "pwm_ofs_auto" +CONF_PWM_SCALE_AUTO = "pwm_scale_auto" +CONF_PWM_SCALE_SUM = "pwm_scale_sum" +CONF_STALLGUARD_RESULT = "stallguard_result" +CONF_TMC2209_ID = "tmc2209_id" + +TMC2209Sensor = tmc2209_ns.class_("TMC2209Sensor", cg.PollingComponent) + +_diagnostic_schema = sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(TMC2209Sensor), + cv.GenerateID(CONF_TMC2209_ID): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_ACTUAL_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_MOTOR_LOAD): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_STALLGUARD_RESULT): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_SUM): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_OFS_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_GRAD_AUTO): _diagnostic_schema, + } +).extend(cv.polling_component_schema("60s")) + + +def _final_validate(config: ConfigType) -> ConfigType: + if CONF_ACTUAL_CURRENT not in config: + return config + full_config = fv.full_config.get() + path = full_config.get_path_for_id(config[CONF_TMC2209_ID])[:-1] + if full_config.get_config_for_path(path).get(CONF_ANALOG_CURRENT_SCALE): + raise cv.Invalid( + f"'{CONF_ACTUAL_CURRENT}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', " + "the current then depends on VREF", + path=[CONF_ACTUAL_CURRENT], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TMC2209_ID]) + var = cg.new_Pvariable(config[CONF_ID], hub) + await cg.register_component(var, config) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACTUAL_CURRENT, var.set_actual_current_sensor) + await sensors(CONF_STALLGUARD_RESULT, var.set_stallguard_result_sensor) + await sensors(CONF_MOTOR_LOAD, var.set_motor_load_sensor) + await sensors(CONF_PWM_SCALE_SUM, var.set_pwm_scale_sum_sensor) + await sensors(CONF_PWM_SCALE_AUTO, var.set_pwm_scale_auto_sensor) + await sensors(CONF_PWM_OFS_AUTO, var.set_pwm_ofs_auto_sensor) + await sensors(CONF_PWM_GRAD_AUTO, var.set_pwm_grad_auto_sensor) diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp new file mode 100644 index 00000000000..a3a6237bca3 --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp @@ -0,0 +1,95 @@ +#include "tmc2209_sensor.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209.sensor"); + +using tmc22xx::TMC22XXStepper; + +// Highest StallGuard result, reached with no load +static constexpr float STALLGUARD_RESULT_MAX = 510.0f; + +static void publish_field(sensor::Sensor *sensor, const optional &data, const RegisterField &field) { + if (sensor == nullptr) + return; + if (!data.has_value()) { + sensor->publish_state(NAN); + return; + } + uint32_t value = TMC22XXStepper::extract_field(*data, field); + sensor->publish_state(field.is_signed ? static_cast(static_cast(value)) : static_cast(value)); +} + +void TMC2209Sensor::update() { + if (this->parent_->is_failed()) + return; + + // Each failed read blocks the loop for the UART timeout. Leave the bus alone while the driver is known to be + // unreachable or after the first failed read; the remaining sensors then publish NaN. + bool reachable = !this->parent_->is_unreachable(); + auto read = [this, &reachable](uint8_t reg) -> optional { + if (!reachable) + return {}; + auto data = this->parent_->read_register(reg); + reachable = data.has_value(); + return data; + }; + + if (this->actual_current_sensor_ != nullptr) { + auto drv_status = read(tmc22xx::REG_DRV_STATUS); + float current = NAN; + if (drv_status.has_value()) { + auto scale = static_cast(TMC22XXStepper::extract_field(*drv_status, tmc22xx::CS_ACTUAL)); + current = this->parent_->scale_to_current(scale); + } + this->actual_current_sensor_->publish_state(current); + } + + // StallGuard only measures the load while the motor turns + bool moving = !this->parent_->has_reached_target(); + if (this->stallguard_result_sensor_ != nullptr || (this->motor_load_sensor_ != nullptr && moving)) { + auto sg_result = read(REG_SG_RESULT); + publish_field(this->stallguard_result_sensor_, sg_result, SG_RESULT); + if (this->motor_load_sensor_ != nullptr) { + // 100% is the load at which a stall is reported, where the result drops below twice the threshold + float stall_result = 2.0f * this->parent_->get_stallguard_threshold(); + float load = NAN; + if (moving && sg_result.has_value() && stall_result < STALLGUARD_RESULT_MAX) { + float result = TMC22XXStepper::extract_field(*sg_result, SG_RESULT); + load = (STALLGUARD_RESULT_MAX - result) / (STALLGUARD_RESULT_MAX - stall_result) * 100.0f; + } + this->motor_load_sensor_->publish_state(load); + } + } else if (this->motor_load_sensor_ != nullptr) { + this->motor_load_sensor_->publish_state(NAN); + } + + if (this->pwm_scale_sum_sensor_ != nullptr || this->pwm_scale_auto_sensor_ != nullptr) { + auto pwm_scale = read(tmc22xx::REG_PWM_SCALE); + publish_field(this->pwm_scale_sum_sensor_, pwm_scale, tmc22xx::PWM_SCALE_SUM); + publish_field(this->pwm_scale_auto_sensor_, pwm_scale, tmc22xx::PWM_SCALE_AUTO); + } + + if (this->pwm_ofs_auto_sensor_ != nullptr || this->pwm_grad_auto_sensor_ != nullptr) { + auto pwm_auto = read(tmc22xx::REG_PWM_AUTO); + publish_field(this->pwm_ofs_auto_sensor_, pwm_auto, tmc22xx::PWM_OFS_AUTO); + publish_field(this->pwm_grad_auto_sensor_, pwm_auto, tmc22xx::PWM_GRAD_AUTO); + } +} + +void TMC2209Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209 Sensor:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Actual Current", this->actual_current_sensor_); + LOG_SENSOR(" ", "StallGuard Result", this->stallguard_result_sensor_); + LOG_SENSOR(" ", "Motor Load", this->motor_load_sensor_); + LOG_SENSOR(" ", "PWM Scale Sum", this->pwm_scale_sum_sensor_); + LOG_SENSOR(" ", "PWM Scale Auto", this->pwm_scale_auto_sensor_); + LOG_SENSOR(" ", "PWM Offset Auto", this->pwm_ofs_auto_sensor_); + LOG_SENSOR(" ", "PWM Gradient Auto", this->pwm_grad_auto_sensor_); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.h b/esphome/components/tmc2209/sensor/tmc2209_sensor.h new file mode 100644 index 00000000000..4ebbf82259a --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/tmc2209/tmc2209.h" +#include "esphome/core/component.h" + +namespace esphome::tmc2209 { + +class TMC2209Sensor : public PollingComponent { + public: + explicit TMC2209Sensor(TMC2209Stepper *parent) : parent_(parent) {} + + void update() override; + void dump_config() override; + + void set_actual_current_sensor(sensor::Sensor *sensor) { this->actual_current_sensor_ = sensor; } + void set_stallguard_result_sensor(sensor::Sensor *sensor) { this->stallguard_result_sensor_ = sensor; } + void set_motor_load_sensor(sensor::Sensor *sensor) { this->motor_load_sensor_ = sensor; } + void set_pwm_scale_sum_sensor(sensor::Sensor *sensor) { this->pwm_scale_sum_sensor_ = sensor; } + void set_pwm_scale_auto_sensor(sensor::Sensor *sensor) { this->pwm_scale_auto_sensor_ = sensor; } + void set_pwm_ofs_auto_sensor(sensor::Sensor *sensor) { this->pwm_ofs_auto_sensor_ = sensor; } + void set_pwm_grad_auto_sensor(sensor::Sensor *sensor) { this->pwm_grad_auto_sensor_ = sensor; } + + protected: + TMC2209Stepper *parent_; + sensor::Sensor *actual_current_sensor_{nullptr}; + sensor::Sensor *stallguard_result_sensor_{nullptr}; + sensor::Sensor *motor_load_sensor_{nullptr}; + sensor::Sensor *pwm_scale_sum_sensor_{nullptr}; + sensor::Sensor *pwm_scale_auto_sensor_{nullptr}; + sensor::Sensor *pwm_ofs_auto_sensor_{nullptr}; + sensor::Sensor *pwm_grad_auto_sensor_{nullptr}; +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index f1c83485fa3..49923316884 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -9,10 +9,12 @@ using tmc22xx::RegisterField; // StallGuard and CoolStep registers, which the TMC2208 does not have static constexpr uint8_t REG_TCOOLTHRS = 0x14; static constexpr uint8_t REG_SGTHRS = 0x40; +static constexpr uint8_t REG_SG_RESULT = 0x41; static constexpr uint8_t REG_COOLCONF = 0x42; static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false}; +static constexpr RegisterField SG_RESULT{REG_SG_RESULT, 0, 10, false}; static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false}; static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false}; static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false}; @@ -30,6 +32,7 @@ class TMC2209Stepper : public tmc22xx::TMC22XXStepper { /// A stall is reported when the StallGuard result drops below twice this value. void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); } + uint8_t get_stallguard_threshold() const { return extract_field(this->sgthrs_, SGTHRS); } /// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG. void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); } void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); } diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 546a27e70d3..19e4813b70d 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -33,6 +33,8 @@ static constexpr uint8_t REG_VACTUAL = 0x22; static constexpr uint8_t REG_CHOPCONF = 0x6C; static constexpr uint8_t REG_DRV_STATUS = 0x6F; static constexpr uint8_t REG_PWMCONF = 0x70; +static constexpr uint8_t REG_PWM_SCALE = 0x71; +static constexpr uint8_t REG_PWM_AUTO = 0x72; // Readable registers that a driver reset sets back to their defaults; the last written values are restored static constexpr std::array RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF}; @@ -59,7 +61,12 @@ 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 CS_ACTUAL{REG_DRV_STATUS, 16, 5, false}; static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; +static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false}; +static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true}; +static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false}; +static constexpr RegisterField PWM_GRAD_AUTO{REG_PWM_AUTO, 16, 8, false}; enum StandstillMode : uint8_t { STANDSTILL_MODE_NORMAL = 0, @@ -129,6 +136,8 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U /// 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_; } + /// True while the driver does not answer, until the status poll reads it again (every 10 s). + bool is_unreachable() const { return this->status_poll_skip_ > 0; } /// Called with the condition and whether it became active or cleared. template void add_on_status_callback(F &&callback) { diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index e53c3ba8233..c39d3fd8ba2 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -84,3 +84,26 @@ esphome: target: 1000 - tmc2209.disable: id: tmc2209_step_dir + +sensor: + - platform: tmc2209 + tmc2209_id: tmc2209_step_dir + update_interval: 10s + actual_current: + name: Actual Current + stallguard_result: + name: StallGuard Result + motor_load: + name: Motor Load + pwm_scale_sum: + name: PWM Scale Sum + pwm_scale_auto: + name: PWM Scale Auto + pwm_ofs_auto: + name: PWM Offset Auto + pwm_grad_auto: + name: PWM Gradient Auto + - platform: tmc2209 + tmc2209_id: tmc2209_serial + stallguard_result: + name: Serial StallGuard Result From 7d735f06774c2bd8a7cca2f5cd3a89e2ab64fe56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:37:09 +0200 Subject: [PATCH 306/651] [emontx] Lock the emonTx on reboot when its firmware supports it (#20621) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/__init__.py | 18 +- esphome/components/emontx/emontx.cpp | 156 ++++++++++++--- esphome/components/emontx/emontx.h | 29 ++- .../components/emontx/text_sensor/__init__.py | 1 + esphome/core/defines.h | 1 + .../emontx/test_lock_on_reboot.py | 38 ++++ tests/components/emontx/common-version.yaml | 9 + tests/components/emontx/common.h | 21 ++ tests/components/emontx/common.yaml | 7 - tests/components/emontx/emontx_test.cpp | 185 +++++++++++++++++- .../emontx/test-actions.esp8266-ard.yaml | 1 + .../components/emontx/text_sensor/__init__.py | 1 + .../text_sensor/emontx_version_test.cpp | 31 +-- 13 files changed, 436 insertions(+), 62 deletions(-) create mode 100644 tests/component_tests/emontx/test_lock_on_reboot.py create mode 100644 tests/components/emontx/common-version.yaml diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index d6c46b5509e..8d2351f1fad 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -23,11 +23,18 @@ DEPENDENCIES = ["uart"] emontx_ns = cg.esphome_ns.namespace("emontx") EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) +LockOnReboot = emontx_ns.enum("LockOnReboot", is_class=True) CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" CONF_LOCK_ON_REBOOT = "lock_on_reboot" +LOCK_ON_REBOOT_AUTO = "auto" +LOCK_ON_REBOOT_MODES = { + LOCK_ON_REBOOT_AUTO: LockOnReboot.LOCK_ON_REBOOT_AUTO, + True: LockOnReboot.LOCK_ON_REBOOT_ALWAYS, + False: LockOnReboot.LOCK_ON_REBOOT_NEVER, +} DOMAIN = "emontx" @@ -59,7 +66,9 @@ 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, + cv.Optional(CONF_LOCK_ON_REBOOT, default=LOCK_ON_REBOOT_AUTO): cv.Any( + cv.boolean, cv.one_of(LOCK_ON_REBOOT_AUTO, lower=True) + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -135,8 +144,11 @@ 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)) + lock = config[CONF_LOCK_ON_REBOOT] + cg.add(var.set_lock_on_reboot(LOCK_ON_REBOOT_MODES[lock])) + if lock == LOCK_ON_REBOOT_AUTO: + # Locking depends on the firmware version the emonTx reports + cg.add_define("USE_EMONTX_VERSION") # Action: emontx.send_command diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 9c8436c0070..dca5ae2131d 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -7,30 +7,85 @@ namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +static constexpr uint32_t UNLOCK_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so the next one waits +static constexpr uint32_t COMMAND_GAP_MS = 250; + #ifdef USE_EMONTX_VERSION -static constexpr uint32_t VERSION_REQUEST_ID = 0; -// The emonTx joins bytes received while a command is pending into that command, so 'v' waits -static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +static constexpr uint32_t VERSION_REQUEST_ID = 1; +// Without a reply, 'v' is sent again every VERSION_RETRY_FRAMES data lines, VERSION_MAX_ATTEMPTS times in all +static constexpr uint8_t VERSION_RETRY_FRAMES = 2; +static constexpr uint8_t VERSION_MAX_ATTEMPTS = 3; // Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +// Every data line starts with the message counter, in JSON or text format +static const char MSG_JSON_PREFIX[] PROGMEM = "{\"MSG\":"; +static const char MSG_TEXT_PREFIX[] PROGMEM = "MSG:"; +// First firmware with 'emonlock' and 'emonunlock'; older firmware takes them for the bootloader command +static constexpr uint32_t LOCK_MIN_MAJOR = 1; +static constexpr uint32_t LOCK_MIN_MINOR = 2; + +// True if line starts with the PROGMEM prefix and has more after it +static bool starts_with(const char *line, size_t len, const char *prefix, size_t prefix_len) { + return len > prefix_len && ESPHOME_strncmp_P(line, prefix, prefix_len) == 0; +} + +// Reads the decimal number (up to 9 digits) at pos and moves pos past it; false if there is none +static bool read_number(const char *&pos, const char *end, uint32_t &value) { + const char *start = pos; + value = 0; + while (pos < end && pos - start < 9 && *pos >= '0' && *pos <= '9') + value = value * 10 + (*pos++ - '0'); + return pos != start; +} + +// True for a release such as "v1.2.0" or "v1.3.0-rc1" from LOCK_MIN_MAJOR.LOCK_MIN_MINOR on +static bool supports_lock(const char *version, const char *end) { + if (version < end && (*version == 'v' || *version == 'V')) + version++; + uint32_t major; + if (!read_number(version, end, major) || version == end || *version != '.') + return false; + version++; + uint32_t minor; + if (!read_number(version, end, minor)) + return false; + return major > LOCK_MIN_MAJOR || (major == LOCK_MIN_MAJOR && minor >= LOCK_MIN_MINOR); +} #endif void EmonTx::setup() { this->buffer_pos_ = 0; - if (this->lock_on_reboot_) { - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + // The line feed ends any boot noise this->write_byte('\n'); - this->send_command("emonunlock"); + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + this->unlock_(); +#ifdef USE_EMONTX_VERSION + if (this->wants_version_()) + this->request_version_(); +#endif + }); + return; } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + // With AUTO, the reply decides whether to unlock + if (this->wants_version_()) this->request_version_(); #endif } +void EmonTx::unlock_() { + ESP_LOGI(TAG, "Unlocking emonTx"); + this->send_command("emonunlock"); +} + void EmonTx::on_shutdown() { - if (!this->lock_on_reboot_) + bool lock = this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS; +#ifdef USE_EMONTX_VERSION + lock |= this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && this->lock_supported_; +#endif + if (!lock) return; if (this->is_paused()) { ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused"); @@ -63,7 +118,7 @@ void EmonTx::loop() { this->discarding_line_ = dropped != '\n'; #ifdef USE_EMONTX_VERSION // The emonTx may have been flashed, or a request dropped, during the pause - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) this->request_version_(); #endif } @@ -96,8 +151,10 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) { + this->parse_msg_number_(this->buffer_.data(), len); this->parse_version_line_(this->buffer_.data(), len); + } #endif } } else if (received == '\r' || this->discarding_line_) { @@ -124,16 +181,6 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif -#ifdef USE_EMONTX_VERSION - auto msg = root["MSG"]; - if (this->version_text_sensor_ != nullptr && msg.is()) { - // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update - uint32_t msg_number = msg; - if (msg_number <= this->last_msg_number_) - this->request_version_(); - this->last_msg_number_ = msg_number; - } -#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -145,17 +192,50 @@ void EmonTx::parse_json_(const char *data, size_t len) { } #ifdef USE_EMONTX_VERSION -// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() void EmonTx::request_version_() { + // The firmware may have changed; until the reply, an unknown version gets no lock commands + this->lock_supported_ = false; + this->version_frames_left_ = VERSION_RETRY_FRAMES * VERSION_MAX_ATTEMPTS; + this->send_version_request_(); +} + +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::send_version_request_() { if (this->is_paused()) return; this->write_byte('\n'); - this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); + this->set_timeout(VERSION_REQUEST_ID, COMMAND_GAP_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_msg_number_(const char *line, size_t len) { + const char *pos; + if (starts_with(line, len, MSG_JSON_PREFIX, sizeof(MSG_JSON_PREFIX) - 1)) { + pos = line + sizeof(MSG_JSON_PREFIX) - 1; + } else if (starts_with(line, len, MSG_TEXT_PREFIX, sizeof(MSG_TEXT_PREFIX) - 1)) { + pos = line + sizeof(MSG_TEXT_PREFIX) - 1; + } else { + return; + } + uint32_t msg_number; + if (!read_number(pos, line + len, msg_number)) + return; + + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + if (msg_number <= this->last_msg_number_) { + this->request_version_(); + } else if (this->version_frames_left_ > 0) { + if (--this->version_frames_left_ == 0) { + ESP_LOGW(TAG, "No firmware version from the emonTx, it may still be locked"); + } else if (this->version_frames_left_ % VERSION_RETRY_FRAMES == 0) { + this->send_version_request_(); + } + } + this->last_msg_number_ = msg_number; } void EmonTx::parse_version_line_(const char *line, size_t len) { constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; - if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + if (!starts_with(line, len, VERSION_PREFIX, prefix_len)) return; const char *version = line + prefix_len; const char *end = line + len; @@ -164,7 +244,23 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { if (version == end) return; ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); - this->version_text_sensor_->publish_state(version, end - version); + this->version_frames_left_ = 0; +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + this->version_text_sensor_->publish_state(version, end - version); +#endif + + bool supported = supports_lock(version, end); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && supported && !this->unlock_sent_) { + // Once the rest of the reply has gone out; a pause drops it, and the version request on resume retries + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + if (this->is_paused()) + return; + this->unlock_(); + this->unlock_sent_ = true; + }); + } + this->lock_supported_ = supported; } #endif @@ -173,7 +269,13 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { */ void EmonTx::dump_config() { ESP_LOGCONFIG(TAG, "EmonTx:"); - ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_)); + const char *lock = LOG_STR_LITERAL("no"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + lock = LOG_STR_LITERAL("yes"); + } else if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO) { + lock = LOG_STR_LITERAL("auto"); + } + ESP_LOGCONFIG(TAG, " Lock on reboot: %s", lock); #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); @@ -183,7 +285,7 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); #endif } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 8f6a2424c94..a8d91d36eb6 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,7 +13,7 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -22,6 +22,12 @@ namespace esphome::emontx { /// Maximum line length in bytes (plus one byte reserved for null terminator) static constexpr size_t MAX_LINE_LENGTH = 1024; +enum class LockOnReboot : uint8_t { + LOCK_ON_REBOOT_NEVER, + LOCK_ON_REBOOT_ALWAYS, + LOCK_ON_REBOOT_AUTO, // Only when the emonTx reports firmware v1.2.0 or later +}; + /** * @class EmonTx * @brief Main class for the EmonTx component. @@ -30,7 +36,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR SUB_TEXT_SENSOR(version) #endif public: @@ -56,8 +62,8 @@ 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; } + /// as a command, and unlock it after boot. Needs emonTx firmware v1.2.0 or later, which AUTO checks. + void set_lock_on_reboot(LockOnReboot lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -66,8 +72,18 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); + void unlock_(); #ifdef USE_EMONTX_VERSION + bool wants_version_() const { +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + return true; +#endif + return this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO; + } void request_version_(); + void send_version_request_(); + void parse_msg_number_(const char *line, size_t len); void parse_version_line_(const char *line, size_t len); #endif @@ -79,9 +95,12 @@ 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}; + LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line #ifdef USE_EMONTX_VERSION + bool lock_supported_{false}; // The last version reported is v1.2.0 or later + bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot + uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed uint32_t last_msg_number_{0}; #endif std::array buffer_{}; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py index 9b3065f0f86..a3e381b9b5d 100644 --- a/esphome/components/emontx/text_sensor/__init__.py +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -21,3 +21,4 @@ async def to_code(config: ConfigType) -> None: text_sensors = text_sensor.sub_text_sensors(config) if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 08f1e31d6c2..8d243043bde 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -69,6 +69,7 @@ #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_EMONTX_VERSION +#define USE_EMONTX_VERSION_TEXT_SENSOR #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT diff --git a/tests/component_tests/emontx/test_lock_on_reboot.py b/tests/component_tests/emontx/test_lock_on_reboot.py new file mode 100644 index 00000000000..6b6ba2881f6 --- /dev/null +++ b/tests/component_tests/emontx/test_lock_on_reboot.py @@ -0,0 +1,38 @@ +"""Tests for the emontx lock_on_reboot option: auto (the default), true or false.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.emontx import ( + CONF_LOCK_ON_REBOOT, + CONFIG_SCHEMA, + LOCK_ON_REBOOT_AUTO, +) + + +def _lock_key() -> cv.Optional: + return next(key for key in CONFIG_SCHEMA.schema if key == CONF_LOCK_ON_REBOOT) + + +def test_default_is_auto() -> None: + assert _lock_key().default() == LOCK_ON_REBOOT_AUTO + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("auto", LOCK_ON_REBOOT_AUTO), + ("AUTO", LOCK_ON_REBOOT_AUTO), + (True, True), + (False, False), + ("yes", True), + ("off", False), + ], +) +def test_accepted_values(value: object, expected: object) -> None: + assert CONFIG_SCHEMA.schema[_lock_key()](value) == expected + + +def test_other_values_rejected() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA.schema[_lock_key()]("sometimes") diff --git a/tests/components/emontx/common-version.yaml b/tests/components/emontx/common-version.yaml new file mode 100644 index 00000000000..1ec3733f771 --- /dev/null +++ b/tests/components/emontx/common-version.yaml @@ -0,0 +1,9 @@ +emontx: + id: test_emontx + lock_on_reboot: true + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 4af5a98572d..678d582f62f 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -8,6 +8,8 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/emontx/emontx.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" namespace esphome::emontx::testing { @@ -42,4 +44,23 @@ class MockUARTComponent : public uart::UARTComponent { #endif // defined(USE_ESP8266) || defined(USE_ESP32) }; +// The test main does not construct App as generated code does +inline void construct_app() { + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; +} + +inline void cancel_timers(EmonTx *emontx) { + for (uint32_t id = 0; id < 3; id++) + App.scheduler.cancel_timeout(emontx, id); + App.scheduler.call(millis()); +} + +// Lets timeouts due within duration_ms run; the scheduler reads the real clock +inline void run_timers(uint32_t duration_ms) { + App.scheduler.call(millis()); + delay(duration_ms); // NOLINT - test only + App.scheduler.call(millis()); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index 4a5e347053a..5c25e37abb5 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -8,7 +8,6 @@ button: emontx: id: test_emontx - lock_on_reboot: true on_json: - then: - logger.log: "Got JSON" @@ -24,9 +23,3 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W - -text_sensor: - - platform: emontx - emontx_id: test_emontx - version: - name: emonTx firmware version diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 5d9fa096119..71bb6c70d36 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -13,11 +14,16 @@ namespace { class EmonTxTest : public ::testing::Test { protected: void SetUp() override { + construct_app(); 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_++; }); } + void TearDown() override { cancel_timers(&this->sut_); } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + MockUARTComponent uart_; EmonTx sut_; std::vector lines_; @@ -160,7 +166,7 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) { EXPECT_CALL(this->uart_, flush()).Times(1); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.on_shutdown(); EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n"); } @@ -174,16 +180,20 @@ TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) { TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) { EXPECT_CALL(this->uart_, flush()).Times(0); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); 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); +// The emonTx would join "emonunlock" to the pending empty line if both came in one burst +TEST_F(EmonTxTest, SetupUnlocksAfterLineFeedWhenEnabled) { + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); - EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n"); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(300); + EXPECT_EQ(this->tx_(), "\nemonunlock\n"); } TEST_F(EmonTxTest, SetupSendsNothingByDefault) { @@ -191,4 +201,169 @@ TEST_F(EmonTxTest, SetupSendsNothingByDefault) { EXPECT_TRUE(this->uart_.tx.empty()); } +class EmonTxLockAutoTest : public EmonTxTest { + protected: + void SetUp() override { + EmonTxTest::SetUp(); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_AUTO); + } + + void report_version_(const char *version) { + this->uart_.push_rx(" - Version: "); + this->uart_.push_rx(version); + this->uart_.push_rx("\r\n"); + this->sut_.loop(); + } +}; + +// Only 'v' goes out until the reply shows the firmware knows 'emonunlock' +TEST_F(EmonTxLockAutoTest, SetupOnlyRequestsVersion) { + this->sut_.setup(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxLockAutoTest, SupportedFirmwareUnlockedAndLockedOnShutdown) { + this->report_version_("v1.2.0"); + EXPECT_TRUE(this->uart_.tx.empty()); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); + + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_EQ(this->tx_(), "emonunlock\nemonlock\n"); +} + +// Firmware before v1.2.0 reads "emon..." as the bootloader command +TEST_F(EmonTxLockAutoTest, OlderFirmwareGetsNoLockCommands) { + this->report_version_("v1.1.1"); + run_timers(300); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NothingSentWithoutVersion) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, VersionFormats) { + for (const char *version : {"V1.10.0", "v2.0.0", "1.2.0", "v1.3.0-rc1"}) { + this->report_version_("v0.99.0"); // So the next one is a change + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_NE(this->tx_().find("emonlock"), std::string::npos) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + this->uart_.tx.clear(); + } + for (const char *version : {"v1.1.1", "v0.99.9-test", "None", "768be1b", "v1", "v.2"}) { + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + } +} + +// The emonTx clears its lock when it reboots, so a repeated report needs no second unlock +TEST_F(EmonTxLockAutoTest, UnlockedOnlyOnce) { + this->report_version_("v1.2.0"); + run_timers(300); + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); +} + +TEST_F(EmonTxLockAutoTest, RequestRetriedEverySecondDataLine) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx("{\"MSG\":10}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\nv\n"); + + // Text format counts too + this->uart_.push_rx("MSG:11,V1:230.1\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); + + this->report_version_("v1.1.1"); + this->uart_.push_rx("{\"MSG\":12}\n{\"MSG\":13}\n{\"MSG\":14}\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); +} + +// Each attempt sends its line feed at once; counting those needs no wait for the delayed 'v' +TEST_F(EmonTxLockAutoTest, RetriesStopAfterThreeAttempts) { + this->sut_.setup(); + for (int msg = 10; msg < 20; msg++) { + char line[24]; + snprintf(line, sizeof(line), "{\"MSG\":%d}\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + EXPECT_EQ(this->tx_(), "\n\n\n"); +} + +// E.g. a downgrade: after the reboot, the new firmware never answers 'v' +TEST_F(EmonTxLockAutoTest, NoLockAfterUnansweredRequest) { + this->report_version_("v1.2.0"); + run_timers(300); + for (int msg : {50, 1, 2, 3, 4, 5, 6, 7}) { + char line[24]; + snprintf(line, sizeof(line), "MSG:%d\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NoLockWhileVersionPending) { + this->report_version_("v1.2.0"); + run_timers(300); + this->uart_.push_rx("MSG:50\nMSG:1\n"); + this->sut_.loop(); + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, UnlockDroppedByPauseSentAfterResume) { + this->report_version_("v1.2.0"); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(300); + EXPECT_TRUE(this->uart_.tx.empty()); + + // Resume asks for the version again, and its reply brings the unlock + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(300); + this->uart_.push_rx("> Firmware:\r\n"); // Ends the line skipped after a resume + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\nemonunlock\n"); +} + +// The lines of a 'v' reply before the version line are not data lines +TEST_F(EmonTxLockAutoTest, ReplyBannerNotCountedAsDataLines) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx( + "> Board:\r\n - emonPi3/emonTx6 (arch. rev. 1)\r\n> Firmware:\r\n - Bootloader: UART (.bin)\r\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/test-actions.esp8266-ard.yaml b/tests/components/emontx/test-actions.esp8266-ard.yaml index 4be79a40dac..04cc5b89f56 100644 --- a/tests/components/emontx/test-actions.esp8266-ard.yaml +++ b/tests/components/emontx/test-actions.esp8266-ard.yaml @@ -2,3 +2,4 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml emontx: !include common.yaml actions: !include common-actions.yaml + version: !include common-version.yaml diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py index 60a67b5a0b6..5bffc87e0f0 100644 --- a/tests/components/emontx/text_sensor/__init__.py +++ b/tests/components/emontx/text_sensor/__init__.py @@ -7,5 +7,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # The platform's own to_code needs a configured hub; only its define is wanted here. async def to_code_testing(config: ConfigType) -> None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index fe905a0e958..34f6315f3ab 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -4,8 +4,6 @@ #include "esphome/components/emontx/emontx.h" #include "esphome/components/text_sensor/text_sensor.h" -#include "esphome/core/application.h" -#include "esphome/core/hal.h" #include "../common.h" namespace esphome::emontx::testing { @@ -14,24 +12,15 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } - void TearDown() override { - App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); - App.scheduler.call(millis()); - } + void TearDown() override { cancel_timers(&this->sut_); } // Lets the delayed 'v' go out - static void run_timers() { - App.scheduler.call(millis()); - delay(300); // NOLINT - test only, the scheduler reads the real clock - App.scheduler.call(millis()); - } + static void run_timers() { testing::run_timers(300); } std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } @@ -51,8 +40,11 @@ TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { } TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); run_timers(); @@ -88,6 +80,15 @@ TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { EXPECT_EQ(this->tx_(), "\nv\n"); } +TEST_F(EmonTxVersionTest, TextMsgCounterResetRequestsVersion) { + this->uart_.push_rx("MSG:41,V1:230.1\r\nMSG:1,V1:230.4\r\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + // The emonTx may have been flashed during the pause, with MSG already past its old value TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { this->sut_.set_paused(true); From f265c46c2152e640b33eb899838ed98154e1ff12 Mon Sep 17 00:00:00 2001 From: Andrej Friesen Date: Wed, 14 Oct 2026 00:50:43 +0200 Subject: [PATCH 307/651] [PCA9505] Add PCA9505/PCA9506 40-channel GPIO expander (#20667) --- CODEOWNERS | 1 + esphome/components/pca9505/__init__.py | 79 +++++ esphome/components/pca9505/pca9505.cpp | 165 +++++++++ esphome/components/pca9505/pca9505.h | 72 ++++ tests/components/pca9505/common.yaml | 46 +++ tests/components/pca9505/test.esp32-idf.yaml | 6 + .../components/pca9505/test.esp8266-ard.yaml | 6 + tests/components/pca9505/test.rp2040-ard.yaml | 6 + tests/components/pca9505/test_pca9505.cpp | 312 ++++++++++++++++++ 9 files changed, 693 insertions(+) create mode 100644 esphome/components/pca9505/__init__.py create mode 100644 esphome/components/pca9505/pca9505.cpp create mode 100644 esphome/components/pca9505/pca9505.h create mode 100644 tests/components/pca9505/common.yaml create mode 100644 tests/components/pca9505/test.esp32-idf.yaml create mode 100644 tests/components/pca9505/test.esp8266-ard.yaml create mode 100644 tests/components/pca9505/test.rp2040-ard.yaml create mode 100644 tests/components/pca9505/test_pca9505.cpp diff --git a/CODEOWNERS b/CODEOWNERS index a770810490b..95d8c49c23e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -439,6 +439,7 @@ esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/packet_transport/* @clydebarrow esphome/components/pca6416a/* @Mat931 +esphome/components/pca9505/* @ajfriesen @ssieb esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py new file mode 100644 index 00000000000..7b0d5ac1732 --- /dev/null +++ b/esphome/components/pca9505/__init__.py @@ -0,0 +1,79 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INTERRUPT_PIN, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +AUTO_LOAD = ["gpio_expander"] +CODEOWNERS = ["@ssieb", "@ajfriesen"] +DEPENDENCIES = ["i2c"] +DOMAIN = "pca9505" +MULTI_CONF = True + +pca9505_ns = cg.esphome_ns.namespace("pca9505") + +PCA9505Component = pca9505_ns.class_("PCA9505Component", cg.Component, i2c.I2CDevice) +PCA9505GPIOPin = pca9505_ns.class_( + "PCA9505GPIOPin", cg.GPIOPin, cg.Parented.template(PCA9505Component) +) + +CONF_PCA9505 = "pca9505" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(PCA9505Component), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x20)) +) + + +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 interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + +def validate_mode(value: ConfigType) -> ConfigType: + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +PCA9505_PIN_SCHEMA = pins.gpio_base_schema( + PCA9505GPIOPin, + cv.int_range(min=0, max=39), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, +).extend( + { + cv.Required(CONF_PCA9505): cv.use_id(PCA9505Component), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA9505, PCA9505_PIN_SCHEMA) +async def pca9505_pin_to_code(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCA9505]) + + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp new file mode 100644 index 00000000000..20f62c14721 --- /dev/null +++ b/esphome/components/pca9505/pca9505.cpp @@ -0,0 +1,165 @@ +#include "pca9505.h" +#include "esphome/core/log.h" + +namespace esphome::pca9505 { + +// Each register block holds one byte per port. Add the port number to reach a single +// port, or set the auto-increment bit to transfer the whole block in one go. +static constexpr uint8_t INPUT_REG = 0x00; +static constexpr uint8_t OUTPUT_REG = 0x08; +static constexpr uint8_t INVERT_REG = 0x10; +static constexpr uint8_t CONFIG_REG = 0x18; +static constexpr uint8_t MASK_REG = 0x20; +static constexpr uint8_t AUTO_INCREMENT = 0x80; + +ESPHOME_LOG_TAG(TAG, "pca9505"); + +void PCA9505Component::setup() { + // Test to see if device exists + if (!this->read_bank_(0)) { + ESP_LOGE(TAG, "PCA9505 not detected at 0x%02X", this->address_); + this->mark_failed(); + return; + } + + // No polarity inversion + this->write_all_banks_(INVERT_REG, 0x00); + // All outputs low, before anything is switched to being an output + this->write_all_banks_(OUTPUT_REG, 0x00); + // All inputs at initialization. The part sees a 1 as an input, so this is the + // inverse of config_, which is all zeroes here. + this->write_all_banks_(CONFIG_REG, 0xFF); + + // Mask every interrupt. This must be written, not assumed: the chip keeps its + // registers through an MCU reboot, so a previous run may have left pins unmasked. + for (uint8_t &bank : this->mask_) { + bank = 0xFF; + } + this->write_all_banks_(MASK_REG, 0xFF); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA9505Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read - only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + + // Nothing to poll until pin_mode() registers an input pin. + this->disable_loop(); +} + +void IRAM_ATTR PCA9505Component::gpio_intr(PCA9505Component *arg) { arg->enable_loop_soon_any_context(); } + +void PCA9505Component::loop() { + // Invalidate the cache so the next digital_read() triggers a fresh I2C read + this->reset_pin_cache_(); + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { + this->disable_loop(); + } +} + +void PCA9505Component::dump_config() { + ESP_LOGCONFIG(TAG, "PCA9505:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool PCA9505Component::digital_read_hw(uint8_t pin) { return this->read_bank_(pin / 8); } + +bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin / 8] & (1 << (pin % 8)); } + +void PCA9505Component::digital_write_hw(uint8_t pin, bool value) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (value) { + this->output_[bank] |= mask; + } else { + this->output_[bank] &= ~mask; + } + this->write_bank_(OUTPUT_REG, bank, this->output_[bank]); +} + +void PCA9505Component::pin_mode(uint8_t pin, gpio::Flags flags) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (flags & gpio::FLAG_INPUT) { + this->config_[bank] &= ~mask; + // An input may raise INT; the polling loop is only needed when there is no interrupt pin. + this->mask_[bank] &= ~mask; + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } + } else if (flags & gpio::FLAG_OUTPUT) { + this->config_[bank] |= mask; + // An output must never raise INT. + this->mask_[bank] |= mask; + } + // Invert as the part sees a 1 as an input + this->write_bank_(CONFIG_REG, bank, ~this->config_[bank]); + // Without an interrupt pin INT is unconnected, so the masks written at setup can stay + if (this->interrupt_pin_ != nullptr) { + this->write_bank_(MASK_REG, bank, this->mask_[bank]); + } +} + +bool PCA9505Component::read_bank_(uint8_t bank) { + if (this->is_failed()) + return false; + + if (this->read_register(INPUT_REG + bank, &this->input_[bank], 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_bank_(uint8_t reg, uint8_t bank, uint8_t value) { + if (this->is_failed()) + return false; + + if (this->write_register(reg + bank, &value, 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { + if (this->is_failed()) + return false; + + uint8_t values[BANK_COUNT]; + for (uint8_t &bank : values) { + bank = value; + } + if (this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } + +void PCA9505GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCA9505GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } + +bool PCA9505GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } + +void PCA9505GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } + +size_t PCA9505GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "%u via PCA9505", this->pin_); +} + +} // namespace esphome::pca9505 diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h new file mode 100644 index 00000000000..29c4f18361c --- /dev/null +++ b/esphome/components/pca9505/pca9505.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::pca9505 { + +/// Number of I/O pins on the PCA9505 and PCA9506. +static constexpr uint8_t PIN_COUNT = 40; +/// The pins are grouped into five ports of eight, each with its own set of registers. +static constexpr uint8_t BANK_COUNT = PIN_COUNT / 8; + +class PCA9505Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + + void setup() override; + void loop() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + protected: + static void IRAM_ATTR gpio_intr(PCA9505Component *arg); + + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + + bool read_bank_(uint8_t bank); + bool write_bank_(uint8_t reg, uint8_t bank, uint8_t value); + bool write_all_banks_(uint8_t reg, uint8_t value); + + /// The pin config per bank - 1 means OUTPUT, 0 means INPUT + uint8_t config_[BANK_COUNT]{}; + /// The output state per bank - 1 means HIGH, 0 means LOW + uint8_t output_[BANK_COUNT]{}; + /// The input pin states per bank - 1 means HIGH, 0 means LOW + uint8_t input_[BANK_COUNT]{}; + /// Which pins may pull INT low - 1 means masked, matching the chip + uint8_t mask_[BANK_COUNT]{}; + InternalGPIOPin *interrupt_pin_{nullptr}; +}; + +class PCA9505GPIOPin final : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::pca9505 diff --git a/tests/components/pca9505/common.yaml b/tests/components/pca9505/common.yaml new file mode 100644 index 00000000000..53614dbcdda --- /dev/null +++ b/tests/components/pca9505/common.yaml @@ -0,0 +1,46 @@ +pca9505: + - id: pca9505_hub + i2c_id: i2c_bus + address: 0x26 + - id: pca9505_hub_second + interrupt_pin: ${interrupt_pin} + i2c_id: i2c_bus + address: 0x27 + +binary_sensor: + # Pins 0 and 39 are the first and last pins, so between them they cover every + # bank index the five-port register maths has to get right. + - platform: gpio + id: pca9505_input_first + name: PCA9505 Binary Sensor First + pin: + pca9505: pca9505_hub + number: 0 + mode: INPUT + inverted: true + - platform: gpio + id: pca9505_input_last + name: PCA9505 Binary Sensor Last + pin: + pca9505: pca9505_hub_second + number: 39 + mode: INPUT + inverted: false + +switch: + - platform: gpio + id: pca9505_output_low + name: PCA9505 Switch Low + pin: + pca9505: pca9505_hub + number: 7 + mode: OUTPUT + inverted: false + - platform: gpio + id: pca9505_output_high + name: PCA9505 Switch High + pin: + pca9505: pca9505_hub + number: 32 + mode: OUTPUT + inverted: true diff --git a/tests/components/pca9505/test.esp32-idf.yaml b/tests/components/pca9505/test.esp32-idf.yaml new file mode 100644 index 00000000000..0dc4dcfd657 --- /dev/null +++ b/tests/components/pca9505/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.esp8266-ard.yaml b/tests/components/pca9505/test.esp8266-ard.yaml new file mode 100644 index 00000000000..84c7105a5a0 --- /dev/null +++ b/tests/components/pca9505/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.rp2040-ard.yaml b/tests/components/pca9505/test.rp2040-ard.yaml new file mode 100644 index 00000000000..9d79eb4f3bd --- /dev/null +++ b/tests/components/pca9505/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test_pca9505.cpp b/tests/components/pca9505/test_pca9505.cpp new file mode 100644 index 00000000000..4ee55fd805f --- /dev/null +++ b/tests/components/pca9505/test_pca9505.cpp @@ -0,0 +1,312 @@ +#include + +#include "esphome/components/pca9505/pca9505.h" + +namespace esphome::pca9505::testing { + +// Models the PCA9505 register map: five ports, each with its own input, output, +// polarity inversion and configuration byte. The command byte selects a register +// and its top bit asks the part to auto-increment through the block. +class FakeBus : public i2c::I2CBus { + public: + static constexpr uint8_t ADDRESS = 0x26; + + 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 { + EXPECT_EQ(address, ADDRESS); + if (this->fail) + return i2c::ERROR_TIMEOUT; + + // Every transfer starts with the command byte. + EXPECT_GE(write_count, 1u); + const uint8_t command = write_buffer[0]; + const bool auto_increment = (command & 0x80) != 0; + uint8_t *block = this->block_for(command & 0x7F); + const uint8_t index = (command & 0x7F) % 8; + + if (read_count != 0) { + this->reads++; + // Without auto-increment the part keeps returning the same register. + EXPECT_TRUE(auto_increment || read_count == 1); + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = block[auto_increment ? index + i : index]; + return i2c::ERROR_OK; + } + + this->writes++; + EXPECT_TRUE(auto_increment || write_count == 2); + for (size_t i = 1; i < write_count; i++) + block[auto_increment ? index + i - 1 : index] = write_buffer[i]; + return i2c::ERROR_OK; + } + + uint8_t *block_for(uint8_t reg) { + if (reg < 0x08) + return this->input; + if (reg < 0x10) + return this->output; + if (reg < 0x18) + return this->invert; + if (reg < 0x20) + return this->config; + return this->mask; + } + + // Power-on reset defaults from the datasheet. + uint8_t input[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t output[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t invert[BANK_COUNT]{0x00, 0x00, 0x00, 0x00, 0x00}; + uint8_t config[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t mask[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + + unsigned reads{0}; + unsigned writes{0}; + bool fail{false}; +}; + +class PCA9505Test : public ::testing::Test { + protected: + void SetUp() override { + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(FakeBus::ADDRESS); + } + + PCA9505GPIOPin make_pin_(uint8_t number, gpio::Flags flags, bool inverted = false) { + PCA9505GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(flags); + pin.set_inverted(inverted); + return pin; + } + + FakeBus bus_; + PCA9505Component component_; + InternalGPIOPin int_pin_; // digital_read() returns the inverted flag on the host, so it doubles as the level +}; + +TEST_F(PCA9505Test, SetupLeavesEveryPinAnInputWithOutputsLow) { + this->component_.setup(); + + ASSERT_FALSE(this->component_.is_failed()); + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should still be an input"; + EXPECT_EQ(this->bus_.output[bank], 0x00) << "bank " << int(bank) << " should be driven low"; + EXPECT_EQ(this->bus_.invert[bank], 0x00) << "bank " << int(bank) << " should not be inverted"; + } +} + +TEST_F(PCA9505Test, SetupFailsWhenTheDeviceIsAbsent) { + this->bus_.fail = true; + this->component_.setup(); + + EXPECT_TRUE(this->component_.is_failed()); +} + +// The chip has 40 pins, so pin numbers reach past the width of an int shift. +// Every pin must land in its own bank and bit. +TEST_F(PCA9505Test, EachPinMapsToItsOwnBankAndBit) { + this->component_.setup(); + + for (uint8_t number = 0; number < PIN_COUNT; number++) { + PCA9505GPIOPin pin = this->make_pin_(number, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + const uint8_t want = (bank == number / 8) ? uint8_t(1 << (number % 8)) : 0x00; + ASSERT_EQ(this->bus_.output[bank], want) << "pin " << int(number) << " wrote outside its own bit"; + } + + pin.digital_write(false); + ASSERT_EQ(this->bus_.output[number / 8], 0x00) << "pin " << int(number) << " did not clear"; + } +} + +TEST_F(PCA9505Test, ConfiguringAnOutputOnlyTouchesItsOwnBank) { + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(35, gpio::FLAG_OUTPUT); + pin.setup(); + + // Pin 35 is bit 3 of bank 4. The part reads a 1 as an input, so that bit clears. + EXPECT_EQ(this->bus_.config[4], 0xFF & ~(1 << 3)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ReadsReportTheLevelOnTheHighestPin) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT); + pin.setup(); + + this->bus_.input[4] = 0x80; // pin 39 high + EXPECT_TRUE(pin.digital_read()); + + this->component_.loop(); // clears the cache so the next read hits the bus + this->bus_.input[4] = 0x00; + EXPECT_FALSE(pin.digital_read()); +} + +TEST_F(PCA9505Test, InvertedPinFlipsTheReportedLevel) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT, /*inverted=*/true); + pin.setup(); + + this->bus_.input[4] = 0x80; + EXPECT_FALSE(pin.digital_read()); + + this->component_.loop(); + this->bus_.input[4] = 0x00; + EXPECT_TRUE(pin.digital_read()); +} + +// Eight pins sharing a port must cost one transfer per loop, not eight. +TEST_F(PCA9505Test, PinsInOneBankShareASingleRead) { + this->component_.setup(); + + PCA9505GPIOPin pins[8]; + for (uint8_t i = 0; i < 8; i++) { + pins[i] = this->make_pin_(8 + i, gpio::FLAG_INPUT); + pins[i].setup(); + } + + this->bus_.input[1] = 0xA5; + const unsigned before = this->bus_.reads; + for (uint8_t i = 0; i < 8; i++) + EXPECT_EQ(pins[i].digital_read(), ((0xA5 >> i) & 1) != 0) << "pin " << int(8 + i); + EXPECT_EQ(this->bus_.reads - before, 1u); +} + +// Pins in different ports are cached separately, so each port costs its own read. +TEST_F(PCA9505Test, EachBankIsReadSeparately) { + this->component_.setup(); + + PCA9505GPIOPin pins[BANK_COUNT]; + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + pins[bank] = this->make_pin_(bank * 8, gpio::FLAG_INPUT); + pins[bank].setup(); + } + + const unsigned before = this->bus_.reads; + for (auto &pin : pins) + pin.digital_read(); + EXPECT_EQ(this->bus_.reads - before, BANK_COUNT); +} + +TEST_F(PCA9505Test, WritesAreSkippedOnceSetupHasFailed) { + this->bus_.fail = true; + this->component_.setup(); + ASSERT_TRUE(this->component_.is_failed()); + + this->bus_.fail = false; + const unsigned before = this->bus_.writes; + PCA9505GPIOPin pin = this->make_pin_(0, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + EXPECT_EQ(this->bus_.writes, before); +} + +// A 1 in a mask register blocks that pin from pulling INT low. +TEST_F(PCA9505Test, SetupMasksEveryInterrupt) { + // Pretend a previous run left port 2 unmasked. The chip keeps its registers + // through an MCU reboot, so setup must write them rather than assume the reset state. + this->bus_.mask[2] = 0x00; + this->component_.setup(); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be masked"; +} + +TEST_F(PCA9505Test, ConfiguringAnInputUnmasksOnlyThatPin) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + // Pin 34 is bit 2 of bank 4, and a 0 lets it through. + EXPECT_EQ(this->bus_.mask[4], 0xFF & ~(1 << 2)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ConfiguringAnOutputLeavesItMasked) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_OUTPUT); + pin.setup(); + + // An output must not be able to pull INT low. + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +TEST_F(PCA9505Test, WithoutAnInterruptPinTheMasksAreNotRewritten) { + this->component_.setup(); + const unsigned before = this->bus_.writes; + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + EXPECT_EQ(this->bus_.writes, before + 1); // the configuration register only + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +// Without an interrupt pin an input turns the polling loop on; with one it stays off. +TEST_F(PCA9505Test, AnInputEnablesPollingOnlyWithoutAnInterruptPin) { + this->component_.setup(); + ASSERT_TRUE(this->component_.is_idle()); + PCA9505GPIOPin polled = this->make_pin_(3, gpio::FLAG_INPUT); + polled.setup(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + PCA9505Component with_interrupt; + with_interrupt.set_i2c_bus(&this->bus_); + with_interrupt.set_i2c_address(FakeBus::ADDRESS); + with_interrupt.set_interrupt_pin(&this->int_pin_); + with_interrupt.setup(); + ASSERT_TRUE(with_interrupt.is_idle()); + PCA9505GPIOPin pin; + pin.set_parent(&with_interrupt); + pin.set_pin(3); + pin.set_flags(gpio::FLAG_INPUT); + pin.setup(); + EXPECT_TRUE(with_interrupt.is_idle()); +} + +// With an interrupt pin a read is served from the cache until loop() clears it. +TEST_F(PCA9505Test, InterruptPinKeepsTheCacheUntilTheLoopRuns) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(3, gpio::FLAG_INPUT); + pin.setup(); + + const unsigned before = this->bus_.reads; + pin.digital_read(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 1); + + this->component_.loop(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 2); +} + +// The loop only turns itself off once INT has gone high again, so a change that +// lands during the read cannot leave the cache stale with no edge to wake it. +TEST_F(PCA9505Test, LoopStaysOnUntilIntIsReleased) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + this->component_.enable_loop(); + ASSERT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(false); // the host pin reads back its inverted flag: INT still low + this->component_.loop(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(true); // INT released + this->component_.loop(); + EXPECT_TRUE(this->component_.is_idle()); +} + +} // namespace esphome::pca9505::testing From d275926f6513edb2dfa1347f1583dbd053746584 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 13 Oct 2026 17:55:22 -0500 Subject: [PATCH 308/651] [mdns] Advertise the connected interface in the network TXT record (#20673) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/mdns/mdns_component.cpp | 53 ++++++++++++++++++- esphome/components/mdns/mdns_component.h | 22 ++++++++ esphome/components/mdns/mdns_esp32.cpp | 19 ++++++- esphome/components/mdns/mdns_rp2.cpp | 19 +++++++ .../mdns/common-dual-interface.yaml | 9 ++++ .../mdns/test-dual-interface.esp32-idf.yaml | 17 ++++++ .../mdns/test-dual-interface.rp2040-ard.yaml | 17 ++++++ 7 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/components/mdns/common-dual-interface.yaml create mode 100644 tests/components/mdns/test-dual-interface.esp32-idf.yaml create mode 100644 tests/components/mdns/test-dual-interface.rp2040-ard.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4397c101fc5..0e363852688 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -20,6 +20,10 @@ #ifdef USE_DASHBOARD_IMPORT #include "esphome/components/dashboard_import/dashboard_import.h" #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +#include "esphome/components/ethernet/ethernet_component.h" +#include "esphome/components/wifi/wifi_component.h" +#endif namespace esphome::mdns { @@ -39,6 +43,45 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); +MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet"); + +// Same order as the default route in NetworkComponent::loop() +const char *MDNSComponent::connected_network_() const { + const bool ethernet = ethernet::global_eth_component->is_connected(); + const bool wifi = wifi::global_wifi_component->is_connected(); +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + if (wifi) + return NETWORK_WIFI; +#endif + if (ethernet) + return NETWORK_ETHERNET; + if (wifi) + return NETWORK_WIFI; + return nullptr; +} + +void MDNSComponent::update_network_txt_() { + if (this->network_txt_ == nullptr) + return; + // With no interface connected, keep the last value instead of flapping to wifi + const char *network = this->connected_network_(); + if (network == nullptr || network == this->network_txt_) + return; + if (!this->publish_network_txt_(network)) { + // The _esphomelib service is missing, so a retry would fail too + this->network_txt_ = nullptr; + return; + } + ESP_LOGD(TAG, "Network TXT: %s", network); + this->network_txt_ = network; +#ifdef USE_MDNS_STORE_SERVICES + this->network_record_->value = MDNS_STR(network); +#endif +} +#endif + void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { #ifdef USE_MDNS_STORE_SERVICES auto &services = this->services_; @@ -160,7 +203,15 @@ void MDNSComponent::compile_records_(StaticVectorconnected_network_(); + this->network_txt_ = network != nullptr ? network : NETWORK_WIFI; + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(this->network_txt_)}); +#ifdef USE_MDNS_STORE_SERVICES + // services is services_ here, which never reallocates + this->network_record_ = &txt_records.back(); +#endif +#elif defined(USE_WIFI) MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); #elif defined(USE_ETHERNET) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 93d9eb1e385..f2ea7c19149 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,6 +32,12 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif +// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface. +// OpenThread hands its services to the SRP client once, so it keeps the fixed value. +#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD) +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -150,6 +156,22 @@ class MDNSComponent final : public Component void setup_buffers_and_register_(PlatformRegisterFn platform_register); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; + /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. + const char *connected_network_() const; + /// Check the connected interface and publish a changed "network" TXT value. + void update_network_txt_(); + /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. + bool publish_network_txt_(const char *network); + /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. + const char *network_txt_{nullptr}; +#ifdef USE_MDNS_STORE_SERVICES + /// The "network" record in services_, kept in step with network_txt_. + MDNSTXTRecord *network_record_{nullptr}; +#endif +#endif + #ifdef USE_MDNS_DYNAMIC_TXT /// Storage for runtime-generated TXT values from user lambdas /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 1df5b9217e5..afa30eb5f30 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -89,7 +89,24 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr } #endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD -void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +bool MDNSComponent::publish_network_txt_(const char *network) { + // ESP-IDF copies the value and announces the changed record + esp_err_t err = mdns_service_txt_item_set("_esphomelib", "_tcp", "network", network); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to update network TXT: %s", esp_err_to_name(err)); + return false; + } + return true; +} +#endif + +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_esp32); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif +} void MDNSComponent::on_shutdown() { #ifndef USE_OPENTHREAD diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 7eaac594fb5..603f3f0417c 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -46,7 +46,26 @@ void MDNSComponent::start_polling_window_() { } #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +ESPHOME_LOG_TAG(TAG, "mdns"); + +bool MDNSComponent::publish_network_txt_(const char *network) { + // LEAmDNS replaces the value of an existing key; announce() sends the change + if (!MDNS.addServiceTxt("esphomelib", "tcp", "network", network)) { + ESP_LOGW(TAG, "Failed to update network TXT"); + return false; + } + MDNS.announce(); + this->start_polling_window_(); + return true; +} +#endif + void MDNSComponent::setup() { +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + // No-op until the first IP-up event registers the services + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our // own IP state listener. Both WiFi and Ethernet have the same listener signature — diff --git a/tests/components/mdns/common-dual-interface.yaml b/tests/components/mdns/common-dual-interface.yaml new file mode 100644 index 00000000000..97ae545f2bc --- /dev/null +++ b/tests/components/mdns/common-dual-interface.yaml @@ -0,0 +1,9 @@ +# wifi and ethernet together: the _esphomelib "network" TXT value follows +# the connected interface, so the update path is compiled. +wifi: + ssid: MySSID + password: password1 + +api: + +mdns: diff --git a/tests/components/mdns/test-dual-interface.esp32-idf.yaml b/tests/components/mdns/test-dual-interface.esp32-idf.yaml new file mode 100644 index 00000000000..d480824494b --- /dev/null +++ b/tests/components/mdns/test-dual-interface.esp32-idf.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/mdns/test-dual-interface.rp2040-ard.yaml b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml new file mode 100644 index 00000000000..3836afb7eef --- /dev/null +++ b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + +# wifi first, so the USE_NETWORK_PRIMARY_INTERFACE_WIFI order is compiled +network: + priority: + - wifi + - ethernet From 63616bf642e3505c655076f6d8847d0e403cd31e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:06:27 -1000 Subject: [PATCH 309/651] [rtttl] Play a configured song from static storage without copying it (#19569) --- esphome/components/rtttl/__init__.py | 13 ++- esphome/components/rtttl/rtttl.cpp | 106 ++++++++++++++---- esphome/components/rtttl/rtttl.h | 69 +++++++++--- tests/components/rtttl/test_rtttl_parser.cpp | 109 +++++++++++++++++++ 4 files changed, 255 insertions(+), 42 deletions(-) create mode 100644 tests/components/rtttl/test_rtttl_parser.cpp diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 47b8f26e3ff..8bff8bb58dc 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -14,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@ximex"] DOMAIN = "rtttl" CONF_RTTTL = "rtttl" +# The player keeps the song length in 16 bits +MAX_SONG_LENGTH = 65535 CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") @@ -97,16 +99,23 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) +def _static_song(config: ConfigType, value: str) -> str: + # A constant song plays straight from static storage, flash on ESP8266, with no copy per play + return f"esphome::rtttl::StaticSong{{{cg.FlashStringLiteral(value)}}}" + + automation.register_apply_action( "rtttl.play", cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Rtttl), - cv.Required(CONF_RTTTL): cv.templatable(cv.string), + cv.Required(CONF_RTTTL): cv.templatable( + cv.All(cv.string, cv.ByteLength(max=MAX_SONG_LENGTH)) + ), }, key=CONF_RTTTL, ), - automation.ApplyField(CONF_RTTTL, "play", cg.std_string), + automation.ApplyField(CONF_RTTTL, "play", cg.std_string, const_fn=_static_song), ) automation.register_apply_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index fb2bf63a646..a1d49c65c72 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -1,5 +1,8 @@ #include "rtttl.h" +#include #include +#include +#include #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -147,14 +150,14 @@ void Rtttl::loop() { #endif // USE_SPEAKER // Align to note: most rtttl's out there does not add any space after the ',' separator but just in case - while (this->position_ < this->rtttl_.length()) { - char c = this->rtttl_[this->position_]; + while (this->position_ < this->song_len_) { + char c = this->song_at_(this->position_); if (c != ',' && c != ' ') break; this->position_++; } - if (this->position_ >= this->rtttl_.length()) { + if (this->position_ >= this->song_len_) { this->finish_(); return; } @@ -169,12 +172,12 @@ void Rtttl::loop() { this->note_duration_ = this->wholenote_duration_ / this->default_note_denominator_; } - uint8_t note_index_in_octave = note_index_from_char(this->rtttl_[this->position_]); + uint8_t note_index_in_octave = note_index_from_char(this->song_at_(this->position_)); this->position_++; // Now, get optional '#' sharp - if (this->rtttl_[this->position_] == '#') { + if (this->song_at_(this->position_) == '#') { note_index_in_octave++; this->position_++; } @@ -192,7 +195,7 @@ void Rtttl::loop() { } // Now, get optional '.' dotted note - if (this->rtttl_[this->position_] == '.') { + if (this->song_at_(this->position_) == '.') { this->note_duration_ += this->note_duration_ / 2; // Duration +50% this->position_++; } @@ -264,15 +267,68 @@ void Rtttl::loop() { } void Rtttl::play(std::string rtttl) { - if (this->state_ != State::STOPPED && this->state_ != State::STOPPING) { - size_t pos = this->rtttl_.find(':'); - size_t len = (pos != std::string::npos) ? pos : this->rtttl_.length(); - ESP_LOGW(TAG, "Already playing: %.*s", (int) len, this->rtttl_.c_str()); + if (this->is_busy_()) { return; } + this->owned_song_ = std::move(rtttl); + this->set_song_(this->owned_song_.data(), this->owned_song_.size()); + this->start_(); +} - this->rtttl_ = std::move(rtttl); +void Rtttl::play(StaticSong song) { + if (this->is_busy_()) { + return; + } + // Release a previously owned song; the static one needs no storage. + std::string().swap(this->owned_song_); + const char *data = reinterpret_cast(song.rtttl); + this->set_song_(data, ESPHOME_strlen_P(data)); + this->start_(); +} +void Rtttl::set_song_(const char *song, size_t len) { + this->song_ = song; + // Anything past what the 16 bit length holds is dropped; the validator keeps configured songs shorter. + this->song_len_ = static_cast(std::min(len, std::numeric_limits::max())); +} + +bool Rtttl::is_busy_() const { + if (this->state_ == State::STOPPED || this->state_ == State::STOPPING) { + return false; + } + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->song_find_(':', 0, this->song_len_)); + ESP_LOGW(TAG, "Already playing: %s", name); + return true; +} + +size_t Rtttl::song_find_(char c, size_t from, size_t end) const { + for (size_t pos = from; pos < end; pos++) { + if (this->song_at_(pos) == c) + return pos; + } + return end; +} + +size_t Rtttl::song_find_control_(char key, size_t from, size_t end) const { + size_t pos = from; + while ((pos = this->song_find_(key, pos, end)) < end) { + if (this->song_at_(pos + 1) == '=') + return pos; + pos++; + } + return end; +} + +void Rtttl::song_name_(char *buf, size_t size, size_t name_len) const { + size_t len = std::min(name_len, size - 1); + for (size_t pos = 0; pos < len; pos++) { + buf[pos] = this->song_at_(pos); + } + buf[len] = '\0'; +} + +void Rtttl::start_() { this->default_note_denominator_ = DEFAULT_NOTE_DENOMINATOR; this->default_octave_ = DEFAULT_OCTAVE; this->note_duration_ = 0; @@ -281,9 +337,9 @@ void Rtttl::play(std::string rtttl) { uint16_t num; // Used for: default note-denominator, default octave, BPM // Get name - this->position_ = this->rtttl_.find(':'); + this->position_ = this->song_find_(':', 0, this->song_len_); - if (this->position_ == std::string::npos) { + if (this->position_ == this->song_len_) { ESP_LOGE(TAG, "Unable to determine name; missing ':'"); return; } @@ -292,18 +348,20 @@ void Rtttl::play(std::string rtttl) { static_cast(SONG_NAME_LENGTH_LIMIT)); return; } - ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->position_); + ESP_LOGD(TAG, "Playing song %s", name); size_t name_end_position = this->position_; - size_t control_end = this->rtttl_.find(':', name_end_position + 1); - if (control_end == std::string::npos) { + size_t control_end = this->song_find_(':', name_end_position + 1, this->song_len_); + if (control_end == this->song_len_) { ESP_LOGE(TAG, "Missing second ':'"); return; } // Get default duration - size_t pos = this->rtttl_.find("d=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + size_t pos = this->song_find_control_('d', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'd='; use default duration %d", this->default_note_denominator_); } else { this->position_ = pos + 2; @@ -317,8 +375,8 @@ void Rtttl::play(std::string rtttl) { } // Get default octave - pos = this->rtttl_.find("o=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('o', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'o='; use default octave %d", this->default_octave_); } else { this->position_ = pos + 2; @@ -332,8 +390,8 @@ void Rtttl::play(std::string rtttl) { } // Get BPM - pos = this->rtttl_.find("b=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('b', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'b='; use default BPM %d", bpm); } else { this->position_ = pos + 2; @@ -387,7 +445,7 @@ void Rtttl::stop() { } #endif // USE_SPEAKER - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } @@ -411,7 +469,7 @@ void Rtttl::finish_() { #endif // USE_SPEAKER // Ensure no more notes are played in case finish_() is called for an error. - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 055204571e1..ade2dcd3205 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -14,6 +15,10 @@ namespace esphome::rtttl { +namespace testing { +class RtttlParserAccess; +} // namespace testing + inline constexpr uint8_t DEFAULT_NOTE_DENOMINATOR = 4; // Default note-denominator (quarter note) inline constexpr uint8_t DEFAULT_OCTAVE = 6; // Default octave for a note (see: `MIN_OCTAVE`, `MAX_OCTAVE` in `rtttl.cpp`) @@ -26,6 +31,11 @@ enum class State : uint8_t { STOPPING, }; +/// A song in storage that outlives playback, such as a literal from generated code; PROGMEM on ESP8266. +struct StaticSong { + ProgmemStr rtttl; +}; + class Rtttl final : public Component { public: #ifdef USE_OUTPUT @@ -38,7 +48,10 @@ class Rtttl final : public Component { void dump_config() override; void loop() override; + /// Play a song; the string is copied so it may come from anywhere. void play(std::string rtttl); + /// Play a song from static storage without copying it. + void play(StaticSong song); void stop(); float get_gain() { return this->gain_; } @@ -55,11 +68,29 @@ class Rtttl final : public Component { protected: inline uint16_t get_integer_() { uint16_t ret = 0; - while (isdigit(this->rtttl_[this->position_])) { - ret = (ret * 10) + (this->rtttl_[this->position_++] - '0'); + while (isdigit(this->song_at_(this->position_))) { + ret = (ret * 10) + (this->song_at_(this->position_++) - '0'); } return ret; } + /// The byte of the song at pos, or NUL past its end; the read is flash safe for a PROGMEM song on ESP8266. + char song_at_(size_t pos) const { + if (pos >= this->song_len_) + return '\0'; + return static_cast(progmem_read_byte(reinterpret_cast(this->song_) + pos)); + } + /// Index of the first `c` in [from, end), or `end` when absent. + size_t song_find_(char c, size_t from, size_t end) const; + /// Index of `key` followed by '=' in [from, end), or `end` when absent. + size_t song_find_control_(char key, size_t from, size_t end) const; + /// Copies the first name_len bytes of the song, its name, into buf for logging. + void song_name_(char *buf, size_t size, size_t name_len) const; + /// Points the view at a song, clamped to what song_len_ can hold. + void set_song_(const char *song, size_t len); + /// Parses the header and starts playback of the song the view points at. + void start_(); + /// Logs and returns true when a song is still playing, so a new one must wait. + bool is_busy_() const; /** * @brief Finalizes the playback of the RTTTL string. * @@ -70,26 +101,30 @@ class Rtttl final : public Component { void finish_(); void set_state_(State state); - /// The RTTTL string to play. - std::string rtttl_; - /// The current position in the RTTTL string. - size_t position_{0}; - /// The default duration of a note (e.g. 4 for a quarter note). - uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; - /// The default octave for a note. - uint8_t default_octave_{DEFAULT_OCTAVE}; - /// The duration of the current note in milliseconds. - uint16_t note_duration_{0}; - /// The duration of a whole note in milliseconds. - uint16_t wholenote_duration_; - /// The current state of the RTTTL player. - State state_{State::STOPPED}; + /// The song being played; points at owned_song_ or at static storage. + const char *song_{nullptr}; + /// Backing store for play(std::string). + std::string owned_song_; /// The time in milliseconds since microcontroller boot when the last note was started. uint32_t last_note_start_time_; /// The frequency of the current note in Hz. uint32_t output_freq_{0}; /// The gain of the output. float gain_{0.6f}; + /// Length of the song in bytes; songs are a few hundred bytes, so 16 bits is ample. + uint16_t song_len_{0}; + /// The current position in the song. + uint16_t position_{0}; + /// The duration of the current note in milliseconds. + uint16_t note_duration_{0}; + /// The duration of a whole note in milliseconds. + uint16_t wholenote_duration_; + /// The default duration of a note (e.g. 4 for a quarter note). + uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; + /// The default octave for a note. + uint8_t default_octave_{DEFAULT_OCTAVE}; + /// The current state of the RTTTL player. + State state_{State::STOPPED}; #ifdef USE_OUTPUT /// The output to write the sound to. @@ -113,6 +148,8 @@ class Rtttl final : public Component { /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; #endif + + friend class testing::RtttlParserAccess; }; } // namespace esphome::rtttl diff --git a/tests/components/rtttl/test_rtttl_parser.cpp b/tests/components/rtttl/test_rtttl_parser.cpp new file mode 100644 index 00000000000..3c706f29877 --- /dev/null +++ b/tests/components/rtttl/test_rtttl_parser.cpp @@ -0,0 +1,109 @@ +#include + +#include + +#include "esphome/components/rtttl/rtttl.h" + +namespace esphome::rtttl::testing { + +// Reads the parser state the tests check; Rtttl is final, so this is a friend rather than a subclass. +class RtttlParserAccess { + public: + static uint8_t denominator(const Rtttl &p) { return p.default_note_denominator_; } + static uint8_t octave(const Rtttl &p) { return p.default_octave_; } + static uint16_t wholenote(const Rtttl &p) { return p.wholenote_duration_; } + static uint16_t note_duration(const Rtttl &p) { return p.note_duration_; } + static uint32_t freq(const Rtttl &p) { return p.output_freq_; } + static size_t position(const Rtttl &p) { return p.position_; } + static char at(const Rtttl &p, size_t pos) { return p.song_at_(pos); } + static void set_running(Rtttl &p) { p.state_ = State::RUNNING; } +}; + +using Access = RtttlParserAccess; + +static const char SONG[] = "test:d=8,o=5,b=100:c"; + +TEST(RtttlParser, StaticSongParsesTheHeader) { + Rtttl player; + player.play(StaticSong{SONG}); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 100); + // Positioned on the first note after the second ':' + EXPECT_EQ(Access::position(player), 19u); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); +} + +TEST(RtttlParser, OwnedSongParsesTheSameAndOutlivesTheCaller) { + Rtttl player; + { + std::string song(SONG); + player.play(song); + } + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::at(player, 0), 't'); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); + EXPECT_EQ(Access::at(player, 100), '\0'); +} + +TEST(RtttlParser, ControlOrderDoesNotMatter) { + for (const char *song : {"a:b=100,o=5,d=8:c", "a:o=5,b=100,d=8:c", "a:d=8,b=100,o=5:c"}) { + Rtttl player; + player.play(StaticSong{song}); + EXPECT_EQ(Access::denominator(player), 8) << song; + EXPECT_EQ(Access::octave(player), 5) << song; + EXPECT_EQ(Access::wholenote(player), 2400) << song; + } +} + +TEST(RtttlParser, MissingControlsUseDefaults) { + Rtttl player; + player.play(StaticSong{"a::c"}); + EXPECT_EQ(Access::denominator(player), DEFAULT_NOTE_DENOMINATOR); + EXPECT_EQ(Access::octave(player), DEFAULT_OCTAVE); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 63); +} + +TEST(RtttlParser, NotesReadFromStaticStorage) { + Rtttl player; + player.play(StaticSong{"t:d=4,o=5,b=120:8c,p"}); + Access::set_running(player); + player.loop(); + // C5 at 523 Hz for an eighth of a 2000 ms whole note + EXPECT_EQ(Access::freq(player), 523u); + EXPECT_EQ(Access::note_duration(player), 250); + player.loop(); + // A pause for a default quarter note + EXPECT_EQ(Access::freq(player), 0u); + EXPECT_EQ(Access::note_duration(player), 500); + player.loop(); + // End of the song + EXPECT_EQ(Access::at(player, Access::position(player)), '\0'); +} + +TEST(RtttlParser, RefusesANewSongWhilePlaying) { + Rtttl player; + player.play(StaticSong{"first:d=8:c"}); + Access::set_running(player); + player.play(std::string("second:d=16:c")); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::at(player, 0), 'f'); +} + +TEST(RtttlParser, ClampsASongLongerThanTheLengthField) { + Rtttl player; + player.play(std::string(70000, 'a')); + EXPECT_EQ(Access::at(player, 65534), 'a'); + EXPECT_EQ(Access::at(player, 65535), '\0'); + EXPECT_EQ(Access::note_duration(player), 0); +} + +TEST(RtttlParser, RejectsASongWithoutAName) { + Rtttl player; + player.play(StaticSong{"no colon here"}); + EXPECT_EQ(Access::note_duration(player), 0); + EXPECT_EQ(Access::position(player), 13u); +} + +} // namespace esphome::rtttl::testing From 36e1d831d3b73b83bc2dec4bece3d6f7f9c339a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:18:35 -1000 Subject: [PATCH 310/651] [core] Add a decimal fast path to `parse_number` (#20639) --- esphome/core/helpers.cpp | 52 +++++++++++++++ esphome/core/helpers.h | 8 +-- tests/components/core/test_parse_float.cpp | 77 ++++++++++++++++++++++ 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 tests/components/core/test_parse_float.cpp diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 906f9fb12ab..872bb199084 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #ifdef USE_ESP32 @@ -315,6 +316,57 @@ size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size // Parsing & formatting +// A plain decimal converts exactly with one division: the digits accumulate +// into an integer that stays below 2^24 and the fraction's scale is a power of +// ten up to 1e9 (2^9 * 5^9), so both convert to float exactly and IEEE division +// rounds the quotient correctly, the same result strtof produces. +static constexpr uint32_t PARSE_FLOAT_MANTISSA_LIMIT = 1000000; // one more digit stays below 2^24 +static constexpr uint32_t PARSE_FLOAT_MAX_FRACTION_DIGITS = 9; + +optional parse_float(const char *str) { + const char *p = str; + const bool negative = *p == '-'; + if (negative || *p == '+') + p++; + const char *digits = p; + const char *dot = nullptr; + uint32_t mantissa = 0; + for (;; p++) { + const uint32_t d = static_cast(*p) - '0'; + if (d > 9) { + if (*p == '.' && dot == nullptr) { + dot = p; + continue; + } + break; + } + if (mantissa >= PARSE_FLOAT_MANTISSA_LIMIT) + break; + mantissa = mantissa * 10 + d; + } + const uint32_t fraction_digits = dot != nullptr ? p - dot - 1 : 0; + const bool any_digit = p - digits > (dot != nullptr ? 1 : 0); + if (*p == '\0' && any_digit && fraction_digits <= PARSE_FLOAT_MAX_FRACTION_DIGITS) { + // The mantissa is exact in a float, so the sign goes on before the one rounding step + float value = static_cast(mantissa); + if (negative) + value = -value; + if (fraction_digits != 0) { + uint32_t scale = 10; + for (uint32_t i = 1; i < fraction_digits; i++) + scale *= 10; + value /= static_cast(scale); + } + return value; + } + // Exponents, hex, inf, nan, leading whitespace and longer inputs take the libc path. + char *end = nullptr; + const float value = ::strtof(str, &end); + if (end == str || *end != '\0' || value == HUGE_VALF) + return {}; + return value; +} + size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { size_t chars = std::min(length, 2 * count); for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index bcb03b010f6..15f247cadbc 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1299,13 +1299,11 @@ template::value && std::is_signed parse_number(const std::string &str) { return parse_number(str.c_str()); } +/// Parse a decimal floating-point number; plain decimals skip the libc parser. +optional parse_float(const char *str); /// Parse a decimal floating-point number from a null-terminated string. template::value), int> = 0> optional parse_number(const char *str) { - char *end = nullptr; - float value = ::strtof(str, &end); - if (end == str || *end != '\0' || value == HUGE_VALF) - return {}; - return value; + return parse_float(str); } /// Parse a decimal floating-point number. template::value), int> = 0> diff --git a/tests/components/core/test_parse_float.cpp b/tests/components/core/test_parse_float.cpp new file mode 100644 index 00000000000..1d52f8afc8f --- /dev/null +++ b/tests/components/core/test_parse_float.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static uint32_t bits(float value) { + uint32_t out; + memcpy(&out, &value, sizeof(out)); + return out; +} + +// strtof is the reference: every accepted input must give the same bits. +static void expect_matches_strtof(const char *str) { + char *end = nullptr; + const float expected = ::strtof(str, &end); + const auto parsed = parse_number(str); + if (!parsed.has_value()) { + ADD_FAILURE() << str << " was rejected"; + return; + } + const float actual = parsed.value(); + EXPECT_EQ(bits(actual), bits(expected)) << str << " parsed " << actual << " expected " << expected; +} + +TEST(ParseFloat, PlainDecimalsMatchStrtof) { + for (const char *str : + {"0", "-0", "+0", "1", "-1", "100.50", "99.75", "0.1", "0.3", + "2.675", "9999999", "0.9999999", "1234.567", "-1234.567", "0.000001", "0.000000001", "12.", ".5", + "-.5", "007", "00.50", "123.4567", "0.009999999", "0.001234567", "-0.009999999"}) { + expect_matches_strtof(str); + } +} + +TEST(ParseFloat, SweepOfTwoDecimalValuesMatchesStrtof) { + char buf[16]; + for (int i = -20000; i <= 20000; i++) { + snprintf(buf, sizeof(buf), "%s%d.%02d", i < 0 ? "-" : "", std::abs(i) / 100, std::abs(i) % 100); + expect_matches_strtof(buf); + } +} + +// A fraction longer than the fast path accepts must fall back, however long it is. +TEST(ParseFloat, LongFractionsMatchStrtof) { + std::string str = "0."; + str.append(300, '0'); + str += "25"; + expect_matches_strtof(str.c_str()); + expect_matches_strtof(("-" + str).c_str()); + str = "1."; + str.append(300, '0'); + expect_matches_strtof(str.c_str()); +} + +TEST(ParseFloat, LibcPathInputsMatchStrtof) { + for (const char *str : {"1e3", "-2.5E-2", "0x10", "nan", " 12", "123456789", "16777215", "1.0000000", "0.0000000001", + "0.00000000001", "1.23456789", "3.4028235e38"}) { + expect_matches_strtof(str); + } +} + +// Positive overflow and a positive literal infinity both come back as HUGE_VALF from strtof, and the parser +// rejects both; the negative forms pass through, as they did before the fast path. +TEST(ParseFloat, RejectsInvalidInput) { + for (const char *str : + {"", "-", "+", ".", "abc", "12abc", "1.2.3", "1,5", "12 ", "--1", "1e", "1e400", "inf", "infinity"}) { + EXPECT_FALSE(parse_number(str).has_value()) << str; + } +} + +} // namespace esphome::core::testing From f61955baa2d7f1b44ce5e8fa58f027f837546c52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:21:23 -1000 Subject: [PATCH 311/651] [core] Declare the remaining log tags with ESPHOME_LOG_TAG (#20666) --- esphome/components/bedjet/bedjet_const.h | 3 ++- esphome/components/bme280_i2c/bme280_i2c.h | 3 ++- esphome/components/bmp280_i2c/bmp280_i2c.h | 3 ++- esphome/components/bmp581_i2c/bmp581_i2c.h | 3 ++- esphome/components/chsc6x/chsc6x_touchscreen.h | 2 +- esphome/components/cst816/touchscreen/cst816_touchscreen.h | 2 +- esphome/components/cst9220/touchscreen/cst9220_touchscreen.h | 2 +- esphome/components/ezo/ezo.h | 3 ++- esphome/components/lvgl/lvgl_esphome.h | 2 +- esphome/components/mapping/mapping.h | 2 +- esphome/components/mipi_dsi/mipi_dsi.h | 2 +- esphome/components/mipi_rgb/mipi_rgb.h | 3 ++- esphome/components/mipi_spi/mipi_spi.h | 3 ++- esphome/components/runtime_stats/runtime_stats.h | 2 +- esphome/components/sdl/sdl_esphome.h | 2 +- esphome/components/spi_led_strip/spi_led_strip.h | 2 +- esphome/components/st7701s/st7701s.h | 3 ++- esphome/components/usb_host/usb_host.h | 3 ++- esphome/components/usb_uart/usb_uart.h | 3 ++- esphome/components/zio_ultrasonic/zio_ultrasonic.h | 3 ++- esphome/core/log.h | 5 +++-- esphome/core/wake/wake_host.cpp | 2 +- 22 files changed, 35 insertions(+), 23 deletions(-) diff --git a/esphome/components/bedjet/bedjet_const.h b/esphome/components/bedjet/bedjet_const.h index eb777a6a834..aa84cdddde9 100644 --- a/esphome/components/bedjet/bedjet_const.h +++ b/esphome/components/bedjet/bedjet_const.h @@ -1,10 +1,11 @@ #pragma once #include +#include "esphome/core/log.h" namespace esphome::bedjet { -static const char *const TAG = "bedjet"; +ESPHOME_LOG_TAG(TAG, "bedjet"); /// Converts a BedJet fan step to a speed percentage, in the range of 5% to 100%. inline static uint8_t bedjet_fan_step_to_speed(const uint8_t fan) { diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index 501556d3c40..45c3e5b260f 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bme280_base/bme280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bme280_i2c { -static const char *const TAG = "bme280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bme280_i2c.sensor"); class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index a19203ff0a0..f2ec93176ef 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp280_base/bmp280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp280_i2c { -static const char *const TAG = "bmp280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp280_i2c.sensor"); /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { diff --git a/esphome/components/bmp581_i2c/bmp581_i2c.h b/esphome/components/bmp581_i2c/bmp581_i2c.h index 126ffd6a606..24929c7d8b0 100644 --- a/esphome/components/bmp581_i2c/bmp581_i2c.h +++ b/esphome/components/bmp581_i2c/bmp581_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp581_base/bmp581_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp581_i2c { -static const char *const TAG = "bmp581_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp581_i2c.sensor"); /// This class implements support for the BMP581 Temperature+Pressure i2c sensor. class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 84e539e5f25..9278546ee94 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::chsc6x { -static const char *const TAG = "chsc6x.touchscreen"; +ESPHOME_LOG_TAG(TAG, "chsc6x.touchscreen"); static const uint8_t CHSC6X_REG_STATUS = 0x00; static const uint8_t CHSC6X_REG_STATUS_TOUCH = 0x00; diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 84b561c7345..72dda4e9854 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst816 { -static const char *const TAG = "cst816.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst816.touchscreen"); static const uint8_t REG_STATUS = 0x00; static const uint8_t REG_TOUCH_NUM = 0x02; diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h index 17050e2429a..5a8e043d654 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst9220 { -static const char *const TAG = "cst9220.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst9220.touchscreen"); // The CST92xx family uses 16-bit (big-endian) register addresses. static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index a20419eceb1..7a3e84f56ca 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -3,11 +3,12 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" #include namespace esphome::ezo { -static const char *const TAG = "ezo.sensor"; +ESPHOME_LOG_TAG(TAG, "ezo.sensor"); enum EzoCommandType : uint8_t { EZO_READ = 0, diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 4da8a6a1a30..75f926d865e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -214,7 +214,7 @@ enum class Orientation : uint8_t { }; class LvglComponent final : public PollingComponent { - constexpr static const char *const TAG = "lvgl"; + constexpr static const char *const TAG = "lvgl"; // NOLINT: lvgl does not build for ESP8266 public: LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, diff --git a/esphome/components/mapping/mapping.h b/esphome/components/mapping/mapping.h index d6790caa35d..e4985c91fa1 100644 --- a/esphome/components/mapping/mapping.h +++ b/esphome/components/mapping/mapping.h @@ -19,7 +19,7 @@ using alloc_string_t = std::basic_string, RAMAlloca * @tparam V The type of the value in the mapping. Should be a basic type or pointer. */ -static const char *const TAG = "mapping"; +ESPHOME_LOG_TAG(TAG, "mapping"); template class Mapping { public: diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 4243d3116cf..4df22354681 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -18,7 +18,7 @@ namespace esphome::mipi_dsi { -constexpr static const char *const TAG = "display.mipi_dsi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_dsi"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 8b29e273abd..01b1ebee91f 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -2,6 +2,7 @@ #if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S31) #include "esphome/core/gpio.h" +#include "esphome/core/log.h" #include "esphome/components/display/display.h" #include #ifdef USE_SPI @@ -10,7 +11,7 @@ namespace esphome::mipi_rgb { -constexpr static const char *const TAG = "display.mipi_rgb"; +ESPHOME_LOG_TAG(TAG, "display.mipi_rgb"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48a2cafbdd3..96f26ef19b7 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -7,10 +7,11 @@ #include "esphome/components/display/display_color_utils.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" namespace esphome::mipi_spi { -constexpr static const char *const TAG = "display.mipi_spi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_spi"); // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 2c783a0df30..61b152d7d36 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -15,7 +15,7 @@ class Component; // Forward declaration namespace runtime_stats { -static const char *const TAG = "runtime_stats"; +ESPHOME_LOG_TAG(TAG, "runtime_stats"); class RuntimeStatsCollector final { public: diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index 54f0d2573fd..c58de643cc8 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -13,7 +13,7 @@ namespace esphome::sdl { -constexpr static const char *const TAG = "sdl"; +ESPHOME_LOG_TAG(TAG, "sdl"); class Sdl final : public display::Display, public snapshot::Snapshot { public: diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index 20b9c25c2e9..a5b10a0e9a6 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -7,7 +7,7 @@ namespace esphome::spi_led_strip { -static const char *const TAG = "spi_led_strip"; +ESPHOME_LOG_TAG(TAG, "spi_led_strip"); class SpiLedStrip final : public light::AddressableLight, public spi::SPIDevice { diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index d44f8c68590..fe29d794141 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -8,13 +8,14 @@ #include "esphome/core/component.h" #include "esphome/components/spi/spi.h" #include "esphome/components/display/display.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_rgb.h" namespace esphome::st7701s { -constexpr static const char *const TAG = "display.st7701s"; +ESPHOME_LOG_TAG(TAG, "display.st7701s"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index aaab8e22a18..bbb6f15f292 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -12,6 +12,7 @@ #include #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -39,7 +40,7 @@ namespace esphome::usb_host { // - Main loop controls backpressure by deciding when to restart after consuming data // The atomic bitmask ensures thread-safe allocation/deallocation without mutex blocking. -static const char *const TAG = "usb_host"; +ESPHOME_LOG_TAG(TAG, "usb_host"); // Forward declarations struct TransferRequest; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 4e52fb60e20..b54641884e6 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -9,6 +9,7 @@ #include "esphome/components/usb_host/usb_host.h" #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -19,7 +20,7 @@ class USBUartComponent; class USBUartChannelBase; class USBUartTypePL2303; -static const char *const TAG = "usb_uart"; +ESPHOME_LOG_TAG(TAG, "usb_uart"); static constexpr uint8_t USB_CDC_SUBCLASS_ACM = 0x02; static constexpr uint8_t USB_SUBCLASS_COMMON = 0x02; diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index 1dbce873073..12ea8e8ade0 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -3,8 +3,9 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" -static const char *const TAG = "Zio Ultrasonic"; +ESPHOME_LOG_TAG(TAG, "Zio Ultrasonic"); namespace esphome::zio_ultrasonic { diff --git a/esphome/core/log.h b/esphome/core/log.h index 145ba3a6f8a..840da292aee 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -190,8 +190,9 @@ struct LogString; #endif -// Declares a static log tag at namespace scope in a .cpp file; on ESP8266 it lives in flash, so read it with -// the _P functions, never strcmp/strlen. +// Declares a static log tag at namespace scope, in a source file or a header; on ESP8266 it lives in flash, so +// read it with the _P functions, never strcmp/strlen. Not usable for a class member: the ESP8266 form is a +// PROGMEM array, which cannot be initialized in a class. #ifdef USE_ESP8266 #define ESPHOME_LOG_TAG(name, tag) static const char name[] PROGMEM = tag #else diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9fc1c717032..e2353db28f4 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -22,7 +22,7 @@ namespace esphome { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -static const char *const TAG = "wake"; +ESPHOME_LOG_TAG(TAG, "wake"); namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and From 92f6ab836744c0ed25ddb7e37c9245be37eb4724 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:21:24 -1000 Subject: [PATCH 312/651] [ci] Lint log tags in headers and core (#20668) --- esphome/components/ili9xxx/ili9xxx_display.h | 3 ++- esphome/components/qspi_dbi/qspi_dbi.h | 3 ++- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 2 +- script/ci-custom.py | 10 ++++--- tests/script/test_ci_custom.py | 28 +++++++++++++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/esphome/components/ili9xxx/ili9xxx_display.h b/esphome/components/ili9xxx/ili9xxx_display.h index 2529e54021b..77cee44ae68 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.h +++ b/esphome/components/ili9xxx/ili9xxx_display.h @@ -2,12 +2,13 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "ili9xxx_defines.h" #include "ili9xxx_init.h" namespace esphome::ili9xxx { -static const char *const TAG = "ili9xxx"; +ESPHOME_LOG_TAG(TAG, "ili9xxx"); const size_t ILI9XXX_TRANSFER_BUFFER_SIZE = 126; // ensure this is divisible by 6 enum ILI9XXXColorMode { diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index 8a1bf0d4c24..eca062f4711 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -8,12 +8,13 @@ #include "esphome/components/display/display.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_rgb.h" namespace esphome::qspi_dbi { -constexpr static const char *const TAG = "display.qspi_dbi"; +ESPHOME_LOG_TAG(TAG, "display.qspi_dbi"); static const uint8_t SW_RESET_CMD = 0x01; static const uint8_t SLEEP_OUT = 0x11; static const uint8_t NORON = 0x13; diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index df0e2b0b167..ad2f6e80c26 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -16,7 +16,7 @@ namespace esphome::rpi_dpi_rgb { -constexpr static const char *const TAG = "rpi_dpi_rgb"; +ESPHOME_LOG_TAG(TAG, "rpi_dpi_rgb"); class RpiDpiRgb final : public display::Display { public: diff --git a/script/ci-custom.py b/script/ci-custom.py index 8361387af01..4431f62cedd 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1269,11 +1269,15 @@ def lint_no_std_nothrow(fname, match): ) +# Every C++ source and header; plain C files cannot use the macro +LOG_TAG_INCLUDE = ["esphome/*.cpp", "esphome/*.h"] + + @lint_re_check( r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?TAG|\s(?:const\s+)?TAG\s*\[\w*\])" - r"\s*(?:=\s*\{?|[{(])\s*\"", + r"\s*(?:=\s*\{?|[{(])\s*\"" + CPP_RE_EOL, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_macro(fname, match): return ( @@ -1292,7 +1296,7 @@ def lint_log_tag_macro(fname, match): r"|\bstd::string\s+\w+\s*[={(]\s*TAG\b", mask=True, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_as_string(fname, match): return ( diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index f303d19bb0d..621ffd93a42 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -400,6 +400,7 @@ def test_ternary_error_message_names_the_literal() -> None: 'static constexpr char TAG[] = {"x"};', 'static const char TAG[8] = "x";', ' static const char *const TAG = "x";', + 'constexpr static const char *const TAG = "x";', ], ) def test_log_tag_macro_flags_plain_tags(line: str) -> None: @@ -408,12 +409,37 @@ def test_log_tag_macro_flags_plain_tags(line: str) -> None: @pytest.mark.parametrize( "line", - ['ESPHOME_LOG_TAG(TAG, "x");', 'static const char *const NAME = "x";'], + [ + 'ESPHOME_LOG_TAG(TAG, "x");', + 'static const char *const NAME = "x";', + 'static const char *const TAG = "x"; // NOLINT', + ], ) def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: assert not ci_custom.lint_log_tag_macro("test.cpp", line + "\n") +@pytest.mark.parametrize( + ("fname", "covered"), + [ + ("esphome/components/x/x.cpp", True), + ("esphome/components/x/x.h", True), + ("esphome/components/x/sub/x.h", True), + ("esphome/core/wake/wake_host.cpp", True), + ("esphome/components/x/x.c", False), + ], +) +def test_log_tag_lints_cover_headers_and_core(fname: str, covered: bool) -> None: + for name, line in ( + ("lint_log_tag_macro", 'static const char *const TAG = "x";\n'), + ("lint_log_tag_as_string", "strcmp(TAG, name);\n"), + ): + lint = next( + c for c in ci_custom.LINT_CONTENT_CHECKS if c["func"].__name__ == name + ) + assert bool(ci_custom.run_check(lint, fname, fname, line)) is covered + + @pytest.mark.parametrize( "line", [ From c6e90de262e87ba0d05db0a7d705fd3f7a0f3a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:21:24 -1000 Subject: [PATCH 313/651] [ci] Catch out-of-class member log tags and keep midea's in flash (#20674) --- esphome/components/ble_client/automation.cpp | 2 +- esphome/components/midea/ac_adapter.cpp | 2 +- esphome/components/midea/ac_adapter.h | 2 +- script/ci-custom.py | 8 +++++--- tests/script/test_ci_custom.py | 5 +++++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/esphome/components/ble_client/automation.cpp b/esphome/components/ble_client/automation.cpp index cd2802f6173..b0fac5b1218 100644 --- a/esphome/components/ble_client/automation.cpp +++ b/esphome/components/ble_client/automation.cpp @@ -4,7 +4,7 @@ namespace esphome::ble_client { -const char *const Automation::TAG = "ble_client.automation"; +const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266 } // namespace esphome::ble_client diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 30771d25ca2..e362c241e8c 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -5,7 +5,7 @@ namespace esphome::midea::ac { -const char *const Constants::TAG = "midea"; +const char Constants::TAG[] PROGMEM = "midea"; const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; const char *const Constants::SILENT = "Silent"; const char *const Constants::TURBO = "Turbo"; diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index 4a888ee8ff2..62e93d2465a 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -17,7 +17,7 @@ using MideaPreset = dudanov::midea::ac::Preset; class Constants { public: - static const char *const TAG; + static const char TAG[]; // PROGMEM, like ESPHOME_LOG_TAG static const char *const FREEZE_PROTECTION; static const char *const SILENT; static const char *const TURBO; diff --git a/script/ci-custom.py b/script/ci-custom.py index 4431f62cedd..5b268d62969 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1274,7 +1274,7 @@ LOG_TAG_INCLUDE = ["esphome/*.cpp", "esphome/*.h"] @lint_re_check( - r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?TAG|\s(?:const\s+)?TAG\s*\[\w*\])" + r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?(?:\w+::)*TAG|\s(?:const\s+)?(?:\w+::)*TAG\s*\[\w*\])" r"\s*(?:=\s*\{?|[{(])\s*\"" + CPP_RE_EOL, prefilter="TAG", include=LOG_TAG_INCLUDE, @@ -1285,6 +1285,8 @@ def lint_log_tag_macro(fname, match): f"ESP8266.\n" f" Before: {highlight('static const char *const TAG = "name";')}\n" f" After: {highlight('ESPHOME_LOG_TAG(TAG, "name");')}\n" + f"A class member tag cannot use the macro; declare {highlight('static const char TAG[];')} in the class " + f"and define {highlight('const char Foo::TAG[] PROGMEM = "name";')} in the .cpp.\n" f"Never pass such a TAG to set_timeout/set_interval names or string functions.\n" f"(If strictly necessary, add `// NOLINT` to the end of the line)" ) @@ -1292,8 +1294,8 @@ def lint_log_tag_macro(fname, match): @lint_re_check( r"(?:\b(?:set_timeout|set_interval|set_retry|cancel_timeout|cancel_interval|cancel_retry|defer)" - r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*TAG\b" - r"|\bstd::string\s+\w+\s*[={(]\s*TAG\b", + r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*(?:\w+::)*TAG\b" + r"|\bstd::string\s+\w+\s*[={(]\s*(?:\w+::)*TAG\b", mask=True, prefilter="TAG", include=LOG_TAG_INCLUDE, diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 621ffd93a42..e65bf9755e3 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -401,6 +401,8 @@ def test_ternary_error_message_names_the_literal() -> None: 'static const char TAG[8] = "x";', ' static const char *const TAG = "x";', 'constexpr static const char *const TAG = "x";', + 'const char *const Constants::TAG = "x";', + 'const char Constants::TAG[] = "x";', ], ) def test_log_tag_macro_flags_plain_tags(line: str) -> None: @@ -413,6 +415,7 @@ def test_log_tag_macro_flags_plain_tags(line: str) -> None: 'ESPHOME_LOG_TAG(TAG, "x");', 'static const char *const NAME = "x";', 'static const char *const TAG = "x"; // NOLINT', + 'const char Constants::TAG[] PROGMEM = "x";', ], ) def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: @@ -452,6 +455,8 @@ def test_log_tag_lints_cover_headers_and_core(fname: str, covered: bool) -> None "std::string(TAG);", "std::string name = TAG;", "std::string name{TAG};", + "strlen(Constants::TAG);", + "this->set_timeout(Constants::TAG, 100, cb);", ], ) def test_log_tag_as_string_flags_string_uses(line: str) -> None: From 3cdcf0417a62809452aba6abb054654f14ec050d Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:31:08 +0100 Subject: [PATCH 314/651] [lvgl] Make the list_outside_block tests work offline (#20676) --- tests/component_tests/lvgl/config/image.png | Bin 0 -> 78 bytes .../lvgl/config/list_outside_block_test.yaml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests/component_tests/lvgl/config/image.png diff --git a/tests/component_tests/lvgl/config/image.png b/tests/component_tests/lvgl/config/image.png new file mode 100644 index 0000000000000000000000000000000000000000..dec8dd15317b06fcf0915663767b66aef8ddd225 GIT binary patch literal 78 zcmeAS@N?(olHy`uVBq!ia0y~yVBi2@Mg|53hBqb6Y77hvqMj~}Ar-fhf1JN?Vgoym ewB$eIKkN+pkJ(J(vVYG5DfD#pb6Mw<&;$Ua^c4;O literal 0 HcmV?d00001 diff --git a/tests/component_tests/lvgl/config/list_outside_block_test.yaml b/tests/component_tests/lvgl/config/list_outside_block_test.yaml index 44407348999..58898c89b12 100644 --- a/tests/component_tests/lvgl/config/list_outside_block_test.yaml +++ b/tests/component_tests/lvgl/config/list_outside_block_test.yaml @@ -30,7 +30,7 @@ display: image: - platform: file - file: mdi:battery + file: image.png id: my_image resize: 8x8 type: binary From 95711d83d1cb164f197165703bb04e994a65297b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:29:29 -1000 Subject: [PATCH 315/651] [ili9xxx] Set removal of the deprecated component for 2027.5.0 (#20687) --- esphome/components/ili9xxx/__init__.py | 1 + esphome/components/ili9xxx/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index 2803b439156..3efebabd885 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -3,4 +3,5 @@ DOMAIN = "ili9xxx" DEPRECATED_COMPONENT = """ The 'ili9xxx' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 64f87c167c1..5928eb5771f 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -220,8 +220,10 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'ili9xxx' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) rhs = MODELS[config[CONF_MODEL]].new() var = cg.Pvariable(config[CONF_ID], rhs) From 96902cc1222b64cd288fcb805563fbadfcc653ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:29:52 -1000 Subject: [PATCH 316/651] [qspi_dbi] Set removal of the deprecated component for 2027.5.0 (#20690) --- esphome/components/qspi_dbi/__init__.py | 1 + esphome/components/qspi_dbi/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/qspi_dbi/__init__.py b/esphome/components/qspi_dbi/__init__.py index abf64f675af..a6dee79abb0 100644 --- a/esphome/components/qspi_dbi/__init__.py +++ b/esphome/components/qspi_dbi/__init__.py @@ -6,4 +6,5 @@ CONF_DRAW_FROM_ORIGIN = "draw_from_origin" DEPRECATED_COMPONENT = """ The 'qspi_dbi' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index dce1a95687d..cf2a6441a6c 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -165,8 +165,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'qspi_dbi' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From 9238fcc72727f4f1ca575afc706e4a3250c1c0cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:30:21 -1000 Subject: [PATCH 317/651] [st7735] Set removal of the deprecated component for 2027.5.0 (#20688) --- esphome/components/st7735/__init__.py | 1 + esphome/components/st7735/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index 176eb233e17..d6e5035a9a4 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -7,4 +7,5 @@ st7735_ns = cg.esphome_ns.namespace("st7735") DEPRECATED_COMPONENT = """ The 'st7735' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 5cbcee12ab3..d6774f4639e 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -92,8 +92,10 @@ async def setup_st7735(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7735' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7735' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable( config[CONF_ID], From 3b261d2cf0dca7517f0050f50f1444679288eb18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:30:52 -1000 Subject: [PATCH 318/651] [st7789v] Set removal of the deprecated component for 2027.5.0 (#20689) --- esphome/components/st7789v/__init__.py | 1 + esphome/components/st7789v/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/st7789v/__init__.py b/esphome/components/st7789v/__init__.py index e67f4809128..dfe139d0183 100644 --- a/esphome/components/st7789v/__init__.py +++ b/esphome/components/st7789v/__init__.py @@ -7,4 +7,5 @@ st7789v_ns = cg.esphome_ns.namespace("st7789v") DEPRECATED_COMPONENT = """ The 'st7789v' component is deprecated and no new functionality will be added to it. PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index fa72c7c328a..82d3f0b26d6 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -183,8 +183,10 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7789v' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From 52a5e6a0072faeb94264e58d8c102f3e0e264ed5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:32:18 -1000 Subject: [PATCH 319/651] [rpi_dpi_rgb] Set removal of the deprecated component for 2027.5.0 (#20691) --- esphome/components/rpi_dpi_rgb/__init__.py | 1 + esphome/components/rpi_dpi_rgb/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/rpi_dpi_rgb/__init__.py b/esphome/components/rpi_dpi_rgb/__init__.py index 20629fd7305..6b274b9b734 100644 --- a/esphome/components/rpi_dpi_rgb/__init__.py +++ b/esphome/components/rpi_dpi_rgb/__init__.py @@ -4,4 +4,5 @@ DOMAIN = "rpi_dpi_rgb" DEPRECATED_COMPONENT = """ The 'rpi_dpi_rgb' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_rgb' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 1ca29a32597..be6ca6c2a74 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -132,8 +132,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." + "The 'rpi_dpi_rgb' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_rgb' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From f505212e64c4a8975e5d74a5aa2ae7a018399e9e Mon Sep 17 00:00:00 2001 From: Felipe Guedes Date: Wed, 14 Oct 2026 02:03:05 +0100 Subject: [PATCH 320/651] [epaper_spi] Add Waveshare 2.66" e-Paper Module (B) support (#20546) --- .../epaper_spi/models/waveshare_b.py | 8 +++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py index 688e7164566..67a0cf5f233 100644 --- a/esphome/components/epaper_spi/models/waveshare_b.py +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -24,3 +24,11 @@ WaveshareB( data_rate="10MHz", minimum_update_interval="1s", ) + +WaveshareB( + "waveshare-2.66in-b", + width=152, + height=296, + data_rate="10MHz", + minimum_update_interval="15s", +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 3839b4ff87a..a2c343be836 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -234,6 +234,27 @@ 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)); + # Waveshare 2.66" B 3-color e-paper (152x296, BWR, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.66in-b + 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 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + 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 From c9cf0ad92eb085ba2fccaedfda4e91abe6c029b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:37:36 -0400 Subject: [PATCH 321/651] Bump platformdirs from 4.12.3 to 4.12.4 (#20697) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a9dbaa3b8d7..fcbcb171246 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 -platformdirs==4.12.3 # native esp-idf toolchain global cache dir +platformdirs==4.12.4 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 576d3325febf66cdcd32e2637a330ef08b2f2c00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:37:47 -1000 Subject: [PATCH 322/651] Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 in /.github/actions/restore-python (#20700) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index affe8fc2d6b..4b8e10053ea 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From c95c17c4e7bd0a948040a18bc1b8cc69195e844c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:17 -1000 Subject: [PATCH 323/651] Bump pyupgrade from 3.21.2 to 3.22.0 (#20698) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 727e8e2f9aa..e06494573c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,7 +36,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + rev: v3.22.0 hooks: - id: pyupgrade args: [--py312-plus] diff --git a/requirements_test.txt b/requirements_test.txt index 0f7d3762554..91ddfa7102d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 426a4a2dce80c4bc823e77d298ba25d4d9ff5da2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:26 -1000 Subject: [PATCH 324/651] Bump filelock from 4.0.12 to 4.1.0 (#20696) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fcbcb171246..4ead538d8e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.4 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From 81e5b50087d7ab0fca84824a5092e65aabcf1266 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:35 -1000 Subject: [PATCH 325/651] Bump backports-zstd from 1.7.0 to 1.8.0 (#20695) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4ead538d8e1..ca8c489116d 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" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 +backports.zstd==1.8.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 From d452dbce30fd4195c8003430b8abfdd249566f56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:38:39 +0000 Subject: [PATCH 326/651] Bump actions/download-artifact from 8.0.1 to 8.0.2 (#20703) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5bcdb22cb86..f3457aad4ea 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -210,7 +210,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download image artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: compile-test-image.tar.zst - name: Load image diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 285d6869e12..fee9fea64e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1740,13 +1740,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efccad9ad96..add0f80b243 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,7 +195,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: digests-* path: /tmp/digests From 7cb5906d2abaebf4e98bd44b0fa795623fa955ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:38:42 +0000 Subject: [PATCH 327/651] Bump actions/upload-artifact from 7.0.1 to 7.0.2 (#20702) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index d0bd1de9ef1..a516e868bef 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -97,7 +97,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index f3457aad4ea..8546daa555f 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -128,7 +128,7 @@ jobs: - name: Upload compile-test image artifact if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # The tar is already compressed, so upload it as-is. archive: false # skips the redundant zip and makes the file name the artifact name diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fee9fea64e1..741a0d7deb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -477,7 +477,7 @@ jobs: # Tests that compare rendered output write the image they actually got here, so a # failure can be looked at without reproducing the whole build locally. if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: integration-test-artifacts-${{ matrix.bucket.name }} path: test_artifacts/ @@ -488,7 +488,7 @@ jobs: # script/update_integration_test_durations.py; only full matrix dev # runs produce usable data. if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-integration-${{ strategy.job-index }} path: junit-integration.xml @@ -528,7 +528,7 @@ jobs: script/check_import_time.py --check --har importtime.har - name: Upload waterfall HAR if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: import-time-waterfall path: importtime.har @@ -1647,7 +1647,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-target path: memory-analysis-target.json @@ -1710,7 +1710,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-pr path: memory-analysis-pr.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index add0f80b243..c0991a6308f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,7 +162,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests From c5522abc7137eef0742020c2678c77d4ab26a7e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:39:11 -0400 Subject: [PATCH 328/651] Bump github/codeql-action/analyze from 4.38.2 to 4.38.3 (#20699) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da02a5dd09f..4d441c73c14 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:${{matrix.language}}" From bc39d56bb48a9eb10b37d8e86194acf5c9eb3295 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:29 -1000 Subject: [PATCH 329/651] Bump CodSpeedHQ/action from 5.4.0 to 5.4.2 (#20705) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 741a0d7deb7..3e27a3aeb52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -626,7 +626,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0 + uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2 with: run: | . venv/bin/activate From 16134ee993ddb5f8e416f8c9fd8a63b72b5e325f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:38 -1000 Subject: [PATCH 330/651] Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 (#20704) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index a516e868bef..9ca9d68d525 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e27a3aeb52..d68bb93ec9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -438,7 +438,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1448,7 +1448,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a0725374f92..ba0404011be 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 8fce95454ae731ec8fa55926c7b9e79e3f4fb25b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:49 -1000 Subject: [PATCH 331/651] Bump github/codeql-action/init from 4.38.2 to 4.38.3 (#20701) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4d441c73c14..caf8c6d8c8f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 59f55dbe9ee457948402e68b36c21e13e95a4b81 Mon Sep 17 00:00:00 2001 From: fredkotze <40389058+fredkotze@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:40:28 +1100 Subject: [PATCH 332/651] [epaper_spi] Add Waveshare 2.13in-G (JD79660) display models (#18141) Co-authored-by: J. Nick Koston --- .../components/epaper_spi/models/jd79660.py | 44 ++++++++++++++++++- .../epaper_spi/test.esp32-s3-idf.yaml | 34 ++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index a70d3fe522e..783dc8953a5 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -87,6 +87,38 @@ jd79660.extend( ), ) +# Waveshare 2.13-G (V2 hardware/init revision, PCB marked "rev 2.1" or later) +# +# Vendor init derived from vendor sample code +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: panel is 122px wide, but vendor code declares the TRES register as a fixed +# 124 on this revision. width=124 is declared here (rather than 122) purely so the +# transferred bytes-per-row matches what the vendor init tells the IC to expect; the +# extra 2 columns fall outside the visible glass. The model width must equal the +# TRES value in initsequence, so overriding dimensions: in YAML brings the shear back. +# +# fmt: off +jd79660.extend( + "Waveshare-2.13in-G", + width=124, + height=250, + + initsequence=( + (0x61, 0x00, 0x7C, 0x00, 0xFA,), # TRES: 124x250 fixed (124 = TRES stride value) + (0xE9, 0x01,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), + fast_update=( + (0xE0, 0x02,), + (0xE6, 0x5A,), + (0xA5,), + ), +) + # Waveshare 7.5-H # # Vendor init derived from vendor sample code @@ -130,7 +162,7 @@ jd79660.extend( # Note: busy pin uses LOW=busy, HIGH=idle. # # fmt: off -jd79660.extend( +gdey0213f51 = jd79660.extend( "goodisplay-gdey0213f51-2.13", width=128, height=250, @@ -154,3 +186,13 @@ jd79660.extend( (0x04,), ), ) + +# Waveshare 2.13-G (V1 hardware/init revision, older stock): the same panel and init as the Good +# Display entry above, byte for byte, so it only adds the name. Vendor sample: +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: width=128 is the TRES value the init sequence programs, not the 122 visible columns; overriding +# dimensions: in YAML shears the image. Not tested on V1 hardware. +gdey0213f51.extend("Waveshare-2.13in-G-V1") diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index a2c343be836..f6091e5715c 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -87,6 +87,40 @@ display: number: GPIO4 inverted: true + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g + 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 + + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g-v1 + 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 + - platform: epaper_spi model: seeed-reterminal-e1002 - platform: epaper_spi From 9ee4314bc2642b9c4c53317a54ecf46e2facdca4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:41:30 -1000 Subject: [PATCH 333/651] Bump esptool from 5.4.0 to 5.5.0 (#20693) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca8c489116d..5397959f63f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.5 # from time pyserial==3.5 platformio==6.2.0 -esptool==5.4.0 +esptool==5.5.0 click==8.5.0 aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi From 613973b4327fb933b7101c14740b350a1431abb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:53:44 -0400 Subject: [PATCH 334/651] Bump ruff from 0.16.10 to 0.17.0 (#20694) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- esphome/components/zigbee/zigbee_zephyr.py | 4 ++-- requirements_test.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e06494573c6..48bb3a69da4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.10 + rev: v0.17.0 hooks: # Run the linter. - id: ruff diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index d468c9881d0..f7da147b88f 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,4 +1,4 @@ -import datetime +import datetime as dt import random import esphome.codegen as cg @@ -176,7 +176,7 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.date_code, # Local build time, matching the esp32 implementation # (App.get_build_time() in C++). - datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), + dt.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), ), zigbee_assign( basic_attrs.power_source, diff --git a/requirements_test.txt b/requirements_test.txt index 91ddfa7102d..65e8a63bfe9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 4b8af08155eebac3ae39da1412cb7b92f04bdf42 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 13 Oct 2026 20:57:59 -0500 Subject: [PATCH 335/651] [network] Notify listeners when the active interface changes (#20684) Co-authored-by: Claude Opus 5.5 --- esphome/components/mdns/__init__.py | 19 +++++++ esphome/components/mdns/mdns_component.cpp | 22 ++++++-- esphome/components/mdns/mdns_component.h | 21 +++++--- esphome/components/mdns/mdns_esp32.cpp | 5 +- esphome/components/mdns/mdns_rp2.cpp | 3 +- esphome/components/network/__init__.py | 33 +++++++++++- .../components/network/network_component.cpp | 22 +++++--- .../components/network/network_component.h | 32 ++++++++++++ esphome/core/defines.h | 6 +++ .../config/priority_ethernet_first_api.yaml | 28 ++++++++++ .../network/config/priority_rp2040_api.yaml | 26 ++++++++++ .../network/test_active_interface.py | 52 +++++++++++++++++++ 12 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 tests/component_tests/network/config/priority_ethernet_first_api.yaml create mode 100644 tests/component_tests/network/config/priority_rp2040_api.yaml create mode 100644 tests/component_tests/network/test_active_interface.py diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e49a3897fee..bc431599248 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -3,7 +3,9 @@ from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv from esphome.const import ( + CONF_API, CONF_DISABLED, + CONF_ETHERNET, CONF_ID, CONF_OPENTHREAD, CONF_PORT, @@ -285,6 +287,23 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + # With both wifi and ethernet, the _esphomelib "network" TXT value follows the + # connected interface. OpenThread hands its services to the SRP client once, + # so it keeps the fixed value. + if ( + CONF_API in CORE.config + and CONF_WIFI in CORE.config + and CONF_ETHERNET in CORE.config + and CONF_OPENTHREAD not in CORE.config + ): + from esphome.components import network + + cg.add_define("USE_MDNS_DYNAMIC_NETWORK_TXT") + # Where the network component tracks the default route (ESP32), follow + # it; otherwise mdns checks the connected interface every second. + if await network.register_active_interface_listener(var): + cg.add_define("USE_MDNS_ACTIVE_INTERFACE_LISTENER") + for service in config[CONF_SERVICES]: txt_records = [ await _mdns_txt_record_templated(var, txt_key, txt_value) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 0e363852688..32a84671fea 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -62,12 +62,9 @@ const char *MDNSComponent::connected_network_() const { return nullptr; } -void MDNSComponent::update_network_txt_() { - if (this->network_txt_ == nullptr) - return; +void MDNSComponent::update_network_txt_(const char *network) { // With no interface connected, keep the last value instead of flapping to wifi - const char *network = this->connected_network_(); - if (network == nullptr || network == this->network_txt_) + if (this->network_txt_ == nullptr || network == nullptr || network == this->network_txt_) return; if (!this->publish_network_txt_(network)) { // The _esphomelib service is missing, so a retry would fail too @@ -80,6 +77,21 @@ void MDNSComponent::update_network_txt_() { this->network_record_->value = MDNS_STR(network); #endif } + +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +void MDNSComponent::on_active_interface(network::NetworkInterface interface) { + switch (interface) { + case network::NetworkInterface::NETWORK_INTERFACE_ETHERNET: + this->update_network_txt_(NETWORK_ETHERNET); + break; + case network::NetworkInterface::NETWORK_INTERFACE_WIFI: + this->update_network_txt_(NETWORK_WIFI); + break; + default: + break; + } +} +#endif #endif void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index f2ea7c19149..06d10d2d818 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,10 +32,11 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif -// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface. -// OpenThread hands its services to the SRP client once, so it keeps the fixed value. -#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD) -#define USE_MDNS_DYNAMIC_NETWORK_TXT +// USE_MDNS_DYNAMIC_NETWORK_TXT (wifi + ethernet + api): the _esphomelib "network" TXT value follows the +// connected interface. With USE_MDNS_ACTIVE_INTERFACE_LISTENER (ESP32) the network component reports +// default route changes; otherwise the value is checked every NETWORK_TXT_CHECK_INTERVAL_MS. +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +#include "esphome/components/network/network_component.h" #endif namespace esphome::mdns { @@ -79,6 +80,10 @@ class MDNSComponent final : public Component , public ethernet::EthernetIPStateListener #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + , + public network::ActiveInterfaceListener +#endif { public: void setup() override; @@ -140,6 +145,10 @@ class MDNSComponent final : public Component } #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + void on_active_interface(network::NetworkInterface interface) override; +#endif + #ifdef USE_MDNS_EVENT_DRIVEN_POLLING void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, const network::IPAddress &dns2) override; @@ -160,8 +169,8 @@ class MDNSComponent final : public Component static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. const char *connected_network_() const; - /// Check the connected interface and publish a changed "network" TXT value. - void update_network_txt_(); + /// Publish network as the "network" TXT value if it changed; nullptr keeps the current value. + void update_network_txt_(const char *network); /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. bool publish_network_txt_(const char *network); /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index afa30eb5f30..d84a2551202 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -103,8 +103,9 @@ bool MDNSComponent::publish_network_txt_(const char *network) { void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); -#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT - this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#if defined(USE_MDNS_DYNAMIC_NETWORK_TXT) && !defined(USE_MDNS_ACTIVE_INTERFACE_LISTENER) + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); #endif } diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 603f3f0417c..f27b6b49812 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -64,7 +64,8 @@ bool MDNSComponent::publish_network_txt_(const char *network) { void MDNSComponent::setup() { #ifdef USE_MDNS_DYNAMIC_NETWORK_TXT // No-op until the first IP-up event registers the services - this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); #endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1c08202d411..89835128fe2 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -100,6 +100,37 @@ if ( network_ns = cg.esphome_ns.namespace("network") NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) + +_request_active_interface_listener_slot = cg.slot_counter( + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT" +) + + +def has_active_interface() -> bool: + """Return True when NetworkComponent tracks the interface holding the default route. + + That is ESP32 with more than one interface in 'network: priority:', the same + condition that emits USE_NETWORK_DEFAULT_ROUTE. + """ + return CORE.is_esp32 and len(CORE.config.get(DOMAIN, {}).get(CONF_PRIORITY, [])) > 1 + + +async def register_active_interface_listener(listener: cg.MockObj) -> bool: + """Register an ActiveInterfaceListener with the NetworkComponent. + + The listener is notified from the main loop when the interface holding the + default route changes. Returns False, and registers nothing, when + has_active_interface() is False; the caller then needs another way to + follow interface changes. + """ + if not has_active_interface(): + return False + _request_active_interface_listener_slot() + network = await cg.get_variable(CORE.config[DOMAIN][CONF_ID]) + cg.add(network.add_active_interface_listener(listener)) + return True + + IPAddress = network_ns.class_("IPAddress") @@ -402,7 +433,7 @@ async def to_code(config: ConfigType) -> None: # frameworks build from source. # The ethernet/wifi-only assumption behind the arbitration is enforced in # _final_validate() so a future unsupported type fails as a config error. - if len(priority_list) > 1 and CORE.is_esp32: + if has_active_interface(): cg.add_define("USE_NETWORK_DEFAULT_ROUTE") # Have lwIP switch to the DNS servers of the netif that owns the # default route whenever the arbitration changes it. diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index fad7a385fdd..ec6959c2d00 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -78,16 +78,24 @@ void NetworkComponent::loop() { // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; // it selects the reported address in util.cpp and doubles as the route-order // pivot here — the two uses must stay in sync. - esp_netif_t *best; + esp_netif_t *wifi = connected_wifi_netif(); + esp_netif_t *ethernet = connected_ethernet_netif(); #ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI - best = connected_wifi_netif(); - if (best == nullptr) - best = connected_ethernet_netif(); + esp_netif_t *best = wifi != nullptr ? wifi : ethernet; #else - best = connected_ethernet_netif(); - if (best == nullptr) - best = connected_wifi_netif(); + esp_netif_t *best = ethernet != nullptr ? ethernet : wifi; #endif + NetworkInterface active = NetworkInterface::NETWORK_INTERFACE_NONE; + if (best != nullptr) { + active = best == ethernet ? NetworkInterface::NETWORK_INTERFACE_ETHERNET : NetworkInterface::NETWORK_INTERFACE_WIFI; + } + if (active != this->active_interface_) { + this->active_interface_ = active; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + for (auto *listener : this->active_interface_listeners_) + listener->on_active_interface(active); +#endif + } if (best == nullptr) { // Forget the last winner: stopping its netif cleared lwIP's default route and // IDF's manual override suppresses re-election, so reconnect must re-assert it. diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 870530974ea..eff08524a6f 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,12 +4,32 @@ #include "esphome/core/component.h" #ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/helpers.h" // Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h // into this header. using esp_netif_t = struct esp_netif_obj; #endif namespace esphome::network { + +#ifdef USE_NETWORK_DEFAULT_ROUTE +/// The interface holding the default route. Lists only the interfaces loop() arbitrates +/// (ARBITRATED_NETWORK_TYPES in __init__.py); extend both together. +enum class NetworkInterface : uint8_t { + NETWORK_INTERFACE_NONE, + NETWORK_INTERFACE_ETHERNET, + NETWORK_INTERFACE_WIFI, +}; + +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT +/// Notified from the main loop when the interface holding the default route changes. +class ActiveInterfaceListener { + public: + virtual void on_active_interface(NetworkInterface interface) = 0; +}; +#endif +#endif + class NetworkComponent final : public Component { public: void setup() override; @@ -20,6 +40,14 @@ class NetworkComponent final : public Component { #ifdef USE_NETWORK_DEFAULT_ROUTE void loop() override; + /// The first connected interface in network: priority order; NONE while none is connected. + NetworkInterface get_active_interface() const { return this->active_interface_; } +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + void add_active_interface_listener(ActiveInterfaceListener *listener) { + this->active_interface_listeners_.push_back(listener); + } +#endif + protected: // Verify-lwIP-route interval for the settled state; keeps the global lwIP core // mutex off the hot loop path. @@ -27,6 +55,10 @@ class NetworkComponent final : public Component { // Last netif this component made the default; avoids redundant esp_netif calls. esp_netif_t *default_netif_{nullptr}; uint32_t last_route_check_{0}; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + StaticVector active_interface_listeners_; +#endif + NetworkInterface active_interface_{NetworkInterface::NETWORK_INTERFACE_NONE}; #endif }; } // namespace esphome::network diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d243043bde..1fa417d7afd 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -217,6 +217,7 @@ #define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE +#define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1 #define USE_NETWORK #define USE_NETWORK_DEFAULT_ROUTE #define USE_NETWORK_PRIMARY_INTERFACE_WIFI @@ -567,6 +568,10 @@ #endif #ifndef USE_OPENTHREAD #define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifdef USE_ETHERNET +#define USE_MDNS_ACTIVE_INTERFACE_LISTENER +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif #endif #endif @@ -647,6 +652,7 @@ #define USE_ETHERNET_SPI #endif #define USE_ETHERNET_W5500 +#define USE_MDNS_DYNAMIC_NETWORK_TXT #define USE_WIFI_IP_STATE_LISTENERS #define ESPHOME_WIFI_IP_STATE_LISTENERS 2 #define USE_ETHERNET_IP_STATE_LISTENERS diff --git a/tests/component_tests/network/config/priority_ethernet_first_api.yaml b/tests/component_tests/network/config/priority_ethernet_first_api.yaml new file mode 100644 index 00000000000..31ff65b5bc2 --- /dev/null +++ b/tests/component_tests/network/config/priority_ethernet_first_api.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/config/priority_rp2040_api.yaml b/tests/component_tests/network/config/priority_rp2040_api.yaml new file mode 100644 index 00000000000..a74442032da --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040_api.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/test_active_interface.py b/tests/component_tests/network/test_active_interface.py new file mode 100644 index 00000000000..2509f24ffb6 --- /dev/null +++ b/tests/component_tests/network/test_active_interface.py @@ -0,0 +1,52 @@ +"""mDNS follows the interface holding the default route through an +ActiveInterfaceListener, only where NetworkComponent tracks that interface +(ESP32 with more than one interface) and the network TXT record exists.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + +DYNAMIC_TXT_DEFINE = "USE_MDNS_DYNAMIC_NETWORK_TXT" +LISTENER_DEFINES = { + "USE_MDNS_ACTIVE_INTERFACE_LISTENER", + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT", +} + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_mdns_registers_on_esp32_with_wifi_ethernet_and_api( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("priority_ethernet_first_api.yaml")) + + assert "add_active_interface_listener(" in main_cpp + assert _defines() >= LISTENER_DEFINES | {DYNAMIC_TXT_DEFINE} + + +@pytest.mark.parametrize( + ("fixture", "dynamic_txt"), + [ + # No api means no _esphomelib service, so no network TXT record + pytest.param("priority_ethernet_first.yaml", False, id="esp32_without_api"), + # No route arbitration on RP2; mDNS keeps its periodic check there + pytest.param("priority_rp2040_api.yaml", True, id="rp2040"), + ], +) +def test_mdns_does_not_register( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + dynamic_txt: bool, +) -> None: + main_cpp = generate_main(component_config_path(fixture)) + + assert "add_active_interface_listener(" not in main_cpp + assert not LISTENER_DEFINES & _defines() + assert (DYNAMIC_TXT_DEFINE in _defines()) is dynamic_txt From f45e6f0bf47a818b8bcb4afff47e2a8a505306b4 Mon Sep 17 00:00:00 2001 From: Andres Signes Grimalt Date: Wed, 14 Oct 2026 04:03:18 +0200 Subject: [PATCH 336/651] [midea] Add display_control and send the display toggle over UART without a transmitter (#18671) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/midea/air_conditioner.cpp | 34 +++++++++++++++---- esphome/components/midea/air_conditioner.h | 9 +++++ esphome/components/midea/climate.py | 24 +++++++++++++ tests/components/midea/common-uart_only.yaml | 6 ++++ tests/components/midea/common.yaml | 1 + .../midea/test-uart_only.esp32-idf.yaml | 8 +++++ 6 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 tests/components/midea/common-uart_only.yaml create mode 100644 tests/components/midea/test-uart_only.esp32-idf.yaml diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 24bbfe76b0b..fce8ab99a9e 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -127,6 +127,19 @@ ClimateTraits AirConditioner::traits() { return traits; } +#ifdef USE_REMOTE_TRANSMITTER +static const LogString *display_control_str(DisplayControl control) { + switch (control) { + case DisplayControl::DISPLAY_CONTROL_UART: + return LOG_STR("UART"); + case DisplayControl::DISPLAY_CONTROL_IR: + return LOG_STR("IR"); + default: + return LOG_STR("AUTO"); + } +} +#endif + void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" @@ -135,7 +148,10 @@ void AirConditioner::dump_config() { " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER - ESP_LOGCONFIG(Constants::TAG, " [x] Using RemoteTransmitter"); + ESP_LOGCONFIG(Constants::TAG, + " [x] Using RemoteTransmitter\n" + " [x] Display control: %s", + LOG_STR_ARG(display_control_str(this->display_control_))); #endif if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { this->base_.getCapabilities().dump(); @@ -183,16 +199,20 @@ void AirConditioner::do_swing_step() { } void AirConditioner::do_display_toggle() { - if (this->base_.getCapabilities().supportLightControl()) { - this->base_.displayToggle(); - } else { #ifdef USE_REMOTE_TRANSMITTER + // AUTO uses IR unless the appliance reports light control over UART. Without a transmitter + // built in, every setting sends the UART request: Mundoclima, Rotenso and others accept it + // without advertising it in the 0xB5 capabilities report. + const bool use_ir = this->display_control_ == DisplayControl::DISPLAY_CONTROL_IR || + (this->display_control_ == DisplayControl::DISPLAY_CONTROL_AUTO && + !this->base_.getCapabilities().supportLightControl()); + if (use_ir) { IrSpecialData data(0x08); this->transmitter_.transmit(data); -#else - ESP_LOGW(Constants::TAG, "Action needs remote_transmitter component"); -#endif + return; } +#endif + this->base_.displayToggle(); } } // namespace esphome::midea::ac diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 9977d2088ff..607027d9962 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,12 +21,20 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; +// How the display toggle reaches the appliance; the AUTO rules are in do_display_toggle() +enum class DisplayControl : uint8_t { + DISPLAY_CONTROL_UART, // always the UART request + DISPLAY_CONTROL_AUTO, // UART when the appliance reports it or no transmitter is built in, else IR + DISPLAY_CONTROL_IR, // always the IR remote, needs a transmitter +}; + class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } void set_humidity_setpoint_sensor(Sensor *sensor) { this->humidity_sensor_ = sensor; } void set_power_sensor(Sensor *sensor) { this->power_sensor_ = sensor; } + void set_display_control(DisplayControl control) { this->display_control_ = control; } void on_status_change() override; /* ############### */ @@ -54,6 +62,7 @@ class AirConditioner final : public ApplianceBase ConfigType: + """`display_control: IR` needs a `remote_transmitter` to send through; UART and AUTO do not.""" + if config.get(CONF_DISPLAY_CONTROL) == "IR" and CONF_TRANSMITTER_ID not in config: + raise cv.Invalid( + f"'{CONF_DISPLAY_CONTROL}: IR' requires '{CONF_TRANSMITTER_ID}' to be set" + ) + return config + CONFIG_SCHEMA = cv.All( climate.climate_schema(AirConditioner) @@ -100,6 +121,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_BEEPER, default=False): cv.boolean, cv.Optional(CONF_AUTOCONF, default=True): cv.boolean, + cv.Optional(CONF_DISPLAY_CONTROL, default="AUTO"): validate_display_control, cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(validate_modes), cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.ensure_list( validate_swing_modes @@ -134,6 +156,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), + validate_display_control_with_transmitter, cv.only_on( [ PLATFORM_ESP32, @@ -204,6 +227,7 @@ async def to_code(config): cg.add(var.set_transmitter(transmitter_)) cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) cg.add(var.set_autoconf(config[CONF_AUTOCONF])) + cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL])) if CONF_SUPPORTED_MODES in config: cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) if CONF_SUPPORTED_SWING_MODES in config: diff --git a/tests/components/midea/common-uart_only.yaml b/tests/components/midea/common-uart_only.yaml new file mode 100644 index 00000000000..d05df221732 --- /dev/null +++ b/tests/components/midea/common-uart_only.yaml @@ -0,0 +1,6 @@ +climate: + - platform: midea + id: midea_uart_only + name: Midea Climate UART Only + autoconf: true + display_control: UART diff --git a/tests/components/midea/common.yaml b/tests/components/midea/common.yaml index 25fc2debcd2..bade07be43f 100644 --- a/tests/components/midea/common.yaml +++ b/tests/components/midea/common.yaml @@ -33,6 +33,7 @@ climate: timeout: 2s beeper: false autoconf: true + display_control: AUTO visual: min_temperature: 17 °C max_temperature: 30 °C diff --git a/tests/components/midea/test-uart_only.esp32-idf.yaml b/tests/components/midea/test-uart_only.esp32-idf.yaml new file mode 100644 index 00000000000..ce09bbb7b7d --- /dev/null +++ b/tests/components/midea/test-uart_only.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea: !include common-uart_only.yaml + +# The MideaUART library includes esp_wifi.h on ESP-IDF +wifi: + ssid: MySSID + password: password1 From 7b591b26d692b5b06e0f8927123831dacd2bed2e Mon Sep 17 00:00:00 2001 From: willumpie82 Date: Wed, 14 Oct 2026 04:19:58 +0200 Subject: [PATCH 337/651] [sy6970] Service the I2C watchdog so charge/LED settings persist (#19424) Co-authored-by: Willem Oldemans <> Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sy6970/__init__.py | 14 ++++++ esphome/components/sy6970/sy6970.cpp | 70 ++++++++++++++++++++++++++- esphome/components/sy6970/sy6970.h | 20 +++++++- tests/components/sy6970/common.yaml | 1 + 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 90bd9f46911..c1c770aa591 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -17,12 +17,22 @@ CONF_CHARGE_CURRENT = "charge_current" CONF_PRECHARGE_CURRENT = "precharge_current" CONF_CHARGE_ENABLED = "charge_enabled" CONF_ENABLE_ADC = "enable_adc" +CONF_I2C_WATCHDOG_TIMEOUT = "i2c_watchdog_timeout" sy6970_ns = cg.esphome_ns.namespace("sy6970") SY6970Component = sy6970_ns.class_( "SY6970Component", cg.PollingComponent, i2c.I2CDevice ) SY6970Listener = sy6970_ns.class_("SY6970Listener") +I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") + +# REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on +I2C_WATCHDOG_TIMEOUTS = { + "DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED, + "40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S, + "80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S, + "160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S, +} CONFIG_SCHEMA = ( cv.Schema( @@ -43,6 +53,9 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_CHARGE_ENABLED, default=True): cv.boolean, cv.Optional(CONF_ENABLE_ADC, default=True): cv.boolean, + cv.Optional(CONF_I2C_WATCHDOG_TIMEOUT, default="40S"): cv.enum( + I2C_WATCHDOG_TIMEOUTS, upper=True + ), } ) .extend(cv.polling_component_schema("5s")) @@ -60,6 +73,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_PRECHARGE_CURRENT], config[CONF_CHARGE_ENABLED], config[CONF_ENABLE_ADC], + config[CONF_I2C_WATCHDOG_TIMEOUT], ) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 3f381002cbd..5e1611ea558 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -6,6 +6,25 @@ namespace esphome::sy6970 { ESPHOME_LOG_TAG(TAG, "sy6970"); +static const LogString *i2c_watchdog_timeout_to_string(I2CWatchdogTimeout timeout) { + switch (timeout) { + case I2C_WATCHDOG_DISABLED: + return LOG_STR("Disabled"); + case I2C_WATCHDOG_40S: + return LOG_STR("40s"); + case I2C_WATCHDOG_80S: + return LOG_STR("80s"); + case I2C_WATCHDOG_160S: + return LOG_STR("160s"); + default: + return LOG_STR("Unknown"); + } +} + +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_ID = 0; +// Well under the 40 s default, so a failed kick or a timeout the chip did not accept still leaves margin +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_MS = 10000; + bool SY6970Component::read_all_registers_() { // Read all registers from 0x00 to 0x14 in one transaction (21 bytes) // This includes unused registers 0x0F, 0x10 for performance @@ -51,6 +70,11 @@ void SY6970Component::setup() { ESP_LOGW(TAG, "Unexpected chip ID: 0x%02X (expected 0x00)", chip_id); } + // Service the watchdog first, so the settings below are not reverted while they are written + ESP_LOGV(TAG, "Setting I2C watchdog timeout to %s", + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); + this->set_i2c_watchdog_timeout(this->i2c_watchdog_timeout_); + // Apply configuration options (all have defaults now) ESP_LOGV(TAG, "Setting LED enabled to %s", ONOFF(this->led_enabled_)); this->set_led_enabled(this->led_enabled_); @@ -85,9 +109,11 @@ void SY6970Component::dump_config() { " Charge Current: %u mA\n" " Precharge Current: %u mA\n" " Charge Enabled: %s\n" - " ADC Enabled: %s", + " ADC Enabled: %s\n" + " I2C Watchdog Timeout: %s", ONOFF(this->led_enabled_), this->input_current_limit_, this->charge_voltage_, this->charge_current_, - this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_)); + this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_), + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); if (this->is_failed()) { @@ -198,4 +224,44 @@ void SY6970Component::set_enable_adc_measure(bool enabled) { this->update_register_(SY6970_REG_ADC_CONTROL, 0xC0, enabled ? 0xC0 : 0x00); } +void SY6970Component::set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout) { + if (this->is_failed()) + return; + + if (timeout != I2C_WATCHDOG_DISABLED) { + // Changing the timeout does not restart the chip's timer, which may be close to expiring + this->reset_i2c_watchdog(); + } + + // REG07 bits 5:4 (WATCHDOG[1:0]) + bool written = this->update_register_(SY6970_REG_TIMER_CONTROL, 0x30, static_cast(timeout) << 4); + if (!written) { + ESP_LOGW(TAG, "Failed to set I2C watchdog timeout to %s", LOG_STR_ARG(i2c_watchdog_timeout_to_string(timeout))); + } + + if (timeout == I2C_WATCHDOG_DISABLED && written) { + this->i2c_watchdog_timeout_ = timeout; + this->cancel_interval(I2C_WATCHDOG_KICK_INTERVAL_ID); + return; + } + if (timeout != I2C_WATCHDOG_DISABLED) { + this->i2c_watchdog_timeout_ = timeout; + } + // Keeps kicking after a failed write too: the chip may still be on its 40 s default + this->set_interval(I2C_WATCHDOG_KICK_INTERVAL_ID, I2C_WATCHDOG_KICK_INTERVAL_MS, + [this]() { this->reset_i2c_watchdog(); }); +} + +bool SY6970Component::reset_i2c_watchdog() { + if (this->is_failed()) + return false; + + // WD_RST, self-clearing + if (!this->update_register_(SY6970_REG_SYS_CONTROL, 0x40, 0x40)) { + ESP_LOGW(TAG, "I2C watchdog reset failed, settings may revert"); + return false; + } + return true; +} + } // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 06f0615ab4b..b9c4f1d4870 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -62,6 +62,15 @@ enum ChargeStatus { CHARGE_STATUS_CHARGE_DONE = 3, }; +// REG07[5:4]. When the watchdog elapses the chip reverts CHG_CONFIG and STAT_DIS to their defaults, +// so an enabled watchdog is kicked on its own interval. +enum I2CWatchdogTimeout { + I2C_WATCHDOG_DISABLED = 0, + I2C_WATCHDOG_40S = 1, + I2C_WATCHDOG_80S = 2, + I2C_WATCHDOG_160S = 3, +}; + // Structure to hold all register data read in one transaction struct SY6970Data { uint8_t registers[21]; // Registers 0x00-0x14 (includes unused 0x0F, 0x10) @@ -76,14 +85,16 @@ class SY6970Listener { class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, - uint16_t precharge_current, bool charge_enabled, bool enable_adc) + uint16_t precharge_current, bool charge_enabled, bool enable_adc, + I2CWatchdogTimeout i2c_watchdog_timeout) : led_enabled_(led_enabled), input_current_limit_(input_current_limit), charge_voltage_(charge_voltage), charge_current_(charge_current), precharge_current_(precharge_current), charge_enabled_(charge_enabled), - enable_adc_(enable_adc) {} + enable_adc_(enable_adc), + i2c_watchdog_timeout_(i2c_watchdog_timeout) {} void setup() override; void dump_config() override; void update() override; @@ -99,6 +110,10 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { void set_charge_enabled(bool enabled); void set_led_enabled(bool enabled); void set_enable_adc_measure(bool enabled = true); + void set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout); + + /// Kick the I2C watchdog (REG03 bit 6); runs on its own interval while enabled. Returns false when not written. + bool reset_i2c_watchdog(); protected: bool read_all_registers_(); @@ -116,6 +131,7 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { uint16_t precharge_current_; bool charge_enabled_; bool enable_adc_; + I2CWatchdogTimeout i2c_watchdog_timeout_; }; } // namespace esphome::sy6970 diff --git a/tests/components/sy6970/common.yaml b/tests/components/sy6970/common.yaml index 53699fe6fb8..74f57f28210 100644 --- a/tests/components/sy6970/common.yaml +++ b/tests/components/sy6970/common.yaml @@ -9,6 +9,7 @@ sy6970: precharge_current: 128 charge_enabled: true enable_adc: true + i2c_watchdog_timeout: 80S update_interval: 5s sensor: From bb1119d83f5ba0bb4b932aed88a3cf824a812d95 Mon Sep 17 00:00:00 2001 From: Christopher Reimer Date: Wed, 14 Oct 2026 04:34:03 +0200 Subject: [PATCH 338/651] [esp32_can] Support 20 kbit/s on ESP32 revision 2 or later (#20426) --- esphome/components/esp32_can/canbus.py | 22 ++++++++ esphome/components/esp32_can/esp32_can.cpp | 4 ++ .../esp32_can/test-20kbps.esp32-idf.yaml | 11 ++++ tests/unit_tests/components/test_esp32_can.py | 54 +++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 tests/components/esp32_can/test-20kbps.esp32-idf.yaml create mode 100644 tests/unit_tests/components/test_esp32_can.py diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2713bae241a..ae299370cd7 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -6,6 +6,8 @@ import esphome.codegen as cg from esphome.components import canbus from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed from esphome.components.esp32 import ( + CONF_MINIMUM_CHIP_REVISION, + KEY_ESP32, VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, @@ -20,6 +22,8 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ADVANCED, + CONF_FRAMEWORK, CONF_ID, CONF_MODE, CONF_RX_PIN, @@ -27,6 +31,7 @@ from esphome.const import ( CONF_TX_PIN, CONF_TX_QUEUE_LEN, ) +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@Sympatron"] @@ -49,6 +54,7 @@ CAN_MODES = { # See ESP-IDF Programming Guide --> API Reference --> Two-Wire Automotive Interface (TWAI) CAN_SPEEDS_ESP32 = { + "20KBPS": CanSpeed.CAN_20KBPS, "25KBPS": CanSpeed.CAN_25KBPS, "50KBPS": CanSpeed.CAN_50KBPS, "100KBPS": CanSpeed.CAN_100KBPS, @@ -114,6 +120,22 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( ) +def _final_validate(config: ConfigType) -> None: + if get_esp32_variant() == VARIANT_ESP32 and config[CONF_BIT_RATE] == "20KBPS": + esp32_config = fv.full_config.get()[KEY_ESP32] + min_revision = esp32_config[CONF_FRAMEWORK][CONF_ADVANCED].get( + CONF_MINIMUM_CHIP_REVISION + ) + if min_revision is None or min_revision < "2.0": + raise cv.Invalid( + "20KBPS on ESP32 requires esp32.framework.advanced.minimum_chip_revision: '2.0' or higher", + path=[CONF_BIT_RATE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def get_default_tx_enqueue_timeout(bit_rate: str) -> int: bit_rate_numeric = canbus.get_rate(bit_rate) bits_per_packet = 140 # ~max CAN message length diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index b98ba853e78..d84b58c5b0c 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -33,6 +33,10 @@ static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config case canbus::CAN_16KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_16KBITS(); return true; +#endif +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) || (defined(USE_ESP32_VARIANT_ESP32) && CONFIG_ESP32_REV_MIN_FULL >= 200) case canbus::CAN_20KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_20KBITS(); return true; diff --git a/tests/components/esp32_can/test-20kbps.esp32-idf.yaml b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml new file mode 100644 index 00000000000..fb60cb7363f --- /dev/null +++ b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml @@ -0,0 +1,11 @@ +esp32: + framework: + advanced: + minimum_chip_revision: "2.0" + +canbus: + - platform: esp32_can + rx_pin: GPIO14 + tx_pin: GPIO12 + can_id: 4 + bit_rate: 20kbps diff --git a/tests/unit_tests/components/test_esp32_can.py b/tests/unit_tests/components/test_esp32_can.py new file mode 100644 index 00000000000..5b70ce1d812 --- /dev/null +++ b/tests/unit_tests/components/test_esp32_can.py @@ -0,0 +1,54 @@ +"""Tests for ESP32 CAN bitrate validation.""" + +import pytest + +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT +from esphome.components.esp32_can import canbus +import esphome.config_validation as cv +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("revision", "supported"), + [ + (None, False), + ("0.0", False), + ("1.0", False), + ("1.1", False), + ("2.0", True), + ("3.0", True), + ("3.1", True), + ], +) +def test_esp32_20kbps_minimum_revision(revision: str | None, supported: bool) -> None: + """Require the chip revision needed by the ESP-IDF timing macro.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"} + advanced = {} if revision is None else {"minimum_chip_revision": revision} + token = fv.full_config.set({"esp32": {"framework": {"advanced": advanced}}}) + try: + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + if supported: + canbus.FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises(cv.Invalid, match="minimum_chip_revision") as error: + canbus.FINAL_VALIDATE_SCHEMA(config) + assert error.value.path == ["bit_rate"] + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32", "ESP32C3", "ESP32S3"]) +def test_other_bitrates(variant: str) -> None: + """Existing bitrates must not require a minimum chip revision.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("25kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("variant", ["ESP32C3", "ESP32S2", "ESP32S3", "ESP32C6"]) +def test_other_variants_20kbps(variant: str) -> None: + """Other supported ESP32 variants must retain 20 kbit/s support.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) From 07029d00775a76427d0ff70011025750eef93fe6 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:47:31 -0500 Subject: [PATCH 339/651] [sen6x] Add measurement control and maintenance actions (#18783) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 111 ++++++++++++++++++++++++++++- esphome/components/sen6x/sen6x.h | 13 ++++ esphome/components/sen6x/sensor.py | 20 ++++++ tests/components/sen6x/common.yaml | 14 ++++ 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 91762e0f079..5087b112a8d 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -14,6 +14,15 @@ static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts // Numeric timeout IDs. Each chain is sequential, so only one timeout per ID is active at a time. static constexpr uint32_t TIMEOUT_POLL = 1; static constexpr uint32_t TIMEOUT_SETUP_STEP = 2; +static constexpr uint32_t TIMEOUT_STARTUP = 3; +// Post-command wait times from the datasheet: start execution (4.8.1), stop execution (4.8.2), +// fan cleaning (4.8.22), SHT heater (4.8.23), CO2 conditioning after start on SEN63C/SEN69C (4.8.1) +static constexpr uint32_t START_MEASUREMENT_DELAY = 50; +static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; +static constexpr uint32_t FAN_CLEANING_DELAY = 10000; +static constexpr uint32_t SHT_HEATER_DELAY = 20000; +static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -27,6 +36,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; +static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; +static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; +static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; @@ -191,7 +203,7 @@ void SEN6XComponent::finish_setup_() { return; } - this->set_timeout(60000, [this]() { this->startup_complete_ = true; }); + this->on_measurement_started_(); this->initialized_ = true; ESP_LOGD(TAG, "Initialized"); } @@ -240,7 +252,7 @@ void SEN6XComponent::dump_config() { } void SEN6XComponent::update() { - if (!this->initialized_) { + if (!this->initialized_ || !this->measuring_) { return; } @@ -442,6 +454,101 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// True while the previous command's execution or settling time has not elapsed +bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } + +void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { + this->command_started_at_ = millis(); + this->command_wait_ = wait_ms; +} + +void SEN6XComponent::on_measurement_started_() { + this->measuring_ = true; + this->set_command_wait_(START_MEASUREMENT_DELAY); + // SEN63C/SEN69C condition the CO2 sensor for 24 s after a start; block restarts until then + if (this->sen6x_type_ == SEN63C || this->sen6x_type_ == SEN69C) { + this->co2_started_at_ = millis(); + this->co2_wait_ = CO2_CONDITIONING_DELAY; + } + // Values need the warm-up period again after every start + this->startup_complete_ = false; + this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); +} + +void SEN6XComponent::start_measurement() { + if (!this->initialized_ || this->measuring_) + return; + // The CO2 conditioning window only blocks restarting, not other commands + if (this->command_blocked_() || millis() - this->co2_started_at_ < this->co2_wait_) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Start measurement failed (%d)", this->last_error_); + return; + } + this->on_measurement_started_(); +} + +void SEN6XComponent::stop_measurement() { + if (!this->initialized_ || !this->measuring_) + return; + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + // Stop any in-flight polling before the device goes idle + this->cancel_timeout(TIMEOUT_POLL); + if (!this->write_command(SEN6X_CMD_STOP_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Stop measurement failed (%d)", this->last_error_); + return; + } + this->measuring_ = false; + this->set_command_wait_(STOP_MEASUREMENT_DELAY); +} + +void SEN6XComponent::start_fan_cleaning() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.22) + if (this->measuring_) { + ESP_LOGW(TAG, "Fan cleaning requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_FAN_CLEANING)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Fan cleaning failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(FAN_CLEANING_DELAY); +} + +void SEN6XComponent::activate_sht_heater() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.23) + if (this->measuring_) { + ESP_LOGW(TAG, "SHT heater requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_ACTIVATE_SHT_HEATER)) { + this->status_set_warning(); + ESP_LOGW(TAG, "SHT heater failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(SHT_HEATER_DELAY); +} + SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) { if (product_name == "SEN62") return SEN62; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 64ce3371fce..588a466d714 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -58,6 +58,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void start_measurement(); + void stop_measurement(); + void start_fan_cleaning(); + void activate_sht_heater(); protected: Sen6xType infer_type_from_product_name_(const std::string &product_name); @@ -65,6 +69,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool command_blocked_() const; + void set_command_wait_(uint32_t wait_ms); + void on_measurement_started_(); void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); @@ -74,6 +81,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S optional voc_tuning_params_; optional nox_tuning_params_; Sen6xType sen6x_type_{UNKNOWN}; + // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap + uint32_t command_started_at_{0}; + uint32_t command_wait_{0}; + uint32_t co2_started_at_{0}; + uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; @@ -81,6 +93,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 4c0242f2e3f..a5c46801887 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -1,3 +1,5 @@ +from esphome import automation +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX @@ -209,3 +211,21 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + + +SEN6X_ACTION_SCHEMA = maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(SEN6XComponent), + } +) + + +for _name, _call in ( + ("sen6x.start_measurement", "start_measurement()"), + ("sen6x.stop_measurement", "stop_measurement()"), + ("sen6x.start_fan_cleaning", "start_fan_cleaning()"), + ("sen6x.activate_sht_heater", "activate_sht_heater()"), +): + automation.register_apply_action( + _name, SEN6X_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index c9b6f22c0fc..6ab80a76b21 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -48,3 +48,17 @@ sensor: formaldehyde: name: Formaldehyde address: 0x6B + +button: + - platform: template + name: Fan Cleaning + on_press: + - sen6x.stop_measurement: sen6x_sensor + - delay: 2s + - sen6x.start_fan_cleaning: sen6x_sensor + - delay: 11s + - sen6x.start_measurement: sen6x_sensor + - platform: template + name: SHT Heater + on_press: + - sen6x.activate_sht_heater: sen6x_sensor From 40e12cd0d893f496c78ccbacdb5df529e64cd783 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:01:47 -1000 Subject: [PATCH 340/651] [image] Add CodSpeed benchmarks for image, runtime image and display block draws (#20649) --- .../benchmarks/components/display/__init__.py | 7 + .../display/bench_draw_pixels_at.cpp | 82 ++++++ tests/benchmarks/components/image/__init__.py | 7 + .../components/image/bench_image_draw.cpp | 245 ++++++++++++++++++ .../bench_runtime_image_draw.cpp | 125 +++++++++ tests/testing_helpers.py | 22 ++ 6 files changed, 488 insertions(+) create mode 100644 tests/benchmarks/components/display/__init__.py create mode 100644 tests/benchmarks/components/display/bench_draw_pixels_at.cpp create mode 100644 tests/benchmarks/components/image/__init__.py create mode 100644 tests/benchmarks/components/image/bench_image_draw.cpp create mode 100644 tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp diff --git a/tests/benchmarks/components/display/__init__.py b/tests/benchmarks/components/display/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/display/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp new file mode 100644 index 00000000000..da96dd83b9a --- /dev/null +++ b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" + +namespace esphome::display::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated block stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// A buffered display without its own draw_pixels_at(), so every block goes +// through the base class fallback and the DisplayBuffer pixel path. +class BenchDisplay : public DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; +}; + +// Deterministic pseudo random pixel bytes, sized for the widest format. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 3; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +// One full block the way LVGL hands a display its rendering. +static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_endian, + DisplayRotation rotation = DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + BenchDisplay display; + display.set_rotation(rotation); + for (auto _ : state) { + display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void DrawPixelsAt_RGB565_BigEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, true); } +BENCHMARK(DrawPixelsAt_RGB565_BigEndian); + +static void DrawPixelsAt_RGB565_LittleEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, false); } +BENCHMARK(DrawPixelsAt_RGB565_LittleEndian); + +static void DrawPixelsAt_RGB888(benchmark::State &state) { draw_block(state, COLOR_BITNESS_888, true); } +BENCHMARK(DrawPixelsAt_RGB888); + +static void DrawPixelsAt_RGB332(benchmark::State &state) { draw_block(state, COLOR_BITNESS_332, true); } +BENCHMARK(DrawPixelsAt_RGB332); + +static void DrawPixelsAt_RGB565_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_565, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB565_Rotated); + +static void DrawPixelsAt_RGB888_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_888, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB888_Rotated); + +static void DrawPixelsAt_RGB332_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_332, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB332_Rotated); + +} // namespace esphome::display::benchmarks diff --git a/tests/benchmarks/components/image/__init__.py b/tests/benchmarks/components/image/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/image/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/image/bench_image_draw.cpp b/tests/benchmarks/components/image/bench_image_draw.cpp new file mode 100644 index 00000000000..6bdd0acffbc --- /dev/null +++ b/tests/benchmarks/components/image/bench_image_draw.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" + +namespace esphome::image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated draw of the image stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is Image::draw() plus the DisplayBuffer pixel path every +// buffered display shares. Everything fits the host cache, so this counts +// the per pixel work, not what a frame buffer in PSRAM costs. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; + + public: + uint8_t pixel(int x, int y) const { return this->frame_[y * kStride + x]; } +}; + +// Models a driver with a bulk path: rows of little endian RGB565 are copied +// straight into a 16 bit frame, as the TFT drivers do for their own format. +// Image::draw() only reaches it once it hands opaque images to +// draw_pixels_at(); until then this case matches ImageDraw_RGB565. +class BulkBenchDisplay : public BenchDisplay { + public: + BulkBenchDisplay() : frame565_(std::make_unique(kStride * kStride)) {} + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (bitness != display::COLOR_BITNESS_565 || big_endian) { + BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + const size_t stride = x_offset + w + x_pad; + for (int y = 0; y < h; y++) { + memcpy(this->frame565_.get() + (y_start + y) * kStride + x_start, ptr + ((y_offset + y) * stride + x_offset) * 2, + w * 2); + } + } + + protected: + std::unique_ptr frame565_; +}; + +// Draws the image at rotation 0 and 90 and checks the second frame is the +// transpose of the first, which is what a wrong traversal order would break. +static bool frames_match(Image &image) { + BenchDisplay plain; + BenchDisplay rotated; + rotated.set_rotation(display::DISPLAY_ROTATION_90_DEGREES); + image.draw(0, 0, &plain, display::COLOR_ON, display::COLOR_OFF); + image.draw(0, 0, &rotated, display::COLOR_ON, display::COLOR_OFF); + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + // DisplayBuffer maps (x, y) to (width - y - 1, x) at 90 degrees + if (plain.pixel(x, y) != rotated.pixel(kStride - y - 1, x)) + return false; + } + } + return true; +} + +// Deterministic pseudo random pixel bytes so the alpha branch sees a mix of +// pixels. Sized for the widest format, the image reads only what it needs. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 4; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +static void draw_image(benchmark::State &state, ImageType type, Transparency transparency, + display::DisplayRotation rotation = display::DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, type, transparency); + BenchDisplay display; + display.set_rotation(rotation); + if (!frames_match(image)) { + state.SkipWithError("rotated draw is not the transpose of the plain draw"); + return; + } + + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void ImageDraw_Binary(benchmark::State &state) { draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_Binary); + +static void ImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE); +} +BENCHMARK(ImageDraw_Grayscale); + +static void ImageDraw_RGB565(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB565); + +static void ImageDraw_RGB(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB); + +// The same opaque RGB565 image on a display with a bulk path. +static void ImageDraw_RGB565_Bulk(benchmark::State &state) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); + BulkBenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} +BENCHMARK(ImageDraw_RGB565_Bulk); + +static void ImageDraw_RGB_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Rotated); + +static void ImageDraw_Binary_Transparent(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Binary_Transparent); + +static void ImageDraw_Grayscale_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey); + +static void ImageDraw_Grayscale_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_Grayscale_Alpha); + +static void ImageDraw_RGB_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB_Alpha); + +static void ImageDraw_RGB_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB_ChromaKey); + +static void ImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey); + +static void ImageDraw_RGB565_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB565_Alpha); + +static void ImageDraw_RGB111(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB111); + +static void ImageDraw_RGB111_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB111_Alpha); + +// The same cases with the display rotated 90 degrees, which walks the other loop order. +static void ImageDraw_Binary_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Rotated); + +static void ImageDraw_Grayscale_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Rotated); + +static void ImageDraw_RGB565_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Rotated); + +static void ImageDraw_Binary_Transparent_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Transparent_Rotated); + +static void ImageDraw_Grayscale_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey_Rotated); + +static void ImageDraw_Grayscale_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Alpha_Rotated); + +static void ImageDraw_RGB_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Alpha_Rotated); + +static void ImageDraw_RGB_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_ChromaKey_Rotated); + +static void ImageDraw_RGB565_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey_Rotated); + +static void ImageDraw_RGB565_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Alpha_Rotated); + +static void ImageDraw_RGB111_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Rotated); + +static void ImageDraw_RGB111_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Alpha_Rotated); + +} // namespace esphome::image::benchmarks diff --git a/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp new file mode 100644 index 00000000000..fd3cf6fb773 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is the image draw plus the DisplayBuffer pixel path every +// buffered display shares. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kWidth * kHeight)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kWidth; } + int get_height_internal() override { return kHeight; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kWidth + x] = color.r; } + + std::unique_ptr frame_; +}; + +// A runtime image filled the way a decoder fills it, one draw_pixel() per +// pixel, then published as decoded so draw() uses the buffer. +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, image::Transparency transparency, bool big_endian) + : RuntimeImage(JPEG, type, transparency, nullptr, big_endian, kWidth, kHeight) {} + + bool fill() { + if (this->resize(kWidth, kHeight) == 0) + return false; + uint32_t seed = 11897; + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + seed = seed * 1103515245u + 12345u; + // Every fourth pixel is transparent so the chroma key cases hit both branches + const uint8_t alpha = (x & 3) == 0 ? 0x00 : 0xFF; + this->draw_pixel(x, y, Color(seed >> 24, seed >> 16, seed >> 8, alpha)); + } + } + this->width_ = this->buffer_width_; + this->height_ = this->buffer_height_; + this->data_start_ = this->buffer_; + return true; + } +}; + +// The decoder side: writing every pixel of the image into its buffer. +static void fill_image(benchmark::State &state, image::ImageType type, image::Transparency transparency) { + BenchImage image(type, transparency, false); + for (auto _ : state) { + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + benchmark::DoNotOptimize(image.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +// The display side: drawing the decoded image onto a buffered display. +static void draw_image(benchmark::State &state, image::ImageType type, image::Transparency transparency, + bool big_endian = false) { + BenchImage image(type, transparency, big_endian); + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + BenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void RuntimeImageFill_RGB565(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB565); + +static void RuntimeImageFill_RGB565_ChromaKey(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageFill_RGB565_ChromaKey); + +static void RuntimeImageFill_RGB(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB); + +static void RuntimeImageDraw_RGB565(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB565); + +static void RuntimeImageDraw_RGB565_BigEndian(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, true); +} +BENCHMARK(RuntimeImageDraw_RGB565_BigEndian); + +static void RuntimeImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageDraw_RGB565_ChromaKey); + +static void RuntimeImageDraw_RGB(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB); + +static void RuntimeImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_Grayscale); + +} // namespace esphome::runtime_image::benchmarks diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 20b76697a19..23bef718694 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -1,6 +1,7 @@ from typing import Any from esphome.loader import ComponentManifest, _replace_component_manifest +from esphome.types import ConfigType class ComponentManifestOverride: @@ -61,3 +62,24 @@ def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> No directory provides an ``override_manifest`` function. """ _replace_component_manifest(domain, manifest) + + +def add_defines_to_code(manifest: ComponentManifestOverride, *defines: str) -> None: + """Keep the component's real ``to_code`` and add the given defines after it. + + For host test and benchmark builds of code that is gated on a platform + define, such as loops that only exist on boards with PSRAM. + """ + from esphome import codegen as cg + + manifest.enable_codegen() + original_to_code = manifest.to_code + + async def to_code(config: ConfigType) -> None: + await original_to_code(config) + for define in defines: + cg.add_define(define) + + if hasattr(original_to_code, "priority"): + to_code.priority = original_to_code.priority + manifest.to_code = to_code From 5718e4b779991c7200a1d18d513bbae38dbea5bc Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:06:56 -0400 Subject: [PATCH 341/651] [tmp102] Add text sensor for diagnostics (#20629) Co-authored-by: J. Nick Koston --- esphome/components/tmp102/text_sensor.py | 31 ++++++ esphome/components/tmp102/tmp102.cpp | 105 ++++++++++++++++-- esphome/components/tmp102/tmp102.h | 32 +++++- esphome/core/defines.h | 1 + .../tmp102/config/duplicate_status.yaml | 23 ++++ .../component_tests/tmp102/config/status.yaml | 20 ++++ tests/component_tests/tmp102/test_config.py | 24 ++++ tests/components/tmp102/__init__.py | 4 +- tests/components/tmp102/common.yaml | 5 + .../test-text-sensor-only.esp32-idf.yaml | 12 ++ tests/components/tmp102/test_tmp102.cpp | 94 ++++++++++++++++ 11 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 esphome/components/tmp102/text_sensor.py create mode 100644 tests/component_tests/tmp102/config/duplicate_status.yaml create mode 100644 tests/component_tests/tmp102/config/status.yaml create mode 100644 tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml diff --git a/esphome/components/tmp102/text_sensor.py b/esphome/components/tmp102/text_sensor.py new file mode 100644 index 00000000000..5d901bfab89 --- /dev/null +++ b/esphome/components/tmp102/text_sensor.py @@ -0,0 +1,31 @@ +"""TMP102 threshold status entity.""" + +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC +).extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "text_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_TEXT_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await text_sensor.new_text_sensor(config) + cg.add(parent.set_threshold_status_text_sensor(var)) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index bb82975dbc3..06d94f1ba86 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -1,8 +1,14 @@ #include "tmp102.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/core/progmem.h" +#endif #include +#ifdef USE_TMP102_TEXT_SENSOR +#include +#endif namespace esphome::tmp102 { @@ -12,6 +18,30 @@ 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; + +enum TMP102ThresholdStatus : uint8_t { + TMP102_THRESHOLD_STATUS_NOT_CONFIGURED, + TMP102_THRESHOLD_STATUS_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_OK, + TMP102_THRESHOLD_STATUS_RESTORE_RESET, + TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERY_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERED, + TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED, + TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED, + TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_COUNT, +}; + +#ifdef USE_TMP102_TEXT_SENSOR +PROGMEM_STRING_TABLE(ThresholdStatusStrings, "Not configured", "Setup failed", "OK", "Restored thresholds reset", + "Restore reset; save failed", "Preference save failed", "Configuration recovery failed", + "Configuration restored", "Write failed: high threshold", "Write failed: low threshold", + "Rejected: sensor setup failed"); +static_assert(ThresholdStatusStrings::COUNT == TMP102_THRESHOLD_STATUS_COUNT); +#endif + #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; @@ -42,15 +72,30 @@ static uint16_t encode_temperature(float temperature, bool extended) { return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); } #endif +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void TMP102Component::setup() { - if (!this->configure_) +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->setup_configured_(); return; + } +#endif + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_NOT_CONFIGURED); +} +#endif + +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::setup_configured_() { + bool corrected = false; #ifdef USE_TMP102_NUMBER float restored[2]{}; - this->prepare_number_limits_(restored); + bool restore_corrected[2]{}; + corrected = this->prepare_number_limits_(restored, restore_corrected); #endif if (!this->write_configuration_()) { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED); this->mark_failed(); return; } @@ -61,14 +106,17 @@ void TMP102Component::setup() { if (control == nullptr) continue; const float value = this->get_limit_temperature_(static_cast(limit)); - if (value != restored[limit]) + if (restore_corrected[limit] || value != restored[limit]) saved &= control->save_value_(value); control->publish_state(value); } if (!saved) { ESP_LOGW(TAG, "Failed to save threshold preferences"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED); + return; } #endif + this->publish_threshold_status_(corrected ? TMP102_THRESHOLD_STATUS_RESTORE_RESET : TMP102_THRESHOLD_STATUS_OK); } #endif @@ -83,6 +131,9 @@ void TMP102Component::dump_config() { #ifdef USE_TMP102_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); #endif +#ifdef USE_TMP102_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Threshold Status", this->threshold_status_text_sensor_); +#endif #ifdef USE_TMP102_CONFIGURE if (!this->configure_) return; @@ -203,6 +254,9 @@ bool TMP102Component::check_configuration_() { this->set_warning_and_invalidate_alert_(); if (!this->write_configuration_()) { ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERY_FAILED); + } else { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERED); } return false; } @@ -228,22 +282,25 @@ void TMP102Component::set_limit_control(TMP102LimitNumber *number) { this->limit_controls_[number->limit_type_] = number; } -void TMP102Component::prepare_number_limits_(float *restored) { +bool TMP102Component::prepare_number_limits_(float *restored, bool *restore_corrected) { const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; uint16_t raw[2] = {this->low_limit_, this->high_limit_}; + bool corrected = false; 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)); + restored[limit] = control->load_value_(decode_temperature(raw[limit], extended), restore_corrected[limit]); + corrected |= restore_corrected[limit]; 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; + return true; } this->low_limit_ = raw[TMP102_LIMIT_LOW]; this->high_limit_ = raw[TMP102_LIMIT_HIGH]; + return corrected; } float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const { @@ -261,10 +318,13 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper 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)); + this->publish_threshold_rejection_(high, decode_temperature(raw, extended), decode_temperature(other, extended)); return false; } if (!this->write_register_(static_cast(TMP102_REGISTER_LOW_LIMIT + limit), raw)) { this->status_set_warning(); + this->publish_threshold_status_(high ? TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED + : TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED); return false; } (high ? this->high_limit_ : this->low_limit_) = raw; @@ -273,6 +333,32 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper #endif #endif +#ifdef USE_TMP102_TEXT_SENSOR +void TMP102Component::publish_threshold_status_(uint8_t status) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[40]; + ESPHOME_strncpy_P( + text, + reinterpret_cast(ThresholdStatusStrings::get_progmem_str(status, TMP102_THRESHOLD_STATUS_OK)), + sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->threshold_status_text_sensor_->publish_state(text); +} + +void TMP102Component::publish_threshold_rejection_(bool high, float value, float other) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[96]; + if (high) { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: high %.4f°C crosses low %.4f°C"), value, other); + } else { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: low %.4f°C crosses high %.4f°C"), value, other); + } + this->threshold_status_text_sensor_->publish_state(text); +} +#endif + #ifdef USE_TMP102_BINARY_SENSOR void TMP102Component::invalidate_alert_() { if (this->alert_binary_sensor_ != nullptr) @@ -294,7 +380,7 @@ bool TMP102Component::read_alert_state_() { #endif #ifdef USE_TMP102_NUMBER -float TMP102LimitNumber::load_value_(float fallback) { +float TMP102LimitNumber::load_value_(float fallback, bool &corrected) { float value = fallback; if (this->restore_value_) { this->pref_ = this->make_entity_preference(); @@ -303,6 +389,7 @@ float TMP102LimitNumber::load_value_(float fallback) { 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; + corrected = true; } return value; } @@ -313,6 +400,7 @@ 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"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED); return; } const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value); @@ -320,6 +408,9 @@ void TMP102LimitNumber::control(float value) { this->publish_state(value); if (accepted && !this->save_value_(value)) { ESP_LOGW(TAG, "Failed to save threshold preference"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED); + } else if (accepted) { + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_OK); } } #endif diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index d88dde78344..34cdfc973c1 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -12,11 +12,20 @@ #include "esphome/core/preferences.h" #endif #include "esphome/components/sensor/sensor.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include namespace esphome::tmp102 { +#ifdef USE_HOST +namespace testing { +class TMP102Test; +} +#endif + #ifdef USE_TMP102_NUMBER enum TMP102LimitType : uint8_t { TMP102_LIMIT_LOW = 0, @@ -31,8 +40,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu #ifdef USE_TMP102_BINARY_SENSOR SUB_BINARY_SENSOR(alert) #endif -#ifdef USE_TMP102_CONFIGURE +#ifdef USE_TMP102_TEXT_SENSOR + SUB_TEXT_SENSOR(threshold_status) +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void setup() override; +#endif +#ifdef USE_TMP102_CONFIGURE void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { this->config_ = config; this->high_limit_ = high_limit; @@ -47,6 +61,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void update() override; protected: +#ifdef USE_TMP102_TEXT_SENSOR + void publish_threshold_status_(uint8_t status); + void publish_threshold_rejection_(bool high, float value, float other); +#else + void publish_threshold_status_(uint8_t) {} + void publish_threshold_rejection_(bool, float, float) {} +#endif void set_warning_and_invalidate_alert_() { this->status_set_warning(); #ifdef USE_TMP102_BINARY_SENSOR @@ -58,6 +79,7 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void invalidate_alert_(); #endif #ifdef USE_TMP102_CONFIGURE + void setup_configured_(); bool write_register_(uint8_t reg, uint16_t value); bool write_configuration_(); bool check_configuration_(); @@ -67,11 +89,10 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu friend class TMP102LimitNumber; bool set_limit_temperature_(TMP102LimitType limit, float temperature); float get_limit_temperature_(TMP102LimitType limit) const; - void prepare_number_limits_(float *restored); + bool prepare_number_limits_(float *restored, bool *restore_corrected); TMP102LimitNumber *limit_controls_[2]{}; #endif - uint16_t config_{0}; uint16_t high_limit_{0}; uint16_t low_limit_{0}; @@ -87,7 +108,10 @@ class TMP102LimitNumber final : public number::Number, public Parented None: + source = generate_main(component_config_path("status.yaml")) + assert "set_threshold_status_text_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_TEXT_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_status_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_status.yaml") + assert read_config({}) is None + assert ( + "Only one TMP102 text_sensor may reference each temperature sensor" + in capsys.readouterr().out + ) + + def test_threshold_numbers_enable_configuration_once( generate_main: Callable[[Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index 346efac83ec..60de2d34958 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,11 +6,12 @@ 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", "number"): + for platform in ("sensor", "binary_sensor", "number", "text_sensor"): 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") + cg.add_define("USE_TMP102_TEXT_SENSOR") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + [ @@ -18,4 +19,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "sensor", "binary_sensor", "number", + "text_sensor", ] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index dfc2e2e2245..3d1f66da5bb 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -30,3 +30,8 @@ binary_sensor: - platform: tmp102 tmp102_id: tmp102_extended_temperature name: TMP102 Alert + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml new file mode 100644 index 00000000000..21880a6338f --- /dev/null +++ b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +sensor: + - platform: tmp102 + id: tmp102_text_sensor_parent + name: TMP102 Temperature + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_text_sensor_parent + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index 6c294f4cdc6..a5b65b060d4 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "esphome/components/host/preferences.h" #include "esphome/components/tmp102/tmp102.h" @@ -29,6 +30,7 @@ class RegisterBus final : public i2c::I2CBus { unsigned transactions{0}; unsigned writes{0}; uint8_t pointer{0}; + std::function on_write; i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, size_t out_size) override { @@ -42,6 +44,10 @@ class RegisterBus final : public i2c::I2CBus { return i2c::ERROR_NOT_ACKNOWLEDGED; ++this->writes; this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2]; + if (this->on_write) { + auto callback = std::move(this->on_write); + callback(); + } } } if (out_size != 0) { @@ -59,6 +65,7 @@ class TMP102Test : public ::testing::Test { RegisterBus bus_; TMP102Component chip_; binary_sensor::BinarySensor alert_; + text_sensor::TextSensor status_; TMP102LimitNumber high_{TMP102_LIMIT_HIGH}; TMP102LimitNumber low_{TMP102_LIMIT_LOW}; unsigned publications_{0}; @@ -122,6 +129,8 @@ class TMP102Test : public ::testing::Test { static void save(TMP102LimitNumber &number, float value) { ASSERT_TRUE(number.make_entity_preference().save(&value)); } + + static void fail_saves(TMP102LimitNumber &number) { number.pref_ = {}; } }; TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { @@ -136,6 +145,24 @@ TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { EXPECT_EQ(this->bus_.registers, original); } +TEST_F(TMP102Test, ReadOnlyStatusDoesNotConfigureOrWriteRegisters) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + const auto original = this->bus_.registers; + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + EXPECT_EQ(this->bus_.registers, original); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "Not configured"); +} + +TEST_F(TMP102Test, ConfiguredSetupPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "OK"); +} + TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) { for (bool configured_extended : {false, true}) { this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000, @@ -408,6 +435,7 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { } TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 20); save(this->low_, 35); @@ -416,30 +444,53 @@ TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { EXPECT_FLOAT_EQ(this->low_.state, 25); EXPECT_FLOAT_EQ(saved(this->high_), 30); EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); +} + +TEST_F(TMP102Test, InvalidRestoredPairAndSaveFailurePublishCombinedStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + save(this->high_, 20); + save(this->low_, 35); + this->bus_.on_write = [this]() { + fail_saves(this->high_); + fail_saves(this->low_); + }; + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_EQ(this->status_.state, "Restore reset; save failed"); } TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) { + this->chip_.set_threshold_status_text_sensor(&this->status_); 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); + EXPECT_FLOAT_EQ(saved(this->high_), 30); + EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); } TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) { + this->chip_.set_threshold_status_text_sensor(&this->status_); 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_EQ(this->status_.state, "Setup 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->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); this->bus_.fail_write = 3; @@ -450,6 +501,7 @@ TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { EXPECT_FALSE(this->high_.has_state()); EXPECT_EQ(this->bus_.writes, writes); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Rejected: sensor setup failed"); } TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { @@ -464,6 +516,7 @@ TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { } TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); save(this->low_, 85); @@ -475,6 +528,47 @@ TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { EXPECT_FLOAT_EQ(this->high_.state, 90); EXPECT_EQ(publications, 1); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Write failed: high threshold"); + + this->bus_.fail_write = 2; + this->low_.make_call().set_value(80).perform(); + EXPECT_FLOAT_EQ(this->low_.state, 85); + EXPECT_FLOAT_EQ(saved(this->low_), 85); + EXPECT_EQ(this->status_.state, "Write failed: low threshold"); +} + +TEST_F(TMP102Test, PreferenceSaveFailurePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + fail_saves(this->high_); + this->high_.make_call().set_value(35).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 35); + EXPECT_EQ(this->status_.state, "Preference save failed"); +} + +TEST_F(TMP102Test, RejectedNumberWritePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + this->high_.make_call().set_value(20).perform(); + EXPECT_EQ(this->status_.state, "Rejected: high 20.0000°C crosses low 25.0000°C"); + this->high_.make_call().set_value(35).perform(); + EXPECT_EQ(this->status_.state, "OK"); +} + +TEST_F(TMP102Test, ConfigurationRecoveryPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + this->bus_.registers[1] = 0; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration restored"); + + this->bus_.registers[1] = 0; + this->bus_.fail_write = 1; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration recovery failed"); } TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) { From 5171113c870a58c0237f4546ac38d8f42e25bd76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:17:48 -1000 Subject: [PATCH 342/651] [core] Let PROGMEM_STRING_TABLE hold more than 255 bytes (#20646) --- esphome/core/progmem.h | 35 ++++++++-- .../core/test_progmem_string_table.cpp | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/components/core/test_progmem_string_table.cpp diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 54fc42dde16..677ef0e2afb 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -68,6 +69,7 @@ template struct FixedString { }; /// Compile-time string table that packs strings into a single blob with offset lookup. +/// Blobs over 255 bytes have no offsets and are scanned, so keep those for rarely read strings. /// Use PROGMEM_STRING_TABLE macro to instantiate with proper flash placement on ESP8266. /// /// Example: @@ -78,6 +80,11 @@ template struct FixedString { template struct ProgmemStringTable { static constexpr size_t COUNT = sizeof...(Strs); static constexpr size_t BLOB_SIZE = (0 + ... + (Strs.size() + 1)); + /// Length of the longest string, for sizing a RAM copy on ESP8266 + static constexpr size_t MAX_LENGTH = std::max({Strs.size()...}); + /// One-byte offsets address up to 255 bytes; a larger blob has no offset table and is scanned instead, + /// which costs less flash than wider offsets for the rarely read tables that get this big. + static constexpr bool INDEXED = BLOB_SIZE <= UINT8_MAX; /// Generate packed string blob at compile time static constexpr auto make_blob() { @@ -91,18 +98,28 @@ template struct ProgmemStringTable { return result; } - /// Generate offset table at compile time (uint8_t limits blob to 255 bytes) + /// Generate offset table at compile time static constexpr auto make_offsets() { static_assert(COUNT > 0, "PROGMEM_STRING_TABLE must contain at least one string"); static_assert(COUNT <= 255, "PROGMEM_STRING_TABLE supports at most 255 strings with uint8_t indices"); - static_assert(BLOB_SIZE <= 255, "PROGMEM_STRING_TABLE blob exceeds 255 bytes; use fewer/shorter strings"); - std::array result{}; - size_t pos = 0, idx = 0; - ((result[idx++] = static_cast(pos), pos += Strs.size() + 1), ...); - return result; + if constexpr (INDEXED) { + std::array result{}; + size_t pos = 0, idx = 0; + ((result[idx++] = static_cast(pos), pos += Strs.size() + 1), ...); + return result; + } else { + return std::array{}; + } } }; +/// Skip idx strings in a packed NUL-separated blob that may live in flash. +inline const char *progmem_skip_strings(const char *blob, uint8_t idx) { + while (idx-- > 0) + blob += ESPHOME_strlen_P(blob) + 1; + return blob; +} + // Forward declaration for LogString (defined in log.h) struct LogString; @@ -115,6 +132,7 @@ struct LogString; static constexpr size_t COUNT = Table::COUNT; \ static constexpr uint8_t LAST_INDEX = COUNT - 1; \ static constexpr size_t BLOB_SIZE = Table::BLOB_SIZE; \ + static constexpr size_t MAX_LENGTH = Table::MAX_LENGTH; \ static constexpr auto BLOB ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_blob(); \ static constexpr auto OFFSETS ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_offsets(); \ static const char *get_(uint8_t idx, uint8_t fallback) { \ @@ -122,7 +140,10 @@ struct LogString; idx = fallback; \ /* std::launder is used here to prevent the inter-procedural analysis that */ \ /* causes the false positive that the string is not null terminated */ \ - return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \ + if constexpr (Table::INDEXED) \ + return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \ + else \ + return std::launder(::esphome::progmem_skip_strings(BLOB.data(), idx)); \ } \ static ::ProgmemStr get_progmem_str(uint8_t idx, uint8_t fallback) { \ return reinterpret_cast<::ProgmemStr>(get_(idx, fallback)); \ diff --git a/tests/components/core/test_progmem_string_table.cpp b/tests/components/core/test_progmem_string_table.cpp new file mode 100644 index 00000000000..4069bd4d2ab --- /dev/null +++ b/tests/components/core/test_progmem_string_table.cpp @@ -0,0 +1,65 @@ +#include + +#include + +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::core::testing { + +PROGMEM_STRING_TABLE(SmallStrings, "OFF", "HEAT", "COOL", "UNKNOWN"); + +// Eight strings of 40 characters: a 328-byte blob, past what one-byte offsets can address, so it is scanned. +PROGMEM_STRING_TABLE(LargeStrings, "0123456789012345678901234567890123456789", + "1123456789012345678901234567890123456789", "2123456789012345678901234567890123456789", + "3123456789012345678901234567890123456789", "4123456789012345678901234567890123456789", + "5123456789012345678901234567890123456789", "6123456789012345678901234567890123456789", + "7123456789012345678901234567890123456789"); + +// Five 50-character strings pack into exactly 255 bytes; one more byte switches to scanning. +PROGMEM_STRING_TABLE(Exactly255, "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789"); +PROGMEM_STRING_TABLE(Exactly256, "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789x"); + +TEST(ProgmemStringTable, ScansInsteadOfIndexingAfter255Bytes) { + static_assert(Exactly255::BLOB_SIZE == 255); + static_assert(Exactly255::Table::INDEXED); + static_assert(Exactly256::BLOB_SIZE == 256); + static_assert(!Exactly256::Table::INDEXED); + static_assert(Exactly256::OFFSETS.empty()); + EXPECT_EQ(Exactly255::OFFSETS[4], 204); + EXPECT_STREQ(reinterpret_cast(Exactly256::get_progmem_str(4, 0)), + "01234567890123456789012345678901234567890123456789x"); +} + +TEST(ProgmemStringTable, SmallTableKeepsOneByteOffsets) { + static_assert(SmallStrings::BLOB_SIZE <= 255); + static_assert(SmallStrings::Table::INDEXED); + static_assert(SmallStrings::MAX_LENGTH == 7); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(0, SmallStrings::LAST_INDEX)), "OFF"); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(2, SmallStrings::LAST_INDEX)), "COOL"); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(200, SmallStrings::LAST_INDEX)), "UNKNOWN"); +} + +TEST(ProgmemStringTable, LargeTableIsScanned) { + static_assert(LargeStrings::BLOB_SIZE > 255); + static_assert(!LargeStrings::Table::INDEXED); + static_assert(LargeStrings::MAX_LENGTH == 40); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(0, LargeStrings::LAST_INDEX)), + "0123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(7, LargeStrings::LAST_INDEX)), + "7123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(8, LargeStrings::LAST_INDEX)), + "7123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(3, LargeStrings::LAST_INDEX)), + "3123456789012345678901234567890123456789"); +} + +} // namespace esphome::core::testing From e41d16ba7e156315e7eb1217196fbb34332e7b4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:23:28 -1000 Subject: [PATCH 343/651] [haier] Keep the alarm message table in flash (#20641) --- esphome/components/haier/hon_climate.cpp | 36 +++++++++++++-- esphome/components/haier/hon_packet.h | 56 ------------------------ 2 files changed, 33 insertions(+), 59 deletions(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 5e672c3e65d..72b1e26818b 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -3,6 +3,7 @@ #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "hon_climate.h" #include "hon_packet.h" @@ -20,6 +21,27 @@ constexpr size_t ALARM_STATUS_REQUEST_INTERVAL_MS = 600000; const uint8_t ONE_BUF[] = {0x00, 0x01}; const uint8_t ZERO_BUF[] = {0x00, 0x00}; +// Alarm texts indexed by alarm code; the last entry is the fallback for unknown codes +PROGMEM_STRING_TABLE( + HonAlarmMessages, "Outdoor module failure", "Outdoor defrost sensor failure", + "Outdoor compressor exhaust sensor failure", "Outdoor EEPROM abnormality", "Indoor coil sensor failure", + "Indoor-outdoor communication failure", "Power supply overvoltage protection", + "Communication failure between panel and indoor unit", "Outdoor compressor overheat protection", + "Outdoor environmental sensor abnormality", "Full water protection", "Indoor EEPROM failure", + "Outdoor out air sensor failure", "CBD and module communication failure", "Indoor DC fan failure", + "Outdoor DC fan failure", "Door switch failure", "Dust filter needs cleaning reminder", "Water shortage protection", + "Humidity sensor failure", "Indoor temperature sensor failure", "Manipulator limit failure", + "Indoor PM2.5 sensor failure", "Outdoor PM2.5 sensor failure", "Indoor heating overload/high load alarm", + "Outdoor AC current protection", "Outdoor compressor operation abnormality", "Outdoor DC current protection", + "Outdoor no-load failure", "CT current abnormality", "Indoor cooling freeze protection", + "High and low pressure protection", "Compressor out air temperature is too high", + "Outdoor evaporator sensor failure", "Outdoor cooling overload", "Water pump drainage failure", + "Three-phase power supply failure", "Four-way valve failure", "External alarm/scraper flow switch failure", + "Temperature cutoff protection alarm", "Different mode operation failure", "Electronic expansion valve failure", + "Dual heat source sensor Tw failure", "Communication failure with the wired controller", + "Indoor unit address duplication failure", "50Hz zero crossing failure", "Outdoor unit failure", + "Formaldehyde sensor failure", "VOC sensor failure", "CO2 sensor failure", "Firewall failure", "Unknown"); + HonClimate::HonClimate() : cleaning_status_(CleaningState::NO_CLEANING), got_valid_outdoor_temp_(false), active_alarms_{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -687,9 +709,17 @@ void HonClimate::process_alarm_message_(const uint8_t *packet, uint8_t size, boo if ((packet[2 + i] & alarm_bit) != (this->active_alarms_[i] & alarm_bit)) { bool alarm_status = (packet[2 + i] & alarm_bit) != 0; int log_level = alarm_status ? ESPHOME_LOG_LEVEL_WARN : ESPHOME_LOG_LEVEL_INFO; - const char *alarm_message = alarm_code < esphome::haier::hon_protocol::HON_ALARM_COUNT - ? esphome::haier::hon_protocol::HON_ALARM_MESSAGES[alarm_code].c_str() - : "Unknown"; +#ifdef USE_ESP8266 + // Static so a delayed on_alarm automation can still read it, but only until the next + // alarm change, including a later one in this packet; `code` stays correct + static char alarm_message[HonAlarmMessages::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(alarm_message, + reinterpret_cast( + HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX)), + sizeof(alarm_message)); +#else + const char *alarm_message = HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX); +#endif esp_log_printf_(log_level, TAG, __LINE__, "Alarm %s (%d): %s", alarm_status ? "activated" : "deactivated", alarm_code, alarm_message); if (alarm_status) { diff --git a/esphome/components/haier/hon_packet.h b/esphome/components/haier/hon_packet.h index 63799f04ba1..1d17acb868a 100644 --- a/esphome/components/haier/hon_packet.h +++ b/esphome/components/haier/hon_packet.h @@ -197,60 +197,4 @@ enum class SubcommandsControl : uint16_t { // content: all values like in status packet) }; -const std::string HON_ALARM_MESSAGES[] = { - "Outdoor module failure", - "Outdoor defrost sensor failure", - "Outdoor compressor exhaust sensor failure", - "Outdoor EEPROM abnormality", - "Indoor coil sensor failure", - "Indoor-outdoor communication failure", - "Power supply overvoltage protection", - "Communication failure between panel and indoor unit", - "Outdoor compressor overheat protection", - "Outdoor environmental sensor abnormality", - "Full water protection", - "Indoor EEPROM failure", - "Outdoor out air sensor failure", - "CBD and module communication failure", - "Indoor DC fan failure", - "Outdoor DC fan failure", - "Door switch failure", - "Dust filter needs cleaning reminder", - "Water shortage protection", - "Humidity sensor failure", - "Indoor temperature sensor failure", - "Manipulator limit failure", - "Indoor PM2.5 sensor failure", - "Outdoor PM2.5 sensor failure", - "Indoor heating overload/high load alarm", - "Outdoor AC current protection", - "Outdoor compressor operation abnormality", - "Outdoor DC current protection", - "Outdoor no-load failure", - "CT current abnormality", - "Indoor cooling freeze protection", - "High and low pressure protection", - "Compressor out air temperature is too high", - "Outdoor evaporator sensor failure", - "Outdoor cooling overload", - "Water pump drainage failure", - "Three-phase power supply failure", - "Four-way valve failure", - "External alarm/scraper flow switch failure", - "Temperature cutoff protection alarm", - "Different mode operation failure", - "Electronic expansion valve failure", - "Dual heat source sensor Tw failure", - "Communication failure with the wired controller", - "Indoor unit address duplication failure", - "50Hz zero crossing failure", - "Outdoor unit failure", - "Formaldehyde sensor failure", - "VOC sensor failure", - "CO2 sensor failure", - "Firewall failure", -}; - -constexpr size_t HON_ALARM_COUNT = sizeof(HON_ALARM_MESSAGES) / sizeof(HON_ALARM_MESSAGES[0]); - } // namespace esphome::haier::hon_protocol From c1fa73470e7c4cc6409da6bb97ce6b20a8c34ea5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:31:13 -1000 Subject: [PATCH 344/651] [display] Walk the blit fallback in frame buffer order and resolve the bitness once (#20651) --- esphome/components/display/display.cpp | 80 ++++++++----- esphome/components/display/display.h | 11 ++ .../components/display/display_color_utils.h | 22 ++++ esphome/components/epaper_spi/epaper_spi.h | 1 + esphome/components/it8951/it8951.h | 1 + esphome/components/mipi_spi/mipi_spi.h | 18 +-- tests/components/display/__init__.py | 20 ++++ .../display/test_draw_pixels_at.cpp | 109 ++++++++++++++++++ 8 files changed, 222 insertions(+), 40 deletions(-) create mode 100644 tests/components/display/__init__.py create mode 100644 tests/components/display/test_draw_pixels_at.cpp diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 0bd8346953c..f006c64be76 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -5,6 +5,7 @@ #include "display_color_utils.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::display { @@ -52,40 +53,65 @@ void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_ this->line(x1, y1, x2, y2, color); } -void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { - size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels - uint32_t color_value; +// flatten keeps to_color() inlined in the pixel loop; in PSRAM builds GCC at +// -Os stops inlining it once there are two call sites, which costs a call and +// spills per pixel. Without PSRAM there is one call site and it changes +// nothing, and draw_pixel_at() stays a virtual call either way. +template +static void __attribute__((flatten)) +draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + bool big_endian, int x_offset, int y_offset, int x_pad) { + const size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels + // BITNESS is a constant here, so to_color()'s three per pixel divisions + // become a multiply and shift and its bitness switch folds. + const auto draw_source_pixel = [&](int x, int y, size_t source_idx) ESPHOME_ALWAYS_INLINE { + display->draw_pixel_at( + x + x_start, y + y_start, + ColorUtil::to_color(ColorUtil::read_packed(ptr, source_idx, big_endian), order, BITNESS)); + }; + // The source and frame buffers are row-major, so walking rows keeps the read + // and the write sequential. When the display swaps the axes before writing, + // walking columns keeps the write sequential instead; with the frame buffer + // in PSRAM a strided write costs more than a strided read. +#ifdef USE_PSRAM + if (display->pixel_axes_swapped()) { + size_t column_idx = y_offset * line_stride + x_offset; + for (int x = 0; x != w; x++, column_idx++) { + size_t source_idx = column_idx; + for (int y = 0; y != h; y++, source_idx += line_stride) { + draw_source_pixel(x, y, source_idx); + } + } + return; + } +#endif + // Row walk: builds without PSRAM, and PSRAM builds whose axes are not swapped. for (int y = 0; y != h; y++) { size_t source_idx = (y_offset + y) * line_stride + x_offset; - size_t source_idx_mod; for (int x = 0; x != w; x++, source_idx++) { - switch (bitness) { - default: - color_value = ptr[source_idx]; - break; - case COLOR_BITNESS_565: - source_idx_mod = source_idx * 2; - if (big_endian) { - color_value = (ptr[source_idx_mod] << 8) + ptr[source_idx_mod + 1]; - } else { - color_value = ptr[source_idx_mod] + (ptr[source_idx_mod + 1] << 8); - } - break; - case COLOR_BITNESS_888: - source_idx_mod = source_idx * 3; - if (big_endian) { - color_value = (ptr[source_idx_mod + 0] << 16) + (ptr[source_idx_mod + 1] << 8) + ptr[source_idx_mod + 2]; - } else { - color_value = ptr[source_idx_mod + 0] + (ptr[source_idx_mod + 1] << 8) + (ptr[source_idx_mod + 2] << 16); - } - break; - } - this->draw_pixel_at(x + x_start, y + y_start, ColorUtil::to_color(color_value, order, bitness)); + draw_source_pixel(x, y, source_idx); } } } +void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + switch (bitness) { + case COLOR_BITNESS_565: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + case COLOR_BITNESS_888: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + default: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + } +} + void HOT Display::horizontal_line(int x, int y, int width, Color color) { // Future: Could be made more efficient by manipulating buffer directly in certain rotations. for (int i = x; i < x + width; i++) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 7d1053f4fa7..1a45c4d6750 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -139,6 +139,11 @@ enum DisplayRotation { DISPLAY_ROTATION_270_DEGREES = 270, }; +/// Whether a rotation swaps the x and y axes. +constexpr bool rotation_swaps_axes(DisplayRotation rotation) { + return rotation == DISPLAY_ROTATION_90_DEGREES || rotation == DISPLAY_ROTATION_270_DEGREES; +} + const int EDGES_TRIGON = 3; const int EDGES_TRIANGLE = 3; const int EDGES_TETRAGON = 4; @@ -717,6 +722,12 @@ class Display : public PollingComponent { virtual DisplayRotation get_rotation() const { return this->rotation_; } + /// True when draw_pixel_at() swaps x and y before writing, so pixels that are + /// neighbours along x land a row apart in the frame buffer. Not the same as + /// get_width()/get_height() swapping: a display that rotates in hardware + /// reports swapped dimensions but writes unswapped pixels. + virtual bool pixel_axes_swapped() const { return rotation_swaps_axes(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/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index be39e2a3e2f..3cf4a54999e 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -2,6 +2,7 @@ #include #include #include "esphome/core/color.h" +#include "esphome/core/helpers.h" namespace esphome::display { enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDER_GRB = 2 }; @@ -10,6 +11,27 @@ inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 25 class ColorUtil { public: + /// Reads one packed pixel of the given bitness from a source buffer into the + /// value to_color() takes. BITNESS is a template argument so the byte layout + /// folds; pass it on to to_color() as a constant too, and its channel scaling + /// divides by a constant, which the compiler turns into a multiply and shift. + template + static ESPHOME_ALWAYS_INLINE uint32_t read_packed(const uint8_t *ptr, size_t index, bool big_endian) { + if constexpr (BITNESS == COLOR_BITNESS_565) { + const size_t idx = index * 2; + if (big_endian) + return (ptr[idx] << 8) + ptr[idx + 1]; + return ptr[idx] + (ptr[idx + 1] << 8); + } else if constexpr (BITNESS == COLOR_BITNESS_888) { + const size_t idx = index * 3; + if (big_endian) + return (ptr[idx + 0] << 16) + (ptr[idx + 1] << 8) + ptr[idx + 2]; + return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16); + } else { + return ptr[index]; + } + } + static Color to_color(uint32_t colorcode, ColorOrder color_order, ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) { uint8_t first_color, second_color, third_color; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 7f1167ae6a4..384b9cb0181 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -106,6 +106,7 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } + bool pixel_axes_swapped() const override { return this->effective_transform_ & SWAP_XY; } void draw_pixel_at(int x, int y, Color color) override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index e1273587c45..e99e905ebf5 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -204,6 +204,7 @@ class IT8951Display : public Display, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } + bool pixel_axes_swapped() const override { return this->effective_transform_ & TRANSFORM_SWAP_XY; } protected: int get_height_internal() override { return this->height_; } diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 96f26ef19b7..3a772862f31 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -130,19 +130,8 @@ class MipiSpi : public display::Display, } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } - int get_width() override { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return HEIGHT; - return WIDTH; - } - - int get_height() override { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return WIDTH; - return HEIGHT; - } + int get_width() override { return display::rotation_swaps_axes(this->rotation_) ? HEIGHT : WIDTH; } + int get_height() override { return display::rotation_swaps_axes(this->rotation_) ? WIDTH : HEIGHT; } void set_init_sequence(const uint8_t *sequence, size_t len) { this->init_sequence_ = sequence; @@ -621,6 +610,9 @@ class MipiSpiBuffer #endif } + // With hardware rotation the buffer is written in drawn coordinates. + bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); } + // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { if (this->is_point_clipped(x, y)) diff --git a/tests/components/display/__init__.py b/tests/components/display/__init__.py new file mode 100644 index 00000000000..db75e3e6360 --- /dev/null +++ b/tests/components/display/__init__.py @@ -0,0 +1,20 @@ +from pathlib import Path + +import esphome.codegen as cg +from esphome.const import CONF_ESPHOME, CONF_INCLUDES +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + +TESTS_DIR = Path(__file__).parent + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The column walk in Display::draw_pixels_at() is only built with PSRAM; define it + # when display's own tests are built so both walk orders run, and leave other + # components' test binaries on the plain build. + async def to_code_testing(config: ConfigType) -> None: + if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]): + cg.add_define("USE_PSRAM") + + manifest.to_code = to_code_testing diff --git a/tests/components/display/test_draw_pixels_at.cpp b/tests/components/display/test_draw_pixels_at.cpp new file mode 100644 index 00000000000..c8c32d15145 --- /dev/null +++ b/tests/components/display/test_draw_pixels_at.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/display/display.h" + +namespace esphome::display::testing { + +// Records every pixel write; pixel_axes_swapped() picks the walk order. +class RecordingDisplay : public Display { + public: + void set_axes_swapped(bool swapped) { this->axes_swapped_ = swapped; } + bool pixel_axes_swapped() const override { return this->axes_swapped_; } + void draw_pixel_at(int x, int y, Color color) override { this->pixels_.emplace_back(x, y, color.raw_32); } + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return 64; } + int get_height_internal() override { return 64; } + + std::vector> sorted_pixels() const { + auto sorted = this->pixels_; + std::sort(sorted.begin(), sorted.end()); + return sorted; + } + size_t count() const { return this->pixels_.size(); } + const std::vector> &pixels() const { return this->pixels_; } + + protected: + bool axes_swapped_{false}; + std::vector> pixels_; +}; + +// A 7x5 region with a 2 pixel left offset, a 3 line top offset and 4 pixels of +// right padding, so the source index arithmetic of both walks is exercised. +static constexpr int W = 7, H = 5, X_OFFSET = 2, Y_OFFSET = 3, X_PAD = 4; +static constexpr int LINE_STRIDE = X_OFFSET + W + X_PAD; +static constexpr int LINES = Y_OFFSET + H; + +static std::vector make_source(int bytes_per_pixel) { + std::vector source(LINE_STRIDE * LINES * bytes_per_pixel); + uint8_t value = 1; + for (auto &byte : source) + byte = value++; + return source; +} + +static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool big_endian) { + const auto source = make_source(bytes_per_pixel); + RecordingDisplay rows; + RecordingDisplay columns; + columns.set_axes_swapped(true); + rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + EXPECT_EQ(rows.count(), static_cast(W * H)); + EXPECT_EQ(columns.count(), static_cast(W * H)); + EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels()); +} + +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888BigEndian) { expect_walks_match(COLOR_BITNESS_888, 3, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888LittleEndian) { expect_walks_match(COLOR_BITNESS_888, 3, false); } + +// One known pixel per bitness and byte order, so a decode that swapped bytes or channels +// could not pass the walk comparison above by being wrong the same way twice. +static void expect_golden(ColorBitness bitness, std::initializer_list bytes, bool big_endian, Color expected) { + const std::vector source(bytes); + RecordingDisplay display; + display.draw_pixels_at(3, 4, 1, 1, source.data(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + ASSERT_EQ(display.count(), 1u); + EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32)); +} + +TEST(DrawPixelsAt, Golden565BigEndian) { + expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, true, Color(230, 0, 57)); // 0xE007: r 28, g 0, b 7 +} +TEST(DrawPixelsAt, Golden565LittleEndian) { + expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, false, Color(0, 255, 0)); // 0x07E0: pure green +} +TEST(DrawPixelsAt, Golden888BigEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x10, 0x20, 0x30)); +} +TEST(DrawPixelsAt, Golden888LittleEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, false, Color(0x30, 0x20, 0x10)); +} +TEST(DrawPixelsAt, Golden332) { + expect_golden(COLOR_BITNESS_332, {0xE3}, true, Color(255, 0, 255)); // r 7 of 7, g 0, b 3 of 3 +} + +// The column walk really is a different order, otherwise the tests above prove nothing. +TEST(DrawPixelsAt, ColumnWalkChangesOrder) { + const auto source = make_source(1); + RecordingDisplay rows; + RecordingDisplay columns; + columns.set_axes_swapped(true); + rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, + X_PAD); + ASSERT_GT(rows.count(), 1u); + ASSERT_GT(columns.count(), 1u); + EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first + EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first +} + +} // namespace esphome::display::testing From 887d4c3a8337d4c62c391b90176fe97b9b1ee798 Mon Sep 17 00:00:00 2001 From: Ryan Ludwig <20423360+steamEngineer@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:36:15 -0700 Subject: [PATCH 345/651] [esp32_can] Add NO_ACK mode (#20706) Co-authored-by: Cursor --- esphome/components/esp32_can/canbus.py | 1 + esphome/components/esp32_can/esp32_can.cpp | 7 +++++-- esphome/components/esp32_can/esp32_can.h | 1 + tests/components/esp32_can/common.yaml | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index ae299370cd7..f8f76b2d578 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -47,6 +47,7 @@ CanMode = esp32_can_ns.enum("CanMode") CAN_MODES = { "NORMAL": CanMode.CAN_MODE_NORMAL, "LISTENONLY": CanMode.CAN_MODE_LISTEN_ONLY, + "NO_ACK": CanMode.CAN_MODE_NO_ACK, } # Currently the driver only supports a subset of the bit rates defined in canbus diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index d84b58c5b0c..ee677a08005 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -79,10 +79,13 @@ bool ESP32Can::setup_internal() { } // Select TWAI mode based on configuration - twai_mode_t twai_mode = (this->mode_ == CAN_MODE_LISTEN_ONLY) ? TWAI_MODE_LISTEN_ONLY : TWAI_MODE_NORMAL; - + twai_mode_t twai_mode = TWAI_MODE_NORMAL; if (this->mode_ == CAN_MODE_LISTEN_ONLY) { + twai_mode = TWAI_MODE_LISTEN_ONLY; ESP_LOGI(TAG, "CAN bus configured in LISTEN_ONLY mode (passive, no ACKs)"); + } else if (this->mode_ == CAN_MODE_NO_ACK) { + twai_mode = TWAI_MODE_NO_ACK; + ESP_LOGI(TAG, "CAN bus configured in NO_ACK mode (transmit without requiring ACK)"); } twai_general_config_t g_config = diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f9549bea346..176a3b57f5e 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -12,6 +12,7 @@ namespace esphome::esp32_can { enum CanMode : uint8_t { CAN_MODE_NORMAL = 0, CAN_MODE_LISTEN_ONLY = 1, + CAN_MODE_NO_ACK = 2, }; class ESP32Can final : public canbus::Canbus { diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index a26c5d35a34..01b208995df 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -18,7 +18,7 @@ canbus: tx_pin: ${tx_pin} can_id: 4 bit_rate: 50kbps - mode: NORMAL + mode: NO_ACK on_frame: - can_id: 500 then: From 60a1d70805b4cc810928ea4174af3c7b3bcc8fde Mon Sep 17 00:00:00 2001 From: Dmitrii Kuminov Date: Tue, 13 Oct 2026 20:46:54 -0700 Subject: [PATCH 346/651] [core] Move deferred include processing to parse_yaml (#16344) 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/yaml_util.py | 14 ++++++++++---- tests/unit_tests/test_yaml_util.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b5e2ec17e8a..261d8b50e36 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1120,8 +1120,12 @@ def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> An res = parse_yaml(fname, f_handle, track_document_range=track_document_range) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err - # Top-level !include returns a deferred IncludeFile; resolve it so - # callers always receive the final content. + return res + + +def _resolve_include(res: Any) -> Any: + # A document that is just `!include` parses to a deferred IncludeFile; resolve it so + # every parse_yaml caller, including the VS Code and ACE loaders, gets the final content. if isinstance(res, IncludeFile): res = res.load() if res.should_load() else {} return res @@ -1147,7 +1151,7 @@ def parse_yaml( # tracked mode; reject the combination instead of half-applying it. raise ValueError("track_document_range=False requires the default yaml_loader") try: - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomeLoader, file_name, file_handle, @@ -1159,13 +1163,15 @@ def parse_yaml( # readable exceptions # Rewind the stream so we can try again file_handle.seek(0, 0) - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomePurePythonLoader, file_name, file_handle, yaml_loader, track_document_range=track_document_range, ) + # Resolved after the fallback so a failed first parse never loads the include twice + return _resolve_include(res) def _load_yaml_internal_with_type( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index c3276823ecc..655fac6bf26 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -954,6 +954,23 @@ def test_yaml_merge_chain_include_resolves() -> None: assert config["base"]["existing"] == "value" +def test_parse_yaml_custom_loader_resolves_root_include() -> None: + """A document that is only `!include` is resolved for a custom loader too.""" + parent = Path("/fake/main.yaml") + loader_calls: list[Path] = [] + + def custom_loader(fname: Path) -> dict[str, int]: + loader_calls.append(fname) + return {"x": 1} + + config = yaml_util.parse_yaml( + parent, io.StringIO("!include inner.yaml\n"), custom_loader + ) + + assert config == {"x": 1} + assert loader_calls == [Path("/fake/inner.yaml")] + + def test_yaml_merge_chain_include_depth_exceeded() -> None: """Chain includes in merge keys exceeding depth limit raise a clear error.""" parent = Path("/fake/main.yaml") From 1338902ac5e5f098801e01d79f42d9d0e1422b26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 18:06:22 -1000 Subject: [PATCH 347/651] [heatpumpir] Link only the configured protocol classes (#20707) --- esphome/components/heatpumpir/climate.py | 4 +- esphome/components/heatpumpir/heatpumpir.cpp | 132 ++++++++++--------- esphome/components/heatpumpir/heatpumpir.h | 32 +++-- 3 files changed, 90 insertions(+), 78 deletions(-) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 2583839ca81..42ef79d31d2 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -126,8 +126,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = await climate_ir.new_climate_ir(config) - cg.add(var.set_protocol(config[CONF_PROTOCOL])) + tag = heatpumpir_ns.ProtocolTag.template(config[CONF_PROTOCOL]) + var = await climate_ir.new_climate_ir(config, tag()) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index b4597fe4abf..a23d69e3b22 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -5,7 +5,6 @@ #if defined(USE_ARDUINO) || defined(USE_ESP32) #include -#include #include #include #include "esphome/components/remote_base/remote_base.h" @@ -45,71 +44,76 @@ class IRSenderESPHome : public IRSender { ESPHOME_LOG_TAG(TAG, "heatpumpir.climate"); -const std::map> PROTOCOL_CONSTRUCTOR_MAP = { - {PROTOCOL_AUX, []() { return new AUXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BALLU, []() { return new BalluHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_MCA, []() { return new CarrierMCAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_NQV, []() { return new CarrierNQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC417, []() { return new DaikinHeatpumpARC417IR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC480, []() { return new DaikinHeatpumpARC480A14IR(); }}, // NOLINT - {PROTOCOL_DAIKIN, []() { return new DaikinHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ELECTROLUXYAL, []() { return new ElectroluxYALHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUEGO, []() { return new FuegoHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUJITSU_AWYZ, []() { return new FujitsuHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREE, []() { return new GreeGenericHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAA, []() { return new GreeYAAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAN, []() { return new GreeYANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAC, []() { return new GreeYACHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYT, []() { return new GreeYTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAP, []() { return new GreeYAPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HISENSE_AUD, []() { return new HisenseHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HITACHI, []() { return new HitachiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HYUNDAI, []() { return new HyundaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_IVT, []() { return new IVTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MIDEA, []() { return new MideaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FA, []() { return new MitsubishiFAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FD, []() { return new MitsubishiFDHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FE, []() { return new MitsubishiFEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_FDTC, []() { return new MitsubishiHeavyFDTCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZJ, []() { return new MitsubishiHeavyZJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZM, []() { return new MitsubishiHeavyZMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZMP, []() { return new MitsubishiHeavyZMPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_KJ, []() { return new MitsubishiKJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSC, []() { return new MitsubishiMSCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSY, []() { return new MitsubishiMSYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_SEZ, []() { return new MitsubishiSEZKDXXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_CKP, []() { return new PanasonicCKPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_DKE, []() { return new PanasonicDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_EKE, []() { return new PanasonicEKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_JKE, []() { return new PanasonicJKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_LKE, []() { return new PanasonicLKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_NKE, []() { return new PanasonicNKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV, []() { return new SamsungAQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_FJM, []() { return new SamsungFJMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SHARP, []() { return new SharpHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA_DAISEIKAI, []() { return new ToshibaDaiseikaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA, []() { return new ToshibaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHLT01, []() { return new ZHLT01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_NIBE, []() { return new NibeHeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_1, []() { return new Qlima1HeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_2, []() { return new Qlima2HeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV12MSAN, []() { return new SamsungAQV12MSANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHJG01, []() { return new ZHJG01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_AIRWAY, []() { return new AIRWAYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BGH_AUD, []() { return new BGHHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_ALTDKE, []() { return new PanasonicAltDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PHILCO_PHS32, []() { return new PhilcoPHS32HeatpumpIR(); }}, // NOLINT - {PROTOCOL_VAILLANTVAI8, []() { return new VaillantHeatpumpIR(); }}, // NOLINT - {PROTOCOL_R51M, []() { return new R51MHeatpumpIR(); }}, // NOLINT -}; +template struct ProtocolClass; + +template HeatpumpIR *create_heatpump_ir() { + return new typename ProtocolClass